# 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.** A later revision then reported the residual as 0.21 deg worst with the yaw limit "not the constraint". **That was also wrong**, in the opposite direction: the limit itself was wrong. The bench confirmed the yaw stage turns a **full half-turn either way (+-180 deg)**, not +-90. With the corrected envelope, solved by closed form (see `inverse-kinematics.md`): | Envelope | Worst residual | Within 0.5 deg | In range | |---|---|---|---| | pitch 90 deg, **yaw +-180 deg** (the machine) | **1.2e-06 deg** | 100% | **1032 / 1032** | Every vertex is reachable, and the residual is at the floating-point floor rather than at any clamp. The "0.21 deg worst, genuinely clamped vertex" claim above was an artefact of the wrong limit and no longer holds. **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. **Correction (motion-correction-plan.zh.md task 1).** This note used to claim that **51 of 1032 vertices end inverted** whichever branch is chosen, because the tool direction was antiparallel to the sensor's own up axis. That count came from `uprightness` comparing a platform axis through `to_sensor(np.eye(3)[1])`, which is the sensor's own **-Z** — a horizontal axis — so a body rolled about the vertical was reported as turned over. Measured against the post-mount nominal up (the tool axis) no exported vertex is inverted, and the corrected flag changes no vertex's branch selection. What did invert poses was the `approach_sign` polarity bug recorded above; the residual could not see it, and neither could that up test. See `inverse-kinematics.md` → *the face is aimed at +Z* and *the two exact poses of one normal*. ## 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. The scene, the palette and the framing rule are described in [finger-preview.md](finger-preview.md) | | `motion/kinematics.py` | closed-form solve, exact to 1.2e-06°; the sweep is kept only as the reference the tests check it against — see [inverse-kinematics.md](inverse-kinematics.md) | | 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 | | `tools/preview_snapshot.py` | photographs the 3D pane with a real GL context, at rest or mid-playback, pane-only or composited into the page | Not yet built: capture of picked points into the sequence, and the live-sensor counterpart of the simulated pose.