Files
ykcheungab bcc55a3b97 修正朝上判断、接触高度与过期姿态目标
朝上判断误用平台轴、接触高度报旋转前数值、多点对准复用缓存姿态,且多点会话被复核入口拒绝;
现按修正计划改正并接通 autocal-multipoint-1.0 只读复核,缺失帧源如实报告而不补零。

离线验证:新回归在 dc5a6f8 上失败、此处通过,panel_check 通过,两截图点接触高度复现
19.0510 / 21.5781 mm;实机项未做,详见 .scratch/autocal-motion-fix/verification.md。
2026-10-15 06:52:28 +08:00

236 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# The finger screen's 3D view
What the picture draws, what each colour means, and which of those meanings is a
measurement rather than a convenience. Written because the view had grown two
coordinate systems, a path, three markers and four competing hues, and the question
"which line is which?" was being answered from memory.
## What is in the scene
| Element | Colour | What it is |
| --- | --- | --- |
| Sensing surface | green → red, shaded per vertex | **The measurement.** Green is an aligned normal, red is `BAD_RESIDUAL_DEG` (20°) or worse of residual. The shading stays on during playback. |
| Shell | dark grey wireframe | The housing mesh from the export, drawn so the face has a body to belong to. |
| Origin plane | grey grid, translucent | The platform's `z = 0` plane, i.e. the plane the two rotation axes lie in. **This is the initial state and it never moves.** |
| Table marker | violet ring + cross-hairs | The table's *own* frame as it now stands: the stages' rotation and the table's XY applied to a circle drawn in the origin plane. While a move has not finished, a fainter ring shows where it ends up. Square grid = before, violet ring = now, faint ring = after. |
| Table triad | red / green / blue, full strength | The machine's X/Y/Z, drawn at the **table origin**. The only full-strength triad in the picture, so "the machine" is readable at a glance. |
| Rotation axes | grey, long | The yaw and pitch axes. They must cross at the one origin; a misalignment shows as the two lines missing each other instead of hiding inside the body. |
| Origin | orange dot | The point the stages turn and the tables translate *reference to*. |
| Base plane | amber outline | The sensor's mounting face: part of the **body**, so it turns with the stages and rides the table. Its height in the initial state is the sensor's z setup offset — the mounting plate. |
| Tool axis | yellow line, arrowhead | The `+Z` direction the sensing face has to meet, drawn at the tool's own position in the table frame. **It does not follow the contact**: the tool is bolted to the machine and the table brings the point to it. |
| Planned path | cyan line with arrowheads, cyan end dot | The contact's route: the stage rotation arc about the table origin, then the straight table move. |
| Picked contact | white dot | The point that was picked on the UV map. It travels onto the tool during playback. |
| Contact normal | blue line, arrowhead | The picked vertex's normal. It is what the solved rotation aims at the tool axis. |
| Selected point | red dot | The point selected in the table below — the one a run would start from. |
## Setup: where the tool and the sensor actually are
Three numbers that are the same on the drawing and never the same on the bench. All
three default to zero, and all three have to be measured on the real machine:
| Field | Meaning |
| --- | --- |
| **Tool X / Y** | The tool's position in the table frame. Every XY move exists to bring the contact here, so this is what the reported move is measured against. It used to be taken as the patch's centre — a stand-in that made the picture self-consistent and the numbers meaningless. **A tool-tip offset is this field**, not a second one. It *was* a second one, added to the reported coordinates while the drawing left the geometry alone: a machine with a 10 mm tip offset reported one landing point and drew another, 10 mm away. A stored `finger_tool_offset_mm` is folded in here when preferences load. |
| **Sensor X / Y** | How far the mounted sensor is from the table origin, along the table's own axes. |
| **Sensor Z / yaw** | The mounting height — **this is the mounting plate**, shims included, so there is no separate plate field — and the sensor's misalignment with the table about its z. |
### What the screen shows at rest, and what playing it does
The screen is a solve view, so **at rest everything stands at the end of the move**:
the body, the contact marker, the tool's position and the table marker. The fixed
grid is the frame they started in and the cyan path is drawn from where the contact
was, so "will it reach, and where does the table finish" is answerable without
watching anything. Pressing play runs the move from the initial state to that same
end state — which is what makes the path checkable rather than decorative.
The one line at the bottom of the rail is the x-y check, printed as two pairs:
```
落点 X/Y +10.000 +0.000 mm / 刀具 X/Y +10.000 +0.000 mm ✓ 已对准
```
They are computed two different ways on purpose — the landing by pushing the contact
through the *drawing's* transform, the tool from its setup number — so "x and y are
aligned" is a measurement rather than a judgement about a picture. Note that the
reported move is **not** simply `tool − platform position`: the stages turn the point
about the origin first, so the move is `tool_xy − (R_pitch · R_yaw · p_table).xy`.
When the two do not match, the line says so, with the distance.
A point of view worth knowing: at rest the sensing face is aimed along the tool axis,
which can put the face on the far side of the body from the default camera. The tool
axis is drawn with depth testing **off** for that reason — it is a reference line,
and hiding it behind the body it is meant to align with is the opposite of useful.
## Running the pose on the machine
Three buttons, and where each one lives is deliberate:
| Button | Where | What it does |
| --- | --- | --- |
| **执行动作** Execute | this screen, next to 播放动作 | Sends the selected point's solved pose to the platform: the stages together with the table X/Y, and the press axis **only when its zero is calibrated** (设备设置 → 压轴). The geometric contact height is not a controller coordinate, so an uncalibrated press axis is *held* and reported as held rather than commanded with a number the machine has not measured. |
| **停止** Stop | here *and* the strip's 全部停止 | The same command. Two access points, one implementation: during a move the operator's eyes are on this pane, and an emergency stop has to be where they are. |
| **回初始位** Return to zero | the axis rail, on every tab | Every drive to 0 — press axis first, then the stages, then the table. A machine-level action, wanted after work in any tab, so it is not repeated inside one. |
**移动前确认** decides whether the moving buttons ask first. The prompt lists the steps
with their numbers and the drive each one uses, and says what is *not* being moved.
The order is the model's own: the kinematics composes `R_pitch · R_yaw`, so yaw acts on
the body first and the machine yaws first — every intermediate pose is one the preview
drew. Table X/Y last, because the translation is what the rotations are resolved
against.
Each drive is commanded on its own and the next goes out only once the previous has
arrived, which is what keeps the move interruptible; a step that stops short, times out
or alarms aborts the rest rather than finishing a sequence nobody has verified. What is
checked *before* the first axis moves — the mapping, the P0 limits, the rotary
calibration — is in `../README.md` under **Safety model**.
The chain the solver and the drawing share is, in order: **mount** (sensor axes into
table axes) → **setup** (the mounting pose above) → **stages** (the solved yaw and
pitch, about the table origin) → **table** (the XY slide). A point of the sensor is
therefore at
```
p_table = R_z(sensor_yaw) · (mount · p_sensor) + sensor_offset
p_final = R_pitch · R_yaw · p_table + (table_x, table_y, 0)
```
and the solver reports the move that puts the contact at the tool:
```
(table_x, table_y) = tool_xy − (R_pitch · R_yaw · p_table).xy
```
Two consequences worth knowing, both covered by tests:
* the mounting yaw is **absorbed by the stage** — a sensor mounted 10° out needs the
yaw stage to turn 10° less, which is why the field exists;
* the mounting z is a lift along the table's z, which the mounting yaw leaves alone,
which is why the plate can live in that same field instead of being a second
number that drifts away from it.
`Pose.target_xy_mm` says where the contact *is*; `Pose.table_xy_mm` says what the
table must *do* about it. The panel shows both, and the trajectory summary is the
same claim again: the contact's last drawn position **is** the tool, to within
1e-6 mm (`test_the_path_ends_where_the_pose_says_the_table_moves_to`).
## The path, and where it starts
`trajectory()` returns the contact's route in the order the machine moves it:
1. **rotation** — the stages turn the mounted body about the table origin, sweeping
the contact along an arc whose radius is the contact's distance from that origin
(reported as `arc_radius_mm`);
2. **translation** — the table slides the contact onto the tool axis, in the table's
own XY, in a straight line (reported as `reach_mm`, the total distance travelled).
Nothing in the path is *at* the table origin. The origin is what the stages turn
about; the path is traced by a point on the body, so it starts at the picked contact
and ends under the tool. The two numbers a reader needs in order to judge the path —
how far the contact travels, and how large the swing is — are printed beside the
readouts.
The palette lives in one place, `pose_preview.COLOUR`, with a rule the view used to
break: **a colour identifies exactly one thing**. Two things sharing a hue read as a
duplicate or a contradiction, which is what "messy" turned out to mean. An import
check (`_check_palette_has_no_collisions`) refuses a duplicate, and
`set_colour`/`set_rgb` accept either 3- or 4-channel entries so the length of a
palette tuple can never abort a paint again — it did once, and silently deleted the
base plane, the contact and the whole trajectory from the view.
## Two ways the picture can lie
Both were real, and both looked like a plausible picture rather than a fault:
* **A half-drawn scene.** A `TypeError` from a colour call inside `paintGL` abandons
every draw call after it. The remaining picture still looks like a picture, so the
failure is invisible until someone counts what should be there. `preview.problem`
now reaches the panel through `problemChanged`, and is drawn on the widget.
* **A camera that frames the wrong thing.** The eye distance was
`half the mesh's bounding-box diagonal × 3`, which cropped the table plane and the
far end of the trajectory exactly when a pose swung them outwards. Framing now
solves the distance from the field of view and the bounding sphere of everything
drawn (`scene_points`, `scene_radius`, `_frame_distance`), so the whole scene fits
at the default zoom at any pane shape. Long reference lines are excluded on
purpose: they are meant to run off the edge, and including them would shrink the
sensor to a speck.
* **A render and a projection that disagreed.** Two separate faults, both invisible
on their own and both found by checking a projected landmark against the pixels:
`project()` left out the frustum's near-plane factor (`x_ndc = (near/right)·x/depth`),
scaling every projected point by about 100; and `_draw` aimed the camera at the
*raw* mesh centre while `_camera`/`project` orbited the *placed* one, so the drawn
scene and the projected overlay sat in different places as soon as the body was
drawn anywhere but at the identity. The axis labels were the visible casualty.
`preview_snapshot.py --debug` now reports, for every shot, how many pixels of a
known colour are near the projection of a known point — origin, tool line, table
marker — so a drift shows up as a number instead of a shrug.
## The path, and where it starts
`trajectory()` returns the contact's route in the order the machine moves it:
1. **rotation** — the stages turn the body about the table origin, sweeping the
contact along an arc whose radius is the contact's distance from that origin
(reported as `arc_radius_mm`);
2. **translation** — the table slides the contact onto the tool axis in a straight
line (reported as `reach_mm`, the total distance travelled).
Nothing in the path is *at* the table origin. The origin is what the stages turn
about; the path is traced by a point on the body, so it starts at the picked contact
and ends under the tool. The two numbers a reader needs in order to judge the path —
how far the contact travels, and how large the swing is — are printed next to the
readouts.
The drawn path and the animation are the same transform by construction:
`trajectory()` samples `body_transform()`, the function the drawing uses. They did
not always: the path was sampled up to `SIM_ROTATION_SHARE` of the solved rotation —
the *time* that phase takes in the animation, not how far it turns — so the drawn
arc ended 45% short and the straight move began from a point the machine never
passes through. `test_the_drawn_path_ends_where_the_animation_puts_the_contact` pins
them together, and `test_the_path_ends_where_the_pose_says_the_table_moves_to` pins
both to the reported move.
## Frames, in one line
The mesh is stored in sensor coordinates and drawn through `body_transform`; the
machine's own frame is drawn separately, in mesh coordinates, by `table_frame()`.
Every frame conversion goes through `PlatformStack` — `mount`, `setup_rotation`,
`setup_point`, `platform_rotation`, `stage_rotation` — and never through a second
copy composed in the view. Two copies of a frame convention is what produced two
wrong orientations in opposite directions, and it is the mistake this screen was
built to stop making.
## Reproducing a picture
The GL pane needs a real context, so the offscreen platform cannot photograph it:
`tools/panel_snapshot.py` renders the window but leaves the 3D pane empty.
```powershell
# the pane itself, large enough to judge
python apps/autocal/tools/preview_snapshot.py --bundle <bundle> --tab sequence `
--pane 1000x760 --points 3 --out pane.png
# the same, caught mid-playback
python apps/autocal/tools/preview_snapshot.py --bundle <bundle> --tab sequence `
--pane 1000x760 --points 1 --play 0.5 --out playing.png
# with a real setup: sensor x,y,z,yaw and the tool's x,y
python apps/autocal/tools/preview_snapshot.py --bundle <bundle> --tab sequence `
--pane 1000x760 --points 1 --setup "6,-3,5,12" --tool "7,4" --play 1.0 `
--out tuned.png
# the whole page, with the pane composited in
python apps/autocal/tools/preview_snapshot.py --bundle <bundle> --tab finger `
--size 1920x1080 --points 3 --whole --out page.png
```
`--debug` prints the state and, most usefully, where the claims land on screen: the
path's end and the tool must project to the same pixel, and it prints the distance
between them in mm so "they look close" is never the evidence.
Two things that tool has to do and are easy to get wrong: the pane is **hidden** for
the read-back (a visible `QOpenGLWidget` is painted by the window system, which a
session with no interactive desktop never does, so its framebuffer stays blank), and
it retries until the framebuffer is not one flat colour (`grabFramebuffer` returns a
valid, blank image before the first paint).