# Finger calibration-point debugger — findings so far Status: subsystem 1 (UV picking) works and is measured. Subsystem 2 (simulated 5-axis pose) works and is measured. This note records the numbers, and the two solver bugs that produced confident wrong ones on the way here. ## What is established ### The export bundle (`finger/r01`, `compute` profile) | Quantity | Value | |---|---| | Mesh | 1032 vertices, 1974 triangles (matches `profiles/compute.yaml`) | | UV atlas | **one connected island**, u 0→1, v 0.142→0.857 | | Pose | `world_to_cam`, uniform scale `0.018388452316735537`, `det(R) = +1` | | Physical size | sensing **15.0 × 7.5 × 15.81 mm**; housing 16.0 × 9.5 × 21.0 mm | | Surface area | 355.77 mm² | | Origin | housing base at `y ≈ 0`; sensing spans `y ≈ 0.99 … 8.49 mm` | | Curvature | ~12% of area within 15° of one axis; area-weighted normal ≈ `(0, 0.57, 0.26)` | **The mesh is not in millimetres.** `blender/scripts/export.py` transforms vertices by `t_sensor_from_world @ world`, and the pose's linear block carries the `origin_sensor` empty's own uniform scale — 1/54.4 on `finger/r01`. Physical size is *mesh units × that factor*, so the factor is derived from the file (and refused if it is not a uniform rotation) rather than assumed. A consumer that treats the vertices as millimetres is wrong by 54×. ### UV picking `rasterise()` builds, in one pass, a flat image of the UV layout **and** a `pixel → (position, normal, face)` table, at 1024×1024 in 0.24 s. Measured: - valid (surface-bearing) pixels: 72.9% - round trip `vertex → its UV pixel → looked-up 3D`: worst error **0.0081 mm** - a pixel in a UV-island gap returns `None` rather than a fabricated `(0,0,0)` Because the atlas is a single island, a click maps to exactly one 3D point. ## The supposed finding, and its retraction An earlier revision of this note reported that the **yaw +-90 deg limit made a quarter of the patch unalignable**, worst residual 65.1 deg, only 47 of 61 sampled points reachable. **That was a solver bug, not a property of the machine.** The corrected solver solves the real surface as: | Envelope | Worst residual | Median | Within 0.01 deg | Within 0.5 deg | |---|---|---|---|---| | pitch 360 deg, **yaw +-90 deg** (the machine) | **0.21 deg** | 0.00 deg | **98.4%** | **100%** | | pitch 360 deg, yaw +-180 deg | 0.24 deg | 0.00 deg | — | 100% | So **every** vertex of the sensing patch is reachable, and widening the yaw range buys essentially nothing (0.21 deg → 0.24 deg worst). The 0.21 deg worst case is a genuinely clamped vertex: the sweep's own minimum at that vertex is 0.211 deg at the yaw clamp, so the number is physical, not lost precision. Why the yaw limit is not the constraint, which the earlier note had backwards: - The pitch stage turns a vector around a circle in the plane **perpendicular to the pitch axis**. - The yaw stage can reach a given great circle as long as the yawed normal is not parallel to the pitch axis. A normal parallel to the pitch axis after yawing would have to be perpendicular to the tool axis already, and the finger patch normals — which point along sensor +Y, mapping to platform **+Z**, the tool direction — are nothing of the sort. - Pitch travel is therefore what does the work, and the 360 deg stage provides it. **No mounting change is required for reachability.** The three options the earlier note listed (re-mount, state a tolerance, index the plate) are all unnecessary on the reachability evidence. Whether they help *repeatability* — a shorter pitch move, a better-conditioned contact — is a separate bench question, not a kinematic one. ## What was wrong before, and why it is worth recording **Four** solver versions preceded this one, each wrong in a way that produced confident numbers: - rotating the **sensor-frame** normal while the stages act in the platform frame (uniform ~90 deg error); - composing the stages in the wrong order; - a **seeded** search, which left 22.7 deg on the table for normals whose best yaw sat far from the analytic guess; - a sweep whose yaw ranking used "how close does the yawed normal come to the tool axis", which is **not** the quantity that matters. What matters is where the whole circle the pitch stage sweeps sits. Ranking by the wrong one left **68 deg** on the table while looking entirely reasonable — this is the bug that produced the retracted 65 deg finding above. The solver is now a sweep of every yaw/pitch combination, evaluated as a grid one yaw at a time (7.8 ms per point, 1032 vertices in 9 s), then a local polish to 1e-4 deg. It is verified two ways: it is **never worse than a brute-force search** at 2 deg on random normals, and its returned residual matches an **independently composed rotation matrix** to 8e-8 deg. Both checks are tests, not one-off probes. The residual is measured from the actual rotations, so a wrong derivation shows up as a number rather than as a plausible pose. ## Implemented so far | Module | State | |---|---| | `sensor/frames.py` | mount mapping as a named constant; refuses a non-rotation | | `sensor/export_bundle.py` | load + validate; derives mm/unit; refuses a non-uniform or mirrored pose, and count mismatches | | `sensor/uv_flat.py` | flat image + pixel→3D table; gap pixels are `None` | | `motion/kinematics.py` | constrained search + measured residual; stage limits are parameters; singular case reported, not guessed | | `ui/sensor_panel.py` | picker, readouts, point table; refuses gap pixels with a reason; picked rows kept at full precision, not re-read from the 3-decimal display text | | `ui/pose_preview.py` | 3D preview coloured by residual, tool axis drawn; the legend is a sibling label, because text composited over the GL surface is unreadable once it overflows | | workspace `finger` tab | wired, checked at 1440×900 and 1920×1080 in both themes | | startup | the remembered bundle loads automatically, so the tab comes up working instead of showing a filled-in path over two empty views | Not yet built: capture of picked points into the sequence, and the live-sensor counterpart of the simulated pose.