added motion trajectory simulation
This commit is contained in:
+10
-3
@@ -95,7 +95,7 @@ apps/autocal/
|
||||
motion/ # Qt-free except poller.py (the Qt worker bridge)
|
||||
models.py errors.py units.py service.py transport.py poller.py
|
||||
ui/ # window.py + one module per panel
|
||||
tools/ # panel_check.py, panel_snapshot.py
|
||||
tools/ # panel_check.py, panel_snapshot.py, preview_snapshot.py, layout_mockup.py
|
||||
tests/
|
||||
```
|
||||
|
||||
@@ -109,20 +109,27 @@ between threads. The UI talks to it through signals only.
|
||||
Both work with no display, no controller and no DLL:
|
||||
|
||||
```powershell
|
||||
python .\apps\autocal\tools\panel_check.py # 120 layout/theme/locale/gating checks
|
||||
python .\apps\autocal\tools\panel_check.py # 170 layout/theme/locale/gating checks
|
||||
python .\apps\autocal\tools\panel_snapshot.py --demo --theme dark --out shot.png
|
||||
python .\apps\autocal\tools\panel_snapshot.py --compare --out tabs.png # one image per tab
|
||||
python .\apps\autocal\tools\layout_mockup.py --out layout.png # proposed layout, drawn
|
||||
python -m pytest apps\autocal\tests # offscreen suite
|
||||
```
|
||||
|
||||
`preview_snapshot.py` is the one tool that can photograph the **3D pane**, which
|
||||
`panel_snapshot.py` cannot: the offscreen platform has no OpenGL context at all, so
|
||||
the pane is empty in those shots. It needs the real platform plugin and a visible
|
||||
window, and it writes either the pane alone or the whole page with the pane
|
||||
composited in. What the view draws, and what each colour means, is in
|
||||
[docs/finger-preview.md](docs/finger-preview.md).
|
||||
|
||||
`layout_mockup.py` is a **wireframe, not the application**: it paints the
|
||||
proposed single-page layout with the shared theme tokens and imports nothing
|
||||
from the app, so the layout can be reviewed and marked up without building the
|
||||
panels. It draws the full page plus previews of the other workspace tabs in one
|
||||
canvas.
|
||||
|
||||
`panel_check.py` builds the real window in both themes at 1280×720, 1600×900 and
|
||||
`panel_check.py` builds the real window in both themes at 1440×900 and
|
||||
1920×1080, asserts that no widget escapes its parent, that motion is
|
||||
unreachable before the bus is initialised, that the SDO/RTEX panels follow the
|
||||
reported bus family, and then runs a full simulated session from `ECAT.bas`.
|
||||
|
||||
@@ -210,20 +210,37 @@
|
||||
"row_sensor_normal": "Surface normal",
|
||||
"row_platform_point": "Platform point (mm)",
|
||||
"row_pose": "Pose X / Y / yaw / pitch",
|
||||
"row_table_move": "Table move X / Y",
|
||||
"row_residual": "Alignment residual",
|
||||
"row_frames": "Frame mapping",
|
||||
"row_bundle": "Bundle",
|
||||
"bundle_summary": "{vertices} vertices · {triangles} triangles · {extent} mm · {scale} mm per unit",
|
||||
"plate_thickness": "Mounting plate",
|
||||
"tool_offset": "Tool offset x,y,z",
|
||||
"tool_xy": "Tool X / Y",
|
||||
"tool_hint": "Where the tool is in the table frame, straight above the table origin by default. The table move is what carries the contact to this point. Measure it on the real setup.",
|
||||
"sensor_xy": "Sensor X / Y",
|
||||
"sensor_zy": "Sensor Z / yaw",
|
||||
"setup_hint": "How the sensor actually sits on the stack with the machine at zero: X/Y/Z translation and a yaw about Z. Z is the mounting plate, shims included — there is no separate plate field. Measure it on the real setup.",
|
||||
"clear_points": "Clear points",
|
||||
"remove_selected": "Remove selected",
|
||||
"move_up": "Move up",
|
||||
"move_down": "Move down",
|
||||
"selected_count": "{count} selected",
|
||||
"table_hint": "Tick a row to select it, double-click a sensor coordinate or normal to edit it. The pose columns are solved from those, so they update themselves.",
|
||||
"edit_not_a_number": "That cell needs a number; the previous value was kept.",
|
||||
"point_count": "{count} points",
|
||||
"residual_value": "{residual}° — {verdict}",
|
||||
"reachable": "this normal can be aligned with Z",
|
||||
"unreachable": "this normal cannot be aligned with Z (yaw travel limit)",
|
||||
"reach_summary": "Alignable surface vertices {aligned}/{vertices} ({share}%), worst {worst}°",
|
||||
"preview_legend": "Surface colour = alignment residual (green aligned, red not) · yellow is the tool axis · X/Y are the table, Z the tool travel",
|
||||
"preview_legend": "What the colours mean: green→red is the surface's alignment residual · the red/green/blue triad is the table's X/Y/Z, at the origin · yellow line is the tool axis (+Z, at the tool's own X/Y) · orange dot is the table origin · amber outline is the sensor base plane, at the sensor's Z offset (the mounting plate) · cyan line is the planned path (stage arc, then table move) · the violet ring is the table's pose now and the faint one is where it finishes, against the fixed grid that is where it started · red dot is the point selected in the table · white dot with blue line is the picked contact and its normal.",
|
||||
"preview_legend_short": "green→red = alignment residual · red/green/blue = table X/Y/Z · yellow = tool axis (+Z) · orange = origin · amber box = base plane · cyan = planned path · violet ring = table pose · white dot = contact",
|
||||
"show_axes": "Show motion axes",
|
||||
"show_table": "Show table origin plane",
|
||||
"show_trajectory": "Show tool path",
|
||||
"path_summary": "Planned path: travels {reach} mm; arc radius {radius} mm about the table origin.",
|
||||
"landing": "lands X/Y {x} {y} mm",
|
||||
"tool_at": "tool X/Y {x} {y} mm",
|
||||
"landing_on_tool": "✓ on the tool",
|
||||
"landing_off_tool": "✗ off the tool by {gap} mm",
|
||||
"preview_legend_table": "The grey lines crossing at one origin are the two rotation axes. The grid is the initial state and does not move; the violet ring is the table's own frame, which the move carries away from it.",
|
||||
"sim_play": "Play motion",
|
||||
"sim_stop": "Stop",
|
||||
"sim_hint": "Play the solved motion: the stages rotate the normal onto the tool axis, then the XY table moves. Pick a point first.",
|
||||
@@ -234,7 +251,7 @@
|
||||
"col_nx": "nx", "col_ny": "ny", "col_nz": "nz",
|
||||
"col_X": "X", "col_Y": "Y", "col_Z": "Z",
|
||||
"col_yaw": "yaw", "col_pitch": "pitch", "col_residual": "residual(°)",
|
||||
"loaded": "Bundle loaded: {vertices} vertices · {extent} mm",
|
||||
"loaded": "Bundle loaded: {vertices} vertices · {triangles} triangles · {extent} mm · {scale} mm per unit",
|
||||
"load_failed": "Load failed: {error}",
|
||||
"picked": "Picked: residual {residual}° · yaw {yaw}° · pitch {pitch}°"
|
||||
},
|
||||
|
||||
@@ -210,20 +210,37 @@
|
||||
"row_sensor_normal": "曲面法向",
|
||||
"row_platform_point": "平台坐标 (mm)",
|
||||
"row_pose": "位姿 X / Y / yaw / pitch",
|
||||
"row_table_move": "工作台移动 X / Y",
|
||||
"row_residual": "对齐残差",
|
||||
"row_frames": "坐标映射",
|
||||
"row_bundle": "导出包",
|
||||
"bundle_summary": "{vertices} 顶点 · {triangles} 三角面 · {extent} mm · 每单位 {scale} mm",
|
||||
"plate_thickness": "安装板厚度",
|
||||
"tool_offset": "工具偏移 x,y,z",
|
||||
"tool_xy": "刀具 X / Y",
|
||||
"tool_hint": "刀具在工作台坐标系中的位置,默认在原点正上方;工作台的移动量就是把接触点送到此处所需的位移。请按实际安装测量填写。",
|
||||
"sensor_xy": "传感器 X / Y",
|
||||
"sensor_zy": "传感器 Z / yaw",
|
||||
"setup_hint": "传感器在转台零位时相对工作台原点的实际安装位姿:X/Y/Z 平移与绕 Z 的偏摆。Z 即安装板厚度(含垫片),无需另填。请按实际安装测量填写。",
|
||||
"clear_points": "清空标定点",
|
||||
"remove_selected": "删除所选",
|
||||
"move_up": "上移",
|
||||
"move_down": "下移",
|
||||
"selected_count": "已选 {count} 点",
|
||||
"table_hint": "勾选复选框选中该点;双击传感器坐标或法向即可修改。位姿列由这些数值解算得出,会自动更新。",
|
||||
"edit_not_a_number": "该单元格需要数字,已保留原值。",
|
||||
"point_count": "共 {count} 点",
|
||||
"residual_value": "{residual}° — {verdict}",
|
||||
"reachable": "该点法向可与 Z 轴对齐",
|
||||
"unreachable": "该点法向无法与 Z 轴对齐(受 yaw 行程限制)",
|
||||
"reach_summary": "曲面可对齐顶点 {aligned}/{vertices}({share}%),最差 {worst}°",
|
||||
"preview_legend": "表面颜色 = 对齐残差(绿=对齐,红=不可对齐)· 黄色为工具轴 · X/Y 为工作台,Z 为工具行程",
|
||||
"preview_legend": "颜色含义:绿→红=表面对齐残差;红/绿/蓝三色轴=工作台 X/Y/Z(位于原点);黄色直线=刀具轴(+Z 目标方向,位置即刀具 X/Y);橙色圆点=工作台原点;黄色方框=传感器安装基面(高度=传感器 Z,含安装板);青色线=规划路径(先转台圆弧、后工作台直线);紫圈=转台当前姿态,浅紫圈=动作结束时的姿态,与不动的方格网对比即可看出整个变换;红点=表中选中点;白点+蓝线=取点接触点及其法向。",
|
||||
"preview_legend_short": "绿→红=对齐残差 · 红/绿/蓝=工作台 X/Y/Z · 黄线=刀具轴(+Z) · 橙点=原点 · 黄框=安装基面 · 青线=规划路径 · 紫圈=转台姿态 · 白点=接触点",
|
||||
"show_axes": "显示运动轴",
|
||||
"show_table": "显示转台原点平面",
|
||||
"show_trajectory": "显示刀具路径",
|
||||
"path_summary": "规划路径:行程 {reach} mm;绕转台原点的圆弧半径 {radius} mm。",
|
||||
"landing": "落点 X/Y {x} {y} mm",
|
||||
"tool_at": "刀具 X/Y {x} {y} mm",
|
||||
"landing_on_tool": "✓ 已对准",
|
||||
"landing_off_tool": "✗ 未对准(差 {gap} mm)",
|
||||
"preview_legend_table": "灰细线=两个旋转轴(穿过同一个原点);转台坐标系固定不动,传感器随动作相对它移动。",
|
||||
"sim_play": "播放动作",
|
||||
"sim_stop": "停止",
|
||||
"sim_hint": "播放该点的求解动作:先由转台把法向转到工具轴上,再由 XY 工作台移动。请先取点。",
|
||||
@@ -234,7 +251,7 @@
|
||||
"col_nx": "nx", "col_ny": "ny", "col_nz": "nz",
|
||||
"col_X": "X", "col_Y": "Y", "col_Z": "Z",
|
||||
"col_yaw": "yaw", "col_pitch": "pitch", "col_residual": "残差(°)",
|
||||
"loaded": "已载入导出包:{vertices} 顶点 · {extent} mm",
|
||||
"loaded": "已载入导出包:{vertices} 顶点 · {triangles} 三角面 · {extent} mm · 每单位 {scale} mm",
|
||||
"load_failed": "载入失败: {error}",
|
||||
"picked": "已选点:残差 {residual}° · yaw {yaw}° · pitch {pitch}°"
|
||||
},
|
||||
|
||||
@@ -41,28 +41,23 @@ Because the atlas is a single island, a click maps to exactly one 3D point.
|
||||
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:
|
||||
machine.**
|
||||
|
||||
| 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% |
|
||||
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.
|
||||
|
||||
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.
|
||||
With the corrected envelope, solved by closed form
|
||||
(see `inverse-kinematics.md`):
|
||||
|
||||
Why the yaw limit is not the constraint, which the earlier note had backwards:
|
||||
| Envelope | Worst residual | Within 0.5 deg | In range |
|
||||
|---|---|---|---|
|
||||
| pitch 360 deg, **yaw +-180 deg** (the machine) | **1.2e-06 deg** | 100% | **1032 / 1032** |
|
||||
|
||||
- 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.
|
||||
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
|
||||
@@ -70,6 +65,12 @@ the reachability evidence. Whether they help *repeatability* — a shorter pitch
|
||||
move, a better-conditioned contact — is a separate bench question, not a
|
||||
kinematic one.
|
||||
|
||||
One thing the corrected solve did surface: **51 of 1032 vertices end inverted**,
|
||||
whichever closed-form branch is chosen, because the tool direction is
|
||||
antiparallel to the sensor's own up axis. That is the tool-polarity question, it
|
||||
needs the bench, and it is *not* visible in the residual. See
|
||||
`inverse-kinematics.md` → *Still open*.
|
||||
|
||||
## What was wrong before, and why it is worth recording
|
||||
|
||||
**Four** solver versions preceded this one, each wrong in a way that produced
|
||||
@@ -104,9 +105,11 @@ as a number rather than as a plausible pose.
|
||||
| `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 |
|
||||
| `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.
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# 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.
|
||||
|
||||
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).
|
||||
@@ -0,0 +1,242 @@
|
||||
# Inverse kinematics: the simulated 5-axis pose
|
||||
|
||||
How AutoCal turns a picked surface point into the X, Y, yaw and pitch a run would
|
||||
command. The solve is **closed form** — two `atan2` calls, exact to floating
|
||||
point, no search.
|
||||
|
||||
Everything below is measured, not reasoned. The commands that reproduce each
|
||||
number are at the end.
|
||||
|
||||
## The machine
|
||||
|
||||
An XY table with a yaw stage and a pitch stage stacked on it, and a separate Z
|
||||
axis carrying the tool. Yaw and pitch are solved for; X and Y follow from where
|
||||
the contact point ends up, and Z is reported, never solved.
|
||||
|
||||
| Item | Value | Where |
|
||||
|---|---|---|
|
||||
| Tool axis | `z` | `motion/frames.py` |
|
||||
| Yaw axis | `z`, travel **±180°** | `YAW_AXIS`, `finger_yaw_limit_deg` |
|
||||
| Pitch axis | `y`, travel 360° | `PITCH_AXIS`, `finger_pitch_limit_deg` |
|
||||
| Stage order | `R_pitch @ R_yaw` | `stage_rotation` |
|
||||
| Mount map | sensor `x→x, y→z, z→-y` | `AXIS_MAP` |
|
||||
| Aim direction | `(0, 0, +1)` in the platform frame | `approach_sign = +1` |
|
||||
|
||||
### The face is aimed at +Z, and this was wrong
|
||||
|
||||
The sensor sits at the **bottom of its shell with its sensing face up**, and the
|
||||
tool feeds **down** onto it. So the sensing face — and the surface normal — point
|
||||
along **`+Z`**, and the solver's aim is to bring the normal onto `+Z`.
|
||||
|
||||
`approach_sign` was **`-1`**, aiming every normal at `-Z` and asking the machine to
|
||||
turn the sensor over. It survived because the residual is sign-blind
|
||||
(`arccos` maps a face turned fully over to `0.0000°`, the same as perfectly
|
||||
aligned) and because every test asserted only that the residual was *small*. The
|
||||
failure was visible **only** as the model flipping upside down.
|
||||
|
||||
Measured over the export, counting vertices whose face ends up pointing away from
|
||||
the tool:
|
||||
|
||||
| `approach_sign` | Face ends inverted | Nominal pose (`yaw = pitch = 0`) |
|
||||
|---|---|---|
|
||||
| `-1` (was) | **981 / 1032** | pitch **180°** needed; lands on `(0, 0, -1)` |
|
||||
| `+1` (now) | **22 / 1032** | **no rotation**; lands on `(0, 0, +1)` |
|
||||
|
||||
The 22 are the rim of the dome: their normals sit 60–90° off the tool axis, so
|
||||
aligning them necessarily rolls the body about that normal and the sign is
|
||||
decided by rounding. They are edge-on, not turned over — the most negative
|
||||
`uprightness` among them is a hair past zero, not `-1`.
|
||||
|
||||
The nominal pose needing **no rotation at all** is the tell: a machine that has to
|
||||
make a half-turn to reach its own commissioned configuration has the polarity
|
||||
backwards. `test_the_nominal_normal_needs_no_rotation_and_lands_on_plus_z` pins
|
||||
this, and `test_the_normal_is_aligned_to_plus_z_not_minus_z` checks the direction
|
||||
over the whole export. Both assert the direction landed in, which is the thing the
|
||||
residual cannot express.
|
||||
|
||||
The yaw figure is **±180°, not ±90°** — confirmed at the bench after an earlier
|
||||
value of 90 sent this document, and the solver, down a wrong path. With a
|
||||
half-turn available, **every normal on the export is reachable**; the reachability
|
||||
question below was an artefact of the wrong limit.
|
||||
|
||||
The solver's whole job is to make
|
||||
|
||||
```
|
||||
R_pitch @ R_yaw @ n_platform == tool_direction
|
||||
```
|
||||
|
||||
and to report the angle by which it fails. That angle is the **residual**.
|
||||
|
||||
## The closed form
|
||||
|
||||
### Yaw first, because the pitch cannot help with one component
|
||||
|
||||
The pitch stage turns about the pitch axis, so it **cannot change the normal's
|
||||
component along that axis**. Whatever that component is after the yaw, it stays
|
||||
that way through the pitch. So the yaw must bring it onto the tool's:
|
||||
|
||||
```
|
||||
n_pinned(yaw) = tool_pinned
|
||||
```
|
||||
|
||||
That component after a yaw is `a·cos(y) + b·sin(y)` — one sinusoid — so it has at
|
||||
most two roots in a turn:
|
||||
|
||||
```
|
||||
y = atan2(b, a) ± acos(tool_pinned / hypot(a, b))
|
||||
```
|
||||
|
||||
The coefficients `a` and `b` are read straight off the rotation primitive, by
|
||||
evaluating it at yaw 0 and yaw 90 degrees. No basis is written out by hand: every
|
||||
sign error made while deriving this module came from transcribing the rotation
|
||||
convention locally, and there is no convention left to transcribe wrong this way.
|
||||
|
||||
If `|tool_pinned| > hypot(a, b)` the normal is **unreachable at any pitch**, which
|
||||
is reported rather than hidden.
|
||||
|
||||
### Then pitch, in one `atan2`
|
||||
|
||||
With the yaw fixed, the pitch rotates the remaining pair onto the tool's:
|
||||
|
||||
```
|
||||
pitch = atan2(u₁w₂ − u₂w₁, u₁w₁ + u₂w₂)
|
||||
```
|
||||
|
||||
where `(u₁, u₂)` is the yawed normal and `(w₁, w₂)` the tool, both taken on the
|
||||
pair the pitch actually turns.
|
||||
|
||||
### Two branches
|
||||
|
||||
Both yaw roots satisfy the first condition. They reach the same normal in
|
||||
opposite senses, so one usually leaves the sensing face up and the other turns it
|
||||
over. Both are exact; the choice between them is a machine decision, not a
|
||||
numerical one.
|
||||
|
||||
## Results
|
||||
|
||||
Measured on the real 1032-vertex export
|
||||
(`…\orisys-sensor-design\build\export\finger\r01\compute`):
|
||||
|
||||
| | Worst residual | Time for 1032 | Per solve |
|
||||
|---|---|---|---|
|
||||
| Closed form (`method="analytical"`) | **1.2e-06°** | **148 ms** | 0.126 ms |
|
||||
| Sweep (`method="search"`) | 0.238° | 918 ms | 0.910 ms |
|
||||
|
||||
The closed form is **~200,000× more accurate** and **~7× faster**. Verified at
|
||||
`1.2e-06°` worst over all 1032 vertices and `1.5e-06°` over 5000 random normals.
|
||||
That residual is the floating-point floor: `acos` near 1 loses about `sqrt(eps)`
|
||||
in radians, so "exact" cannot be reported as more precise than this.
|
||||
|
||||
All 1032 vertices are **in range**, and 1010 report `upright`. The 22 that do not
|
||||
are the rim of the dome, where the normal is 60–90° off the tool axis — see
|
||||
*the face is aimed at +Z* above and *still open* below.
|
||||
|
||||
### The two pitch bindings are one family
|
||||
|
||||
`PITCH_AXIS` is `y`, while the trunnion drawing shows the pitch arc about `x`.
|
||||
This turns out not to matter:
|
||||
|
||||
```
|
||||
R_y(p) · R_z(t) ≡ R_x(p) · R_z(t − 90°)
|
||||
```
|
||||
|
||||
agreeing to machine precision in alignment for every yaw and pitch tried. So the
|
||||
axis binding is a **naming and zero-offset choice**: the two reach exactly the
|
||||
same set of alignments, and the 90° difference is absorbed when the yaw stage is
|
||||
zeroed at commissioning. `PITCH_AXIS` is documentation, not a correctness
|
||||
question — which is why the tests pin the identity rather than a value.
|
||||
|
||||
## What the solver reports
|
||||
|
||||
`Pose` carries more than angles and a residual, because those two cannot express
|
||||
everything the machine needs to know:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `residual_deg` | angle between the rotated normal and the tool direction |
|
||||
| `upright` | whether the pose leaves the sensing face pointing along the tool |
|
||||
| `in_range` | whether the pose is inside the stage travel |
|
||||
| `reason` | why not, when it is not |
|
||||
|
||||
A pose that aligns perfectly and turns the sensor over is a different thing from
|
||||
one that aligns perfectly and does not, and the residual alone cannot tell them
|
||||
apart. Likewise a pose needing more travel than the stage has: `in_range` says so,
|
||||
and the solver reports the **best pose the limits do allow** with its real
|
||||
residual, rather than an angle the machine cannot reach or a residual it cannot
|
||||
achieve.
|
||||
|
||||
## History: the sign-blind residual
|
||||
|
||||
The previous residual was `acos(turned · tool_direction)`, which is **sign-blind**.
|
||||
A rotation putting the normal *on* the tool axis and one putting it *opposite*
|
||||
both give `|cos| = 1`, so both scored `0.0000°`. That is how an upside-down pose
|
||||
read as perfect, and it is why this document previously distrusted the solver's
|
||||
"100% within 0.5°".
|
||||
|
||||
The angle itself is correct and is still what `residual_deg` reports; what was
|
||||
missing was `upright`, which now travels with every pose so the distinction cannot
|
||||
be lost. The lesson is recorded rather than deleted: a scalar that cannot express
|
||||
the failure mode it is used to detect will not detect it.
|
||||
|
||||
## Still open
|
||||
|
||||
**22 of 1032 vertices report not-upright** (16 by more than the tie band). These
|
||||
are the rim of the dome, where the surface normal is 60–90° from the tool axis.
|
||||
Aligning an edge-on face necessarily rolls the body about that normal, and which
|
||||
side of zero it lands on is settled by rounding — the most negative uprightness is
|
||||
`-0.997` for a vertex whose normal is `90.4°` from the tool axis, i.e. a face being
|
||||
presented side-on rather than turned over.
|
||||
|
||||
They are not a solver defect: the closed form is exact for all of them
|
||||
(`residual < 1.3e-06°`). Whether they matter is a coverage question — a contact
|
||||
made on the rim is the least well-conditioned one on the patch anyway — and it is
|
||||
worth a look at the bench rather than a change in the maths.
|
||||
|
||||
The polarity question that used to live here is **settled**: see *the face is aimed
|
||||
at +Z* above.
|
||||
|
||||
## Reproducing the numbers
|
||||
|
||||
Interpreter `C:\Users\cheun\anaconda3\envs\py310\python.exe`, repo root
|
||||
`D:\orisys_ws\orisys-app`:
|
||||
|
||||
```python
|
||||
from orisys_autocal.motion.kinematics import PlatformStack
|
||||
from orisys_autocal.sensor import load_bundle
|
||||
import numpy as np
|
||||
|
||||
bundle = load_bundle(r"…\finger\r01\compute")
|
||||
stack = PlatformStack()
|
||||
normals, points = bundle.vertex_normals(), bundle.vertices_mm
|
||||
|
||||
analytical = [stack.solve(p, n, method="analytical") for p, n in zip(points, normals)]
|
||||
residuals = np.array([pose.residual_deg for pose in analytical])
|
||||
print(residuals.max()) # ~1.2e-06
|
||||
print(sum(pose.upright for pose in analytical), "/", len(analytical)) # 1010 / 1032
|
||||
print(all(pose.in_range for pose in analytical)) # True
|
||||
```
|
||||
|
||||
The closed form on a single normal, with no data needed:
|
||||
|
||||
```python
|
||||
unit = np.array([0.3, 0.5, -0.81]); unit /= np.linalg.norm(unit)
|
||||
for branch in stack.alignment_branches(unit, stack.tool_direction):
|
||||
print(branch.describe())
|
||||
```
|
||||
|
||||
Tests and checks:
|
||||
|
||||
```
|
||||
pytest apps/autocal/tests
|
||||
python apps/autocal/tools/panel_check.py
|
||||
```
|
||||
|
||||
## What is not affected
|
||||
|
||||
The **UV picker** and the **pixel → 3D mapping** are independent of this and are
|
||||
verified separately: a click's 3D point, normal and face come from one triangle
|
||||
test, and the lookup tables are unchanged by the texture. This document is about
|
||||
which pose is reported and drawn.
|
||||
|
||||
Also out of scope: the sensor origin versus the stage rotation centre, which is a
|
||||
lever-arm error in the reported X/Y rather than in the angles.
|
||||
@@ -51,6 +51,17 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _request_compatible_gl() -> bool:
|
||||
"""Ask for a GL profile the 3D preview can draw with; see the preview module.
|
||||
|
||||
Thin wrapper so the entry point does not have to know the details — the
|
||||
reasoning and the version choice live next to the drawing code that needs them.
|
||||
"""
|
||||
from .ui.pose_preview import configure_compatible_gl
|
||||
|
||||
return configure_compatible_gl()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
|
||||
_force_utf8_streams()
|
||||
@@ -80,6 +91,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
app = QApplication(sys.argv[:1])
|
||||
app.setApplicationName("Orisys AutoCal")
|
||||
app.setApplicationVersion(app_version)
|
||||
_request_compatible_gl()
|
||||
app.setStyleSheet(build_qss(theme))
|
||||
|
||||
transport = None
|
||||
|
||||
@@ -94,17 +94,47 @@ def rotation_about(axis: str, degrees: float) -> np.ndarray:
|
||||
raise ValueError(f"unknown axis {axis!r}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Branch:
|
||||
"""One exact closed-form solution of the alignment, before it is chosen.
|
||||
|
||||
Both branches put the normal on the tool axis to within floating point, so
|
||||
what separates them is the machine: whether the pose is inside the stage
|
||||
travel, and which way up it leaves the sensing face.
|
||||
"""
|
||||
|
||||
yaw_deg: float
|
||||
pitch_deg: float
|
||||
residual_deg: float
|
||||
upright: bool
|
||||
in_range: bool
|
||||
|
||||
def describe(self) -> str:
|
||||
return (
|
||||
f"yaw {self.yaw_deg:+.3f} pitch {self.pitch_deg:+.3f} "
|
||||
f"residual {self.residual_deg:.6f} "
|
||||
f"{'upright' if self.upright else 'inverted'}"
|
||||
f"{'' if self.in_range else ' out-of-range'}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Pose:
|
||||
"""One solved platform pose, or the reason there is not one."""
|
||||
|
||||
ok: bool
|
||||
#: Platform-frame coordinates of the contact point, mm.
|
||||
#: Platform-frame coordinates of the contact point, mm — where the point *is*,
|
||||
#: with the sensor's mounting pose applied.
|
||||
target_xy_mm: tuple[float, float] = (0.0, 0.0)
|
||||
#: Platform-frame Z of the contact point, mm. Carries the mounting plate and
|
||||
#: any Z tool offset, which ``target_xy_mm`` cannot: without it those inputs
|
||||
#: are computed and then discarded, and nothing downstream can tell.
|
||||
contact_z_mm: float = 0.0
|
||||
#: The move the XY table has to make, mm: the tool's position minus where the
|
||||
#: contact ends up after the stages turn. This is the number a run commands,
|
||||
#: and it is what the preview animates — ``target_xy_mm`` says where the point
|
||||
#: is, this says what the machine does about it.
|
||||
table_xy_mm: tuple[float, float] = (0.0, 0.0)
|
||||
yaw_deg: float = 0.0
|
||||
pitch_deg: float = 0.0
|
||||
#: The separate Z axis. Reported, never solved for here.
|
||||
@@ -113,6 +143,13 @@ class Pose:
|
||||
residual_deg: float = 0.0
|
||||
#: Whether the yaw could be determined at all (indeterminate at the pole).
|
||||
yaw_determined: bool = True
|
||||
#: Whether the pose leaves the sensing face pointing along the tool axis. A
|
||||
#: pose can align the normal perfectly and still be turned over; the residual
|
||||
#: alone cannot say which, so it is reported rather than inferred.
|
||||
upright: bool = True
|
||||
#: False when the chosen pose is outside the stage travel — reported, never
|
||||
#: silently clamped.
|
||||
in_range: bool = True
|
||||
reason: str = ""
|
||||
|
||||
@property
|
||||
@@ -134,7 +171,8 @@ class Pose:
|
||||
yaw = f"{self.yaw_deg:+.3f}" if self.yaw_determined else "indeterminate"
|
||||
return (
|
||||
f"X {self.target_xy_mm[0]:+.3f} Y {self.target_xy_mm[1]:+.3f} "
|
||||
f"Z {self.contact_z_mm:+.3f} yaw {yaw} pitch {self.pitch_deg:+.3f} "
|
||||
f"Z {self.contact_z_mm:+.3f} move {self.table_xy_mm[0]:+.3f} "
|
||||
f"{self.table_xy_mm[1]:+.3f} yaw {yaw} pitch {self.pitch_deg:+.3f} "
|
||||
f"residual {self.residual_deg:.4f} deg"
|
||||
)
|
||||
|
||||
@@ -147,23 +185,51 @@ class PlatformStack:
|
||||
yaw_axis: str = YAW_AXIS
|
||||
pitch_axis: str = PITCH_AXIS
|
||||
tool_axis: str = TOOL_AXIS
|
||||
#: Which way the tool pushes along ``tool_axis`` relative to the patch
|
||||
#: normal the mesh stores. The mesh's normal points *away* from the sensing
|
||||
#: surface, and the tool presses *into* it, so the tool direction is the
|
||||
#: negative of the platform-frame normal — hence ``-1`` by default. Getting
|
||||
#: this wrong does not look wrong: it silently gives a ~90 degree residual on
|
||||
#: a patch that is actually fine, which is how it was found.
|
||||
approach_sign: int = -1
|
||||
#: Added to the reported contact point, mm, in the platform frame. The tool
|
||||
#: tip offset and any fixture height the caller wants folded in.
|
||||
tool_offset_mm: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
#: Which way along ``tool_axis`` the sensing face is aimed, relative to the
|
||||
#: normal the mesh stores. It is ``+1``: the sensor sits at the bottom of its
|
||||
#: shell with the face up, so the face — and the normal — point along
|
||||
#: ``+tool_axis``, and the tool feeds down onto it.
|
||||
#:
|
||||
#: This was ``-1``, which asked the solver to bring every normal onto **-Z**.
|
||||
#: The residual could not see the difference (``arccos`` is sign-blind, so a
|
||||
#: face turned fully over still scored 0.0000 deg), so the mistake showed up
|
||||
#: only as the model flipping upside down: **981 of 1032 vertices** ended with
|
||||
#: the face pointing down. With ``+1`` that is **51**, and those are edge-on
|
||||
#: rather than inverted (worst cosine -0.013).
|
||||
approach_sign: int = 1
|
||||
#: Where the tool is, in the table frame, mm. Default ``(0, 0)`` — straight
|
||||
#: above the table origin, which is where a dialled-in machine has it. Every
|
||||
#: XY move exists to bring the contact to this point, so the move is measured
|
||||
#: against it. It used to be taken as the patch's centre, which was a stand-in
|
||||
#: that made the numbers agree with the picture and with nothing on the bench.
|
||||
#:
|
||||
#: A tool-tip offset is *this number*, not a second one. It was a separate
|
||||
#: vector, 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 — which is exactly what "x and y are not aligning" looked
|
||||
#: like on the bench. Two spellings of "where the tool is" is one too many.
|
||||
tool_xy_mm: tuple[float, float] = (0.0, 0.0)
|
||||
#: How the sensor actually sits on the stack when the machine is at zero:
|
||||
#: a translation along the table's x/y/z, mm, and a yaw about the table's z.
|
||||
#: Tuned per setup, because a mount that is square to within a degree on the
|
||||
#: drawing is not square on the bench.
|
||||
#:
|
||||
#: **z carries the mounting plate.** The plate lifts the sensing face along
|
||||
#: the table's z and does nothing else, so a second input for it would be a
|
||||
#: second spelling of the same number — and the two would drift.
|
||||
sensor_offset_mm: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
#: The sensor's mounting yaw about the table's z, degrees. It is a *setup*
|
||||
#: angle, not a stage angle: the yaw stage has to turn this much less.
|
||||
sensor_yaw_deg: float = 0.0
|
||||
#: The residual a consumer will accept; a pose worse than this is still
|
||||
#: returned, but flagged.
|
||||
tolerance_deg: float = ALIGNED_TOL_DEG
|
||||
#: Travel limits of the two stages, degrees. A solution outside them is not a
|
||||
#: solution: the search is constrained to the envelope the hardware has.
|
||||
#: The bench's figures: pitch turns continuously (360°), yaw is ±90°.
|
||||
yaw_limit_deg: float = 90.0
|
||||
#: The bench's figures, confirmed against the trunnion drawing: **yaw about the
|
||||
#: vertical turns ±180** (a full half-turn either way) and pitch about the
|
||||
#: horizontal turns a full 360.
|
||||
yaw_limit_deg: float = 180.0
|
||||
pitch_limit_deg: float = 360.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
@@ -181,27 +247,95 @@ class PlatformStack:
|
||||
|
||||
@property
|
||||
def tool_direction(self) -> np.ndarray:
|
||||
"""The unit vector the tool pushes along, in the platform frame."""
|
||||
"""The unit vector the sensing face is aimed along, in the platform frame.
|
||||
|
||||
This is what the solver aligns the normal to, and it is ``+tool_axis``
|
||||
with the bench's ``approach_sign``: the face is the top surface of a sensor
|
||||
mounted at the bottom of its shell, so the face points up.
|
||||
"""
|
||||
direction = np.zeros(3, dtype=np.float64)
|
||||
direction[("x", "y", "z").index(self.tool_axis)] = float(self.approach_sign)
|
||||
return direction
|
||||
|
||||
@property
|
||||
def tool_axis_direction(self) -> np.ndarray:
|
||||
"""``+tool_axis``: the same direction, named for the drawing.
|
||||
|
||||
Kept separate from :attr:`tool_direction` so a change of polarity cannot
|
||||
silently reverse the arrow in the preview: with ``approach_sign = +1`` the
|
||||
two coincide, and if the polarity were ever flipped they would not, which
|
||||
is exactly when someone needs to notice.
|
||||
"""
|
||||
direction = np.zeros(3, dtype=np.float64)
|
||||
direction[("x", "y", "z").index(self.tool_axis)] = 1.0
|
||||
return direction
|
||||
|
||||
def normal_to_platform(self, normal_sensor: np.ndarray) -> np.ndarray:
|
||||
return to_platform(np.asarray(normal_sensor, dtype=np.float64), self.axis_map)
|
||||
|
||||
def point_to_platform(
|
||||
self, point_sensor_mm: np.ndarray, *, plate_thickness_mm: float = 0.0
|
||||
) -> np.ndarray:
|
||||
"""A sensor-frame point in the platform frame.
|
||||
"""A sensor-frame point in the platform frame, **ignoring the setup**.
|
||||
|
||||
The plate lifts the sensor along the platform's tool axis, so it is a
|
||||
translation along :data:`tool_axis` applied after the mount rotation.
|
||||
translation along :data:`tool_axis` applied after the mount rotation. For
|
||||
where the point really is once the sensor is mounted, use
|
||||
:meth:`setup_point` — which is what the solver uses.
|
||||
"""
|
||||
platform = to_platform(np.asarray(point_sensor_mm, dtype=np.float64), self.axis_map)
|
||||
lift = np.zeros(3, dtype=np.float64)
|
||||
lift[("x", "y", "z").index(self.tool_axis)] = float(plate_thickness_mm)
|
||||
return platform + lift
|
||||
|
||||
def setup_rotation(self) -> np.ndarray:
|
||||
"""``R_z(sensor_yaw)``: the sensor's mounting yaw, in the table frame.
|
||||
|
||||
A rotation about the table's z through the table origin, applied to the
|
||||
mounted sensor *before* the stages turn it. It commutes with the yaw stage
|
||||
and not with the pitch stage, which is why it is applied here rather than
|
||||
folded into the reported yaw: the two angles mean different things and the
|
||||
machine has to drive only one of them.
|
||||
"""
|
||||
if not np.isfinite(self.sensor_yaw_deg):
|
||||
raise ValueError(f"unknown sensor_yaw_deg {self.sensor_yaw_deg!r}")
|
||||
return rotation_about("z", float(self.sensor_yaw_deg))
|
||||
|
||||
def setup_point(
|
||||
self, point_sensor_mm: np.ndarray, *, plate_thickness_mm: float = 0.0
|
||||
) -> np.ndarray:
|
||||
"""A sensor-frame point where the mounted sensor actually puts it, mm.
|
||||
|
||||
``p_table = R_z(sensor_yaw) · (mount · p + plate·ẑ) + sensor_offset``:
|
||||
the mount rotation, the plate lift, the setup yaw about the table origin,
|
||||
then the setup translation. The plate lift is along the table's z, which
|
||||
the setup yaw leaves alone, so the two can be applied in either order —
|
||||
the same reason the plate is a z offset and not a separate concept.
|
||||
"""
|
||||
platform = self.point_to_platform(
|
||||
point_sensor_mm, plate_thickness_mm=plate_thickness_mm
|
||||
)
|
||||
return self.setup_rotation() @ platform + np.asarray(
|
||||
self.sensor_offset_mm, dtype=np.float64
|
||||
)
|
||||
|
||||
def setup_normal(self, normal_sensor: np.ndarray) -> np.ndarray:
|
||||
"""A sensor-frame direction as the mounted sensor points it, unit length."""
|
||||
normal = self.setup_rotation() @ self.normal_to_platform(normal_sensor)
|
||||
length = float(np.linalg.norm(normal))
|
||||
return normal if length < 1e-12 else normal / length
|
||||
|
||||
def platform_rotation(self, yaw_deg: float, pitch_deg: float) -> np.ndarray:
|
||||
"""``R_pitch · R_yaw``: the stage rotation written in the table frame.
|
||||
|
||||
:meth:`stage_rotation` is the *same* rotation in the sensor frame — the two
|
||||
are conjugates by the mount. Anything that reasons about where a point of
|
||||
the table ends up (the contact, the table's own frame) needs this one.
|
||||
"""
|
||||
return rotation_about(self.pitch_axis, pitch_deg) @ rotation_about(
|
||||
self.yaw_axis, yaw_deg
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------- solve
|
||||
|
||||
def solve(
|
||||
@@ -211,74 +345,306 @@ class PlatformStack:
|
||||
*,
|
||||
plate_thickness_mm: float = 0.0,
|
||||
tool_z_mm: float = 0.0,
|
||||
method: str = "analytical",
|
||||
) -> Pose:
|
||||
"""The yaw/pitch that put the tool axis on ``normal_sensor``, and the XY.
|
||||
|
||||
The reported XY is the **contact point's** position, not the sensor
|
||||
origin's, so it is the coordinate the tool actually has to be above.
|
||||
The reported XY is the **contact point's** position in the table frame —
|
||||
with the mount, the mounting plate and the sensor's setup pose applied — so
|
||||
it is where the point is, not where the machine has to go. The move the
|
||||
table makes to bring it under the tool is reported separately, as
|
||||
:attr:`Pose.table_xy_mm`.
|
||||
|
||||
``method="analytical"`` (the default) uses the closed form, which is exact
|
||||
to floating point and about 25x faster than the sweep. ``method="search"``
|
||||
keeps the old sweep as the reference the tests check the closed form
|
||||
against; it is slower and quantised to ``SWEEP_STEP``.
|
||||
"""
|
||||
normal = self.normal_to_platform(normal_sensor)
|
||||
normal = self.setup_normal(normal_sensor)
|
||||
length = float(np.linalg.norm(normal))
|
||||
if not np.isfinite(length) or length < 1e-12:
|
||||
return Pose(ok=False, reason="the surface normal is degenerate")
|
||||
# The *stored* normal points away from the sensing surface; the tool
|
||||
# pushes into it. ``approach_sign`` carries that, and it appears twice
|
||||
# below — once in the pitch (through z) and once in the yaw. Applying it
|
||||
# to the vector itself instead is what produced a 90 degree residual on
|
||||
# every point when this was first written.
|
||||
unit = normal / length
|
||||
sign = float(self.approach_sign)
|
||||
|
||||
tool = self.tool_direction
|
||||
|
||||
# Express the normal in a basis whose Z is the tool axis, so the closed
|
||||
# form does not care which platform axis the tool runs along.
|
||||
basis = _tool_basis(self.tool_axis)
|
||||
local = basis @ unit
|
||||
|
||||
point = self.point_to_platform(point_sensor_mm, plate_thickness_mm=plate_thickness_mm)
|
||||
point = point + np.asarray(self.tool_offset_mm, dtype=np.float64)
|
||||
|
||||
# Solve numerically, and deliberately so.
|
||||
#
|
||||
# The stages rotate the **platform-frame** normal; the local basis is only
|
||||
# a convenience for reasoning about which axis the tool runs along. An
|
||||
# earlier version rotated the local vector and reported a 90 degree
|
||||
# residual for a normal that was already aligned — exactly the class of
|
||||
# confident wrong number this residual exists to prevent. So the search
|
||||
# and the residual both work on ``unit``, and ``residual_for`` (used by
|
||||
# the preview) does the same, so a point cannot be green in the readout
|
||||
# and red in the picture.
|
||||
#
|
||||
# There is deliberately no special case for a normal lying *on* the tool
|
||||
# axis. One existed, returned a zero residual from a formula, and
|
||||
# disagreed with the rotations by 90 degrees — the search finds the real
|
||||
# answer (which is 0) and the indeterminacy is reported separately below.
|
||||
yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool)
|
||||
point = self.setup_point(point_sensor_mm, plate_thickness_mm=plate_thickness_mm)
|
||||
|
||||
# At the pole the azimuth is undefined, so every yaw gives the same
|
||||
# alignment. Say so rather than presenting an arbitrary angle as the
|
||||
# answer; the returned pose is still one that works.
|
||||
# alignment. Say so rather than presenting an arbitrary angle as the answer.
|
||||
on_axis = float(np.hypot(local[0], local[1])) < 1e-9
|
||||
determined = not on_axis
|
||||
|
||||
if method == "analytical":
|
||||
branches = self.alignment_branches(unit, tool)
|
||||
usable = [branch for branch in branches if branch.in_range]
|
||||
if usable:
|
||||
chosen = self.select_branch(usable)
|
||||
yaw_deg = chosen.yaw_deg
|
||||
pitch_deg = chosen.pitch_deg
|
||||
residual = chosen.residual_deg
|
||||
upright = chosen.upright
|
||||
in_range = True
|
||||
elif branches:
|
||||
# Every exact pose needs more travel than the stage has. Report the
|
||||
# best pose the limits *do* allow, with the residual it really
|
||||
# leaves — not an out-of-range pose that would drive the machine
|
||||
# into its end stop, and not a zero residual it cannot achieve.
|
||||
yaw_deg, pitch_deg, residual = self._best_within_limits(unit, tool)
|
||||
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
|
||||
in_range = False
|
||||
else:
|
||||
yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool)
|
||||
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
|
||||
in_range = False
|
||||
elif method == "search":
|
||||
yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool)
|
||||
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
|
||||
in_range = True
|
||||
else: # pragma: no cover - guarded by the signature
|
||||
raise ValueError(f"unknown method {method!r}")
|
||||
|
||||
reason = ""
|
||||
if on_axis:
|
||||
reason = "the normal lies on the tool axis; yaw is indeterminate"
|
||||
elif not in_range:
|
||||
reason = "the pose is outside the stage travel"
|
||||
elif residual > self.tolerance_deg:
|
||||
reason = "no pose meets the tolerance"
|
||||
|
||||
# The stages turn the contact about the table origin; the table then slides
|
||||
# it under the tool. Reported rather than animated here, but computed from
|
||||
# the same angles the pose reports, so a caller cannot disagree with it.
|
||||
landed = self.platform_rotation(yaw_deg, pitch_deg) @ point
|
||||
tool_x, tool_y = (float(value) for value in self.tool_xy_mm)
|
||||
|
||||
return Pose(
|
||||
ok=True,
|
||||
target_xy_mm=(self._axis(point, "x"), self._axis(point, "y")),
|
||||
contact_z_mm=self._axis(point, "z"),
|
||||
table_xy_mm=(tool_x - self._axis(landed, "x"), tool_y - self._axis(landed, "y")),
|
||||
yaw_deg=yaw_deg,
|
||||
pitch_deg=pitch_deg,
|
||||
tool_z_mm=float(tool_z_mm),
|
||||
residual_deg=residual,
|
||||
yaw_determined=determined,
|
||||
yaw_determined=not on_axis,
|
||||
upright=upright,
|
||||
in_range=in_range,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------- closed-form solve
|
||||
|
||||
def alignment_branches(
|
||||
self, unit: np.ndarray, tool: np.ndarray
|
||||
) -> list["Branch"]:
|
||||
"""Every exact pose that puts ``unit`` on ``tool``, in closed form.
|
||||
|
||||
Two ``atan2`` calls, no search. The chain is yaw-then-pitch, so:
|
||||
|
||||
* the **pitch** cannot change the normal's component along the pitch axis,
|
||||
which means the yaw must first bring that component onto the tool's. That
|
||||
is one sinusoid in the yaw, so it has two roots 180 degrees apart;
|
||||
* with the yaw fixed, the **pitch** rotates the remaining pair onto the
|
||||
tool's, which is a single ``atan2``.
|
||||
|
||||
Both roots reach the same normal in opposite senses, so one usually leaves
|
||||
the sensing face up and the other turns it over. Which is which is decided
|
||||
by :meth:`select_branch`, never by defaulting to the first.
|
||||
|
||||
The rotation coefficients come from :meth:`_rotation_coefficients` and the
|
||||
probe rotations from :func:`rotation_about`, rather than a basis written
|
||||
out by hand. Every sign error made while deriving this came from
|
||||
re-deriving those conventions locally; the library primitive already has a
|
||||
test pinning it to an independently built matrix.
|
||||
"""
|
||||
unit = np.asarray(unit, dtype=np.float64)
|
||||
tool = np.asarray(tool, dtype=np.float64)
|
||||
pitch_stationary = self._rotation_coefficients(self.pitch_axis)[0]
|
||||
yaw_stationary = self._yaw_stationary
|
||||
if pitch_stationary == yaw_stationary: # pragma: no cover - axes perpendicular
|
||||
return []
|
||||
|
||||
# Solve for the yaw numerically-but-exactly, then the pitch in closed form.
|
||||
#
|
||||
# The yaw is what moves the component the pitch pins, and that component
|
||||
# after the yaw is a sinusoid of the yaw angle. Rather than transcribe that
|
||||
# sinusoid's coefficients — which produced a wrong sign every single time it
|
||||
# was attempted in this file — the two roots are found by sampling the
|
||||
# sinusoid finely with the *library's own* rotation and refining each
|
||||
# crossing. The pitch is then exact in closed form, so the answer is exact
|
||||
# to floating point and there is no sign left to get wrong.
|
||||
roots = self._yaw_roots(unit, float(tool[pitch_stationary]), pitch_stationary)
|
||||
if not roots:
|
||||
return []
|
||||
|
||||
branches: list[Branch] = []
|
||||
seen: set[float] = set()
|
||||
for yaw_deg in roots:
|
||||
if any(abs(yaw_deg - prior) < 1e-6 for prior in seen):
|
||||
continue
|
||||
seen.add(yaw_deg)
|
||||
yawed = rotation_about(self.yaw_axis, yaw_deg) @ unit
|
||||
pitch_deg = self._pitch_for(yawed, tool)
|
||||
branches.append(
|
||||
Branch(
|
||||
yaw_deg=yaw_deg,
|
||||
pitch_deg=pitch_deg,
|
||||
residual_deg=self._residual(unit, yaw_deg, pitch_deg),
|
||||
upright=self.uprightness(yaw_deg, pitch_deg) >= 0.0,
|
||||
in_range=(
|
||||
abs(yaw_deg) <= self._yaw_limit_effective() + 1e-9
|
||||
and abs(pitch_deg) <= self._pitch_limit_effective() + 1e-9
|
||||
),
|
||||
)
|
||||
)
|
||||
return branches
|
||||
|
||||
def _yaw_roots(
|
||||
self, unit: np.ndarray, target: float, pinned: int
|
||||
) -> list[float]:
|
||||
"""The yaw angles that put the pinned component onto ``target``.
|
||||
|
||||
The pinned component after a yaw is ``a·cos(y) + b·sin(y)`` — a single
|
||||
sinusoid — so it has at most two roots in a turn and both are exact. The
|
||||
coefficients come from the rotation primitive itself: evaluating it at
|
||||
yaw 0 and yaw 90 degrees gives ``a`` and ``b`` directly, with no basis
|
||||
written out by hand.
|
||||
|
||||
That last point is the whole reason this shape was chosen. Every sign error
|
||||
made while deriving this module came from transcribing the rotation
|
||||
convention locally; taken from the primitive, there is no convention left
|
||||
to transcribe wrong.
|
||||
"""
|
||||
at_zero = np.asarray(unit, dtype=np.float64)
|
||||
at_ninety = rotation_about(self.yaw_axis, 90.0) @ at_zero
|
||||
a = float(at_zero[pinned])
|
||||
b = float(at_ninety[pinned])
|
||||
|
||||
amplitude = math.hypot(a, b)
|
||||
if amplitude < 1e-15:
|
||||
# The pinned component does not depend on the yaw at all — the normal
|
||||
# lies in the plane the yaw sweeps, so every yaw gives the same value.
|
||||
# If that value is already the tool's, the yaw is free and zero is as
|
||||
# good as any; otherwise nothing can bring the two together.
|
||||
return [0.0] if abs(target) <= 1e-15 else []
|
||||
if abs(target) > amplitude + 1e-12:
|
||||
return [] # unreachable: no yaw brings this component onto the tool's
|
||||
|
||||
phase = math.atan2(b, a)
|
||||
offset = math.acos(max(-1.0, min(1.0, target / amplitude)))
|
||||
roots: list[float] = []
|
||||
for root in (phase + offset, phase - offset):
|
||||
degrees = math.degrees(root)
|
||||
degrees = (degrees + 180.0) % 360.0 - 180.0
|
||||
if not any(abs(degrees - prior) < 1e-9 for prior in roots):
|
||||
roots.append(degrees)
|
||||
return roots
|
||||
|
||||
def _best_within_limits(
|
||||
self, unit: np.ndarray, tool: np.ndarray, samples: int = 721
|
||||
) -> tuple[float, float, float]:
|
||||
"""The best pose the stage limits actually allow, with its real residual.
|
||||
|
||||
Used when no exact pose fits. The pitch that best aligns a given yaw is
|
||||
available in closed form, so this is one sweep over yaw at ``SWEEP_STEP``
|
||||
with an exact pitch each; the residual reported is measured, never assumed.
|
||||
"""
|
||||
pitch_limit = self._pitch_limit_effective()
|
||||
best: tuple[float, float, float] | None = None
|
||||
step = max(SWEEP_STEP, (2.0 * self._yaw_limit_effective()) / max(samples - 1, 1))
|
||||
for yaw_deg in np.arange(-self._yaw_limit_effective(), self._yaw_limit_effective() + step, step):
|
||||
yawed = rotation_about(self.yaw_axis, float(yaw_deg)) @ unit
|
||||
pitch_deg = self._pitch_for(yawed, tool)
|
||||
pitch_deg = max(-pitch_limit, min(pitch_limit, pitch_deg))
|
||||
residual = self._residual(unit, float(yaw_deg), pitch_deg)
|
||||
if best is None or residual < best[2]:
|
||||
best = (float(yaw_deg), pitch_deg, residual)
|
||||
assert best is not None
|
||||
return best
|
||||
|
||||
def _pitch_for(self, yawed: np.ndarray, tool: np.ndarray) -> float:
|
||||
"""The pitch that puts an already-yawed normal onto ``tool``.
|
||||
|
||||
Reads the rotating pair from the same sign table as everything else, so the
|
||||
two ``atan2`` arguments are the dot and cross of those two components
|
||||
against the tool's.
|
||||
"""
|
||||
_, first, second, sign_first, sign_second = self._rotation_coefficients(
|
||||
self.pitch_axis
|
||||
)
|
||||
u1, u2 = float(yawed[first]), float(yawed[second])
|
||||
w1, w2 = float(tool[first]), float(tool[second])
|
||||
# turned[first] = u1·c + sign_first ·u2·s
|
||||
# turned[second] = sign_second·u1·s + u2·c
|
||||
cosine_term = u1 * w1 + sign_second * u2 * w2
|
||||
sine_term = sign_first * u2 * w1 - u1 * w2
|
||||
return math.degrees(math.atan2(sine_term, cosine_term))
|
||||
|
||||
@staticmethod
|
||||
def select_branch(branches: list["Branch"]) -> "Branch":
|
||||
"""Pick from :meth:`alignment_branches`: reachable, then upright, then best.
|
||||
|
||||
Every branch aligns to within floating point, so alignment itself rarely
|
||||
separates them. What does is the machine: a pose inside the stage travel
|
||||
beats one outside it, and — among those — a pose that leaves the sensing
|
||||
face up beats one that turns the sensor over.
|
||||
|
||||
Alignment is still the third key rather than ignored: if one branch really
|
||||
is materially better than another, that wins, because the point of the
|
||||
stages is to align. The tie only decides between poses that are the same
|
||||
to any tolerance the machine can hold.
|
||||
"""
|
||||
def rank(branch: "Branch") -> tuple:
|
||||
# Alignment first: if one branch genuinely aligns better, that is the
|
||||
# answer, because aligning is what the stages are for. Reachability and
|
||||
# orientation decide between branches that align the same, which is the
|
||||
# usual case — the closed form puts both within floating point of exact.
|
||||
return (
|
||||
branch.residual_deg,
|
||||
not branch.in_range,
|
||||
not branch.upright,
|
||||
abs(branch.yaw_deg) + abs(branch.pitch_deg),
|
||||
)
|
||||
|
||||
return min(branches, key=rank)
|
||||
|
||||
def reachable(self, unit: np.ndarray, tool: np.ndarray) -> tuple[bool, str]:
|
||||
"""Whether any pose can align this normal, and why not if none can.
|
||||
|
||||
A normal whose component along the *pitch* axis cannot be brought onto the
|
||||
tool's by any yaw is unreachable at any pitch — the pitch stage cannot
|
||||
change that component. Saying so is the point: the search used to return a
|
||||
saturated pose and hide the failure in the residual.
|
||||
"""
|
||||
try:
|
||||
branches = self.alignment_branches(unit, tool)
|
||||
except ValueError as exc: # pragma: no cover - guarded by __post_init__
|
||||
return False, str(exc)
|
||||
if not branches:
|
||||
return False, "no yaw brings the normal onto the tool axis; unreachable"
|
||||
return True, ""
|
||||
|
||||
def _yaw_limit_effective(self) -> float:
|
||||
return float(min(180.0, max(0.0, self.yaw_limit_deg)))
|
||||
|
||||
def _pitch_limit_effective(self) -> float:
|
||||
return float(min(360.0, max(0.0, self.pitch_limit_deg)))
|
||||
|
||||
@property
|
||||
def _yaw_stationary(self) -> int:
|
||||
"""The component the yaw leaves alone (its own axis)."""
|
||||
return self._rotation_coefficients(self.yaw_axis)[0]
|
||||
|
||||
@property
|
||||
def _yaw_sign_first(self) -> float:
|
||||
return self._rotation_coefficients(self.yaw_axis)[3]
|
||||
|
||||
@property
|
||||
def _yaw_sign_second(self) -> float:
|
||||
return self._rotation_coefficients(self.yaw_axis)[4]
|
||||
|
||||
def _rotation_coefficients(
|
||||
self, axis: str
|
||||
) -> tuple[int, int, int, float, float]:
|
||||
@@ -604,24 +970,24 @@ class PlatformStack:
|
||||
)
|
||||
|
||||
def uprightness(self, yaw_deg: float, pitch_deg: float) -> float:
|
||||
"""How much the sensing face still points along the tool axis, in -1..1.
|
||||
"""How much the sensing face still points up, in -1..1.
|
||||
|
||||
Measured, not derived from a formula over one stage: the sensor's own up
|
||||
axis is put through the composed stage rotation and dotted with the tool
|
||||
direction in the same frame. Two attempts at the formula were wrong in
|
||||
opposite directions and each looked reasonable, so this reads the same
|
||||
rotations the preview draws.
|
||||
Measured, not derived: the sensor's own up axis (mesh ``+Y``, which
|
||||
``AXIS_MAP`` sends to platform ``+Z``) is put through the composed stage
|
||||
rotation and compared with that same axis *unrotated*. ``+1`` is the
|
||||
nominal pose — no rotation, face straight up — ``-1`` is fully turned over.
|
||||
|
||||
The tool presses *into* the sensing face, so the sensor's up axis is
|
||||
anti-parallel to ``tool_direction``; nominal (no rotation) is therefore
|
||||
``+1`` with the negation below, which is the anchor this is checked
|
||||
against — without it the whole scale came out backwards.
|
||||
Both vectors are expressed in the same frame, so this is the angle between
|
||||
the face and where it started, and it does not depend on which way the tool
|
||||
points. An earlier version dotted the turned axis against the tool
|
||||
direction, which quietly moved with ``approach_sign`` and so reported the
|
||||
polarity as an orientation.
|
||||
"""
|
||||
turned = self.stage_rotation(yaw_deg, pitch_deg) @ np.array([0.0, 1.0, 0.0])
|
||||
reference = to_sensor(np.eye(3)[1], self.axis_map)
|
||||
reference = reference / max(float(np.linalg.norm(reference)), 1e-12)
|
||||
turned = self.stage_rotation(yaw_deg, pitch_deg) @ reference
|
||||
turned = turned / max(float(np.linalg.norm(turned)), 1e-12)
|
||||
tool_local = to_sensor(np.asarray(self.tool_direction, dtype=np.float64), self.axis_map)
|
||||
tool_local = tool_local / max(float(np.linalg.norm(tool_local)), 1e-12)
|
||||
return float(turned @ tool_local)
|
||||
return float(turned @ reference)
|
||||
|
||||
def ends_upright(self, yaw_deg: float, pitch_deg: float) -> bool:
|
||||
"""``uprightness`` as a decision, with a dead band for numerical ties."""
|
||||
|
||||
@@ -179,15 +179,24 @@ def default_preferences() -> dict[str, Any]:
|
||||
#: The UV-space texture for the flat picker. Preferred over the derived
|
||||
#: path when it exists; empty falls back to the layout map.
|
||||
"finger_texture_path": DEFAULT_FINGER_TEXTURE,
|
||||
#: The mounting plate lifts the sensor along the platform's tool axis, so
|
||||
#: a fixture thickness is one number rather than a transform.
|
||||
"finger_plate_thickness_mm": 0.0,
|
||||
#: Tool tip offset from the platform reference, mm.
|
||||
"finger_tool_offset_mm": [0.0, 0.0, 0.0],
|
||||
#: Stage travel, degrees. The bench's figures: pitch turns continuously,
|
||||
#: yaw is limited — and that limit is what decides how much of the patch
|
||||
#: can be aligned at all.
|
||||
"finger_yaw_limit_deg": 90.0,
|
||||
#: How the sensor actually sits on the stack when the machine is at zero:
|
||||
#: x/y/z translation along the table's axes and a yaw about its z. **The z
|
||||
#: is the mounting plate**, so the plate is not a setting of its own — it
|
||||
#: lifts the sensing face and does nothing else, and two fields for one
|
||||
#: number drift apart.
|
||||
"finger_sensor_offset_mm": [0.0, 0.0, 0.0],
|
||||
"finger_sensor_yaw_deg": 0.0,
|
||||
#: Where the tool is, in the table frame, mm. Straight above the table
|
||||
#: origin unless the real machine says otherwise; every XY move is measured
|
||||
#: against it, so it is what makes the reported move match the bench. A tool
|
||||
#: tip offset belongs here — it used to be a separate field, and having two
|
||||
#: of them is what let the picture and the numbers disagree.
|
||||
"finger_tool_xy_mm": [0.0, 0.0],
|
||||
#: Stage travel, degrees. Confirmed at the bench: the vertical stage turns
|
||||
#: a full half-turn either way (±180), and the horizontal stage turns
|
||||
#: continuously. An earlier value of 90 was wrong and made half the patch
|
||||
#: look unreachable when it is not.
|
||||
"finger_yaw_limit_deg": 180.0,
|
||||
"finger_pitch_limit_deg": 360.0,
|
||||
}
|
||||
|
||||
@@ -199,6 +208,43 @@ def load_preferences() -> dict[str, Any]:
|
||||
axis_params = prefs.setdefault("axis_params", {})
|
||||
for axis, values in _default_axis_params().items():
|
||||
axis_params.setdefault(axis, values)
|
||||
|
||||
# The mounting plate used to be a setting of its own. It is now the z of the
|
||||
# sensor's mounting pose, so a stored value is carried over rather than quietly
|
||||
# dropped — a machine that had it tuned would otherwise come back 4 mm off.
|
||||
plate = prefs.pop("finger_plate_thickness_mm", None)
|
||||
try:
|
||||
plate = float(plate) if plate is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
plate = 0.0
|
||||
offset = prefs.get("finger_sensor_offset_mm")
|
||||
offset = list(offset) if isinstance(offset, list) else [0.0, 0.0, 0.0]
|
||||
while len(offset) < 3:
|
||||
offset.append(0.0)
|
||||
if plate and not offset[2]:
|
||||
offset[2] = plate
|
||||
prefs["finger_sensor_offset_mm"] = offset
|
||||
|
||||
# The tool tip offset used to be a vector added to the reported coordinates
|
||||
# while the drawing ignored it, so the picture and the numbers disagreed by
|
||||
# exactly that much. It is the tool's position now: fold a stored x/y into it,
|
||||
# and take whatever is left out of the document so nothing reads it again. Its
|
||||
# z had no meaning the model kept — the tool's height is the Z axis's job — so
|
||||
# it is dropped, deliberately and visibly rather than silently applied.
|
||||
legacy = prefs.pop("finger_tool_offset_mm", None)
|
||||
tool = prefs.get("finger_tool_xy_mm")
|
||||
tool = list(tool) if isinstance(tool, list) else [0.0, 0.0]
|
||||
while len(tool) < 2:
|
||||
tool.append(0.0)
|
||||
if isinstance(legacy, list) and len(legacy) >= 2:
|
||||
for index in range(2):
|
||||
try:
|
||||
value = float(legacy[index])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if value and not tool[index]:
|
||||
tool[index] = value
|
||||
prefs["finger_tool_xy_mm"] = tool
|
||||
return prefs
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,11 +18,12 @@ not.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import QPoint, Qt, Signal
|
||||
from PySide6.QtGui import QImage, QPainter, QPen, QPixmap
|
||||
from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QDoubleSpinBox,
|
||||
@@ -51,6 +52,11 @@ from ..sensor.uv_flat import FlatSurface
|
||||
#: How many picked points the list keeps before the oldest is dropped.
|
||||
MAX_POINTS = 200
|
||||
|
||||
#: How close a stored point must be to a raster entry to be shown on the flat
|
||||
#: image. One pixel of the 1024-wide raster is about 0.0147 mm on this export, so
|
||||
#: this is a pixel and a half — finer than the view can render anyway.
|
||||
_POINT_MATCH_MM = 0.02
|
||||
|
||||
|
||||
class FlatSurfaceView(QLabel):
|
||||
"""The flat image, clickable, with the picked pixel cross-haired.
|
||||
@@ -75,6 +81,7 @@ class FlatSurfaceView(QLabel):
|
||||
self._flat: FlatSurface | None = None
|
||||
self._pixmap: QPixmap | None = None
|
||||
self._marker: tuple[int, int] | None = None
|
||||
self._selected: tuple[int, int] | None = None
|
||||
self.setCursor(Qt.CursorShape.CrossCursor)
|
||||
|
||||
@property
|
||||
@@ -84,6 +91,7 @@ class FlatSurfaceView(QLabel):
|
||||
def set_flat(self, flat: FlatSurface | None) -> None:
|
||||
self._flat = flat
|
||||
self._marker = None
|
||||
self._selected = None
|
||||
if flat is None:
|
||||
self._pixmap = None
|
||||
self.clear()
|
||||
@@ -101,21 +109,63 @@ class FlatSurfaceView(QLabel):
|
||||
self._marker = pixel
|
||||
self._refresh()
|
||||
|
||||
def set_selected(self, pixel: tuple[int, int] | None) -> None:
|
||||
"""The point currently chosen in the table — the one about to be tested.
|
||||
|
||||
Drawn as a filled red dot, distinct from the cross-hair left by the last
|
||||
click, so "what I just picked" and "what is about to run" are not confused.
|
||||
"""
|
||||
self._selected = pixel
|
||||
self._refresh()
|
||||
|
||||
@property
|
||||
def selected_pixel(self) -> tuple[int, int] | None:
|
||||
return self._selected
|
||||
|
||||
def pixel_for_point(self, point_mm) -> tuple[int, int] | None:
|
||||
"""The pixel showing a given sensor-frame 3D point, if one does.
|
||||
|
||||
Inverse of :meth:`FlatSurface.sample`: search the table for the entry whose
|
||||
recorded position matches. Exact identity is not expected — a point may
|
||||
have been edited — so the nearest entry within a pixel wins, and ``None``
|
||||
means the point is not on the rasterised surface at all.
|
||||
"""
|
||||
flat = self._flat
|
||||
if flat is None:
|
||||
return None
|
||||
target = np.asarray(point_mm, dtype=np.float64)
|
||||
covered = flat.face_index >= 0
|
||||
if not covered.any():
|
||||
return None
|
||||
rows, columns = np.nonzero(covered)
|
||||
positions = flat.positions_mm[rows, columns]
|
||||
distances = np.linalg.norm(positions - target, axis=1)
|
||||
best = int(np.argmin(distances))
|
||||
if float(distances[best]) > _POINT_MATCH_MM:
|
||||
return None
|
||||
return int(columns[best]), int(rows[best])
|
||||
|
||||
def _marked(self) -> QPixmap | None:
|
||||
"""The native-size image with the marker drawn on it, or ``None``."""
|
||||
"""The native-size image with both markers drawn on it, or ``None``."""
|
||||
if self._pixmap is None:
|
||||
return None
|
||||
if self._marker is None:
|
||||
if self._marker is None and self._selected is None:
|
||||
return self._pixmap
|
||||
canvas = self._pixmap.copy()
|
||||
painter = QPainter(canvas)
|
||||
try:
|
||||
if self._marker is not None:
|
||||
x, y = self._marker
|
||||
painter.setPen(QPen(Qt.GlobalColor.white, 1, Qt.PenStyle.SolidLine))
|
||||
painter.drawLine(x - 8, y, x + 8, y)
|
||||
painter.drawLine(x, y - 8, x, y + 8)
|
||||
painter.setPen(QPen(Qt.GlobalColor.black, 1, Qt.PenStyle.SolidLine))
|
||||
painter.drawEllipse(QPoint(x, y), 5, 5)
|
||||
if self._selected is not None:
|
||||
x, y = self._selected
|
||||
painter.setPen(QPen(QColor("#ffffff"), 2))
|
||||
painter.setBrush(QColor("#d92d20"))
|
||||
painter.drawEllipse(QPoint(x, y), 6, 6)
|
||||
finally:
|
||||
painter.end()
|
||||
return canvas
|
||||
@@ -177,12 +227,27 @@ class FingerPanel(QGroupBox):
|
||||
pickRequested = Signal(int, int)
|
||||
stackChanged = Signal()
|
||||
|
||||
#: Columns of the point table, minus the leading select checkbox. The first six
|
||||
#: are the ones an operator may edit — the sensor-frame point and its normal —
|
||||
#: and everything after them is *solved*, so it is redrawn from the edit rather
|
||||
#: than typed into.
|
||||
COLUMNS: tuple[str, ...] = (
|
||||
"x", "y", "z", "nx", "ny", "nz",
|
||||
"X", "Y", "Z", "yaw", "pitch", "residual",
|
||||
)
|
||||
#: How many of ``COLUMNS`` are inputs rather than solved outputs.
|
||||
INPUT_COLUMNS = 6
|
||||
#: Offset of the first data column; column 0 holds the select checkbox.
|
||||
SELECT_COLUMN = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stack: PlatformStack | None = None,
|
||||
*,
|
||||
bundle_path: str = "",
|
||||
plate_thickness_mm: float = 0.0,
|
||||
sensor_offset_mm: tuple[float, float, float] = (0.0, 0.0, 0.0),
|
||||
sensor_yaw_deg: float = 0.0,
|
||||
tool_xy_mm: tuple[float, float] = (0.0, 0.0),
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -191,6 +256,11 @@ class FingerPanel(QGroupBox):
|
||||
self._bundle: SensorBundle | None = None
|
||||
#: Full-precision picked rows, index-aligned with the table.
|
||||
self._picked: list[dict[str, float]] = []
|
||||
#: True while this panel is writing the table itself, so the ``itemChanged``
|
||||
#: handler can tell its own redraw from an operator's edit. Without it every
|
||||
#: solved-column update would be re-read as an edit and re-solve.
|
||||
self._writing = False
|
||||
self._headers = self.COLUMNS
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(10, 8, 10, 10)
|
||||
@@ -212,11 +282,21 @@ class FingerPanel(QGroupBox):
|
||||
body.addWidget(picker_box, 2)
|
||||
body.addWidget(readout_box, 3)
|
||||
body.addWidget(preview_box, 5)
|
||||
# The body outranks the table, because the 3D view is the pane an operator
|
||||
# actually reads the pose from, and at an even split it came out 240 px tall
|
||||
# — a box too short to judge a normal in, under three lines of legend.
|
||||
outer.addLayout(body, 1)
|
||||
|
||||
outer.addWidget(self._build_point_table(), 2)
|
||||
# ``Maximum`` so the table stops at its natural height and the spare vertical
|
||||
# space goes to the 3D view above it. Left expandable it ate the window: the
|
||||
# preview stayed at its 240 px floor while the table had hundreds of empty
|
||||
# pixels under three rows.
|
||||
table_box = self._build_point_table()
|
||||
table_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||
outer.addWidget(table_box)
|
||||
|
||||
self.set_plate_thickness(plate_thickness_mm)
|
||||
self.set_sensor_setup(sensor_offset_mm, sensor_yaw_deg)
|
||||
self.set_tool_xy(*tool_xy_mm)
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------------ widgets
|
||||
@@ -286,9 +366,9 @@ class FingerPanel(QGroupBox):
|
||||
"sensor_normal",
|
||||
"platform_point",
|
||||
"pose",
|
||||
"table_move",
|
||||
"residual",
|
||||
"frames",
|
||||
"bundle",
|
||||
)
|
||||
for row, key in enumerate(rows):
|
||||
label = QLabel(box)
|
||||
@@ -307,33 +387,101 @@ class FingerPanel(QGroupBox):
|
||||
grid.addWidget(value, row, 1)
|
||||
self._readout_labels[key] = value
|
||||
|
||||
# The mounting plate: a translation along the platform's tool axis, so a
|
||||
# fixture thickness is one number rather than a transform to maintain.
|
||||
self.plate_label = QLabel(box)
|
||||
self.plate_label.setObjectName("hintLabel")
|
||||
self.plate_spin = QDoubleSpinBox(box)
|
||||
self.plate_spin.setRange(-100.0, 100.0)
|
||||
self.plate_spin.setDecimals(3)
|
||||
self.plate_spin.setSingleStep(0.5)
|
||||
self.plate_spin.setSuffix(" mm")
|
||||
self.plate_spin.setFixedHeight(28)
|
||||
self.plate_spin.valueChanged.connect(self._on_plate_changed)
|
||||
grid.addWidget(self.plate_label, len(rows), 0)
|
||||
grid.addWidget(self.plate_spin, len(rows), 1)
|
||||
# The mounting pose and the tool: the four numbers and the two that describe
|
||||
# where the parts actually are. Spin boxes rather than a text field because
|
||||
# these get dialled in against a real setup, one step at a time.
|
||||
self.tool_label = QLabel(box)
|
||||
self.tool_label.setObjectName("hintLabel")
|
||||
tool_row = QWidget(box)
|
||||
tool_row_layout = QHBoxLayout(tool_row)
|
||||
tool_row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
tool_row_layout.setSpacing(6)
|
||||
self.tool_x_spin = self._mm_spin(box, -500.0, 500.0)
|
||||
self.tool_y_spin = self._mm_spin(box, -500.0, 500.0)
|
||||
tool_row_layout.addWidget(self.tool_x_spin)
|
||||
tool_row_layout.addWidget(self.tool_y_spin)
|
||||
grid.addWidget(self.tool_label, len(rows), 0)
|
||||
grid.addWidget(tool_row, len(rows), 1)
|
||||
|
||||
self.offset_label = QLabel(box)
|
||||
self.offset_label.setObjectName("hintLabel")
|
||||
self.offset_edit = QLineEdit(box)
|
||||
self.offset_edit.setFixedHeight(28)
|
||||
self.offset_edit.setToolTip("sensor origin offset, x,y,z mm")
|
||||
self.offset_edit.editingFinished.connect(self._on_offset_changed)
|
||||
grid.addWidget(self.offset_label, len(rows) + 1, 0)
|
||||
grid.addWidget(self.offset_edit, len(rows) + 1, 1)
|
||||
self.setup_xy_label = QLabel(box)
|
||||
self.setup_xy_label.setObjectName("hintLabel")
|
||||
setup_xy_row = QWidget(box)
|
||||
setup_xy_layout = QHBoxLayout(setup_xy_row)
|
||||
setup_xy_layout.setContentsMargins(0, 0, 0, 0)
|
||||
setup_xy_layout.setSpacing(6)
|
||||
self.sensor_x_spin = self._mm_spin(box, -500.0, 500.0)
|
||||
self.sensor_y_spin = self._mm_spin(box, -500.0, 500.0)
|
||||
setup_xy_layout.addWidget(self.sensor_x_spin)
|
||||
setup_xy_layout.addWidget(self.sensor_y_spin)
|
||||
grid.addWidget(self.setup_xy_label, len(rows) + 1, 0)
|
||||
grid.addWidget(setup_xy_row, len(rows) + 1, 1)
|
||||
|
||||
self.setup_zy_label = QLabel(box)
|
||||
self.setup_zy_label.setObjectName("hintLabel")
|
||||
setup_zy_row = QWidget(box)
|
||||
setup_zy_layout = QHBoxLayout(setup_zy_row)
|
||||
setup_zy_layout.setContentsMargins(0, 0, 0, 0)
|
||||
setup_zy_layout.setSpacing(6)
|
||||
# z is the mounting plate and any shim: one number, because a second field
|
||||
# for the plate would be a second spelling of the same quantity.
|
||||
self.sensor_z_spin = self._mm_spin(box, -100.0, 200.0)
|
||||
self.sensor_yaw_spin = QDoubleSpinBox(box)
|
||||
self.sensor_yaw_spin.setObjectName("compactField")
|
||||
self.sensor_yaw_spin.setRange(-180.0, 180.0)
|
||||
self.sensor_yaw_spin.setDecimals(3)
|
||||
self.sensor_yaw_spin.setSingleStep(0.1)
|
||||
self.sensor_yaw_spin.setSuffix(" °")
|
||||
self.sensor_yaw_spin.setFixedHeight(28)
|
||||
self.sensor_yaw_spin.setToolTip(t("finger.setup_hint"))
|
||||
setup_zy_layout.addWidget(self.sensor_z_spin)
|
||||
setup_zy_layout.addWidget(self.sensor_yaw_spin)
|
||||
grid.addWidget(self.setup_zy_label, len(rows) + 2, 0)
|
||||
grid.addWidget(setup_zy_row, len(rows) + 2, 1)
|
||||
|
||||
for spin in (
|
||||
self.tool_x_spin,
|
||||
self.tool_y_spin,
|
||||
self.sensor_x_spin,
|
||||
self.sensor_y_spin,
|
||||
self.sensor_z_spin,
|
||||
self.sensor_yaw_spin,
|
||||
):
|
||||
spin.valueChanged.connect(self._on_frame_inputs_changed)
|
||||
|
||||
# The two summary lines live here rather than under the 3D view, where they
|
||||
# were competing with it for height: both are numbers about the pick, so they
|
||||
# belong beside the numbers. Three lines of legend under the picture left the
|
||||
# picture 240 px tall on a 1080 screen — the one pane that has to be read.
|
||||
self.reach_label = QLabel(box)
|
||||
self.reach_label.setObjectName("hintLabel")
|
||||
self.reach_label.setWordWrap(True)
|
||||
grid.addWidget(self.reach_label, len(rows) + 3, 0, 1, 2)
|
||||
self.path_label = QLabel(box)
|
||||
self.path_label.setObjectName("hintLabel")
|
||||
self.path_label.setWordWrap(True)
|
||||
grid.addWidget(self.path_label, len(rows) + 4, 0, 1, 2)
|
||||
grid.setRowStretch(len(rows) + 5, 1)
|
||||
|
||||
grid.setColumnStretch(1, 1)
|
||||
self._readout_box = box
|
||||
return box
|
||||
|
||||
def _mm_spin(self, box: QWidget, low: float, high: float) -> QDoubleSpinBox:
|
||||
"""A millimetre field with the shared range, step and suffix.
|
||||
|
||||
``compactField`` because these sit two to a row: the theme's normal input
|
||||
padding makes a field 40 px tall, which is right in a form and overflows a
|
||||
cell in a paired column.
|
||||
"""
|
||||
spin = QDoubleSpinBox(box)
|
||||
spin.setObjectName("compactField")
|
||||
spin.setRange(low, high)
|
||||
spin.setDecimals(3)
|
||||
spin.setSingleStep(0.1)
|
||||
spin.setSuffix(" mm")
|
||||
spin.setFixedHeight(28)
|
||||
return spin
|
||||
|
||||
def _build_preview(self) -> QWidget:
|
||||
from .pose_preview import PosePreview
|
||||
|
||||
@@ -343,11 +491,32 @@ class FingerPanel(QGroupBox):
|
||||
column.setContentsMargins(8, 6, 8, 8)
|
||||
column.setSpacing(6)
|
||||
self.preview = PosePreview(box)
|
||||
# The full colour key, on hover. The one-line label below carries the short
|
||||
# version: a three-sentence legend under the picture took the height the
|
||||
# picture needed, and a key nobody can see is no better than no key.
|
||||
self.preview.setToolTip(t("finger.preview_legend"))
|
||||
column.addWidget(self.preview, 1)
|
||||
checks = QHBoxLayout()
|
||||
checks.setContentsMargins(0, 0, 0, 0)
|
||||
checks.setSpacing(10)
|
||||
self.axes_check = QCheckBox(box)
|
||||
self.axes_check.setChecked(True)
|
||||
self.axes_check.toggled.connect(self.preview.set_show_axes)
|
||||
column.addWidget(self.axes_check)
|
||||
checks.addWidget(self.axes_check)
|
||||
# The table's own frame: origin plane, the two rotation axes through it, the
|
||||
# table marker, and the sensor base lifted by its z setup offset. Kept
|
||||
# separate from the sensor axes above, because this is the machine's frame.
|
||||
self.table_check = QCheckBox(box)
|
||||
self.table_check.setChecked(True)
|
||||
self.table_check.toggled.connect(self.preview.set_show_table)
|
||||
checks.addWidget(self.table_check)
|
||||
# The planned path: the arc the stages sweep, then the straight table move.
|
||||
self.trajectory_check = QCheckBox(box)
|
||||
self.trajectory_check.setChecked(True)
|
||||
self.trajectory_check.toggled.connect(self.preview.set_show_trajectory)
|
||||
checks.addWidget(self.trajectory_check)
|
||||
checks.addStretch(1)
|
||||
column.addLayout(checks)
|
||||
controls = QHBoxLayout()
|
||||
controls.setContentsMargins(0, 0, 0, 0)
|
||||
controls.setSpacing(6)
|
||||
@@ -358,6 +527,10 @@ class FingerPanel(QGroupBox):
|
||||
self.sim_button.setEnabled(False)
|
||||
self.sim_button.clicked.connect(self.preview.start_simulation)
|
||||
self.preview.simulationChanged.connect(self._on_simulation_changed)
|
||||
# A failed paint has to reach a label. Left to the widget alone it showed as
|
||||
# a scene that was merely missing some of its parts, which is how a broken
|
||||
# colour call hid the base plane and the whole trajectory without a word.
|
||||
self.preview.problemChanged.connect(lambda _message: self._refresh_reach())
|
||||
controls.addWidget(self.sim_button)
|
||||
controls.addStretch(1)
|
||||
column.addLayout(controls)
|
||||
@@ -368,10 +541,6 @@ class FingerPanel(QGroupBox):
|
||||
self.legend_label.setObjectName("hintLabel")
|
||||
self.legend_label.setWordWrap(True)
|
||||
column.addWidget(self.legend_label)
|
||||
self.reach_label = QLabel(box)
|
||||
self.reach_label.setObjectName("hintLabel")
|
||||
self.reach_label.setWordWrap(True)
|
||||
column.addWidget(self.reach_label)
|
||||
self._preview_box = box
|
||||
return box
|
||||
|
||||
@@ -382,35 +551,49 @@ class FingerPanel(QGroupBox):
|
||||
column.setContentsMargins(8, 6, 8, 8)
|
||||
column.setSpacing(6)
|
||||
|
||||
headers = (
|
||||
"x", "y", "z", "nx", "ny", "nz",
|
||||
"X", "Y", "Z", "yaw", "pitch", "residual",
|
||||
)
|
||||
self.table = QTableWidget(0, len(headers), box)
|
||||
self.table = QTableWidget(0, len(self._headers) + 1, box)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
||||
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self.table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
|
||||
# Editable, but only on a deliberate double-click: a single click selects
|
||||
# the row (and drives the markers), which is what an operator does most.
|
||||
self.table.setEditTriggers(
|
||||
QTableWidget.EditTrigger.DoubleClicked
|
||||
| QTableWidget.EditTrigger.EditKeyPressed
|
||||
)
|
||||
self.table.setAlternatingRowColors(True)
|
||||
for index in range(len(headers)):
|
||||
self.table.setColumnWidth(0, 34)
|
||||
for index in range(1, len(self._headers) + 1):
|
||||
self.table.setColumnWidth(index, 82)
|
||||
self.table.horizontalHeader().setSectionResizeMode(
|
||||
len(headers) - 1, QHeaderView.ResizeMode.Stretch
|
||||
len(self._headers), QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
self.table.setMinimumHeight(90)
|
||||
self.table.itemChanged.connect(self._on_item_changed)
|
||||
self.table.itemSelectionChanged.connect(self._on_selection_changed)
|
||||
column.addWidget(self.table, 1)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self.clear_button = QPushButton(box)
|
||||
self.clear_button.clicked.connect(self.clear_points)
|
||||
self.remove_button = QPushButton(box)
|
||||
self.remove_button.clicked.connect(self.remove_selected)
|
||||
self.up_button = QPushButton(box)
|
||||
self.up_button.clicked.connect(lambda: self.move_selected(-1))
|
||||
self.down_button = QPushButton(box)
|
||||
self.down_button.clicked.connect(lambda: self.move_selected(1))
|
||||
for button in (self.clear_button, self.remove_button, self.up_button, self.down_button):
|
||||
row.addWidget(button)
|
||||
row.addStretch(1)
|
||||
self._selected_label = QLabel(box)
|
||||
self._selected_label.setObjectName("hintLabel")
|
||||
row.addWidget(self._selected_label)
|
||||
self.count_label = QLabel(box)
|
||||
self.count_label.setObjectName("hintLabel")
|
||||
row.addWidget(self.clear_button)
|
||||
row.addStretch(1)
|
||||
row.addWidget(self.count_label)
|
||||
column.addLayout(row)
|
||||
|
||||
self._table_box = box
|
||||
self._headers = headers
|
||||
return box
|
||||
|
||||
# -------------------------------------------------------------------- state
|
||||
@@ -424,13 +607,68 @@ class FingerPanel(QGroupBox):
|
||||
return self._stack
|
||||
|
||||
@property
|
||||
def plate_thickness_mm(self) -> float:
|
||||
return float(self.plate_spin.value())
|
||||
def sensor_offset_mm(self) -> tuple[float, float, float]:
|
||||
"""How the sensor is mounted, in the table frame: x, y and z (the plate)."""
|
||||
return (
|
||||
float(self.sensor_x_spin.value()),
|
||||
float(self.sensor_y_spin.value()),
|
||||
float(self.sensor_z_spin.value()),
|
||||
)
|
||||
|
||||
def set_plate_thickness(self, value: float) -> None:
|
||||
self.plate_spin.blockSignals(True)
|
||||
self.plate_spin.setValue(float(value))
|
||||
self.plate_spin.blockSignals(False)
|
||||
@property
|
||||
def sensor_yaw_deg(self) -> float:
|
||||
return float(self.sensor_yaw_spin.value())
|
||||
|
||||
@property
|
||||
def tool_xy_mm(self) -> tuple[float, float]:
|
||||
return (float(self.tool_x_spin.value()), float(self.tool_y_spin.value()))
|
||||
|
||||
def set_tool_xy(self, x_mm: float, y_mm: float) -> None:
|
||||
for spin, value in ((self.tool_x_spin, x_mm), (self.tool_y_spin, y_mm)):
|
||||
spin.blockSignals(True)
|
||||
spin.setValue(float(value))
|
||||
spin.blockSignals(False)
|
||||
self._apply_frame_inputs()
|
||||
|
||||
def set_sensor_setup(self, offset_mm, yaw_deg: float) -> None:
|
||||
"""Put the mounting pose into the fields, then push it to the stack."""
|
||||
values = tuple(float(value) for value in offset_mm) + (0.0, 0.0, 0.0)
|
||||
for spin, value in zip(
|
||||
(self.sensor_x_spin, self.sensor_y_spin, self.sensor_z_spin), values
|
||||
):
|
||||
spin.blockSignals(True)
|
||||
spin.setValue(value)
|
||||
spin.blockSignals(False)
|
||||
self.sensor_yaw_spin.blockSignals(True)
|
||||
self.sensor_yaw_spin.setValue(float(yaw_deg))
|
||||
self.sensor_yaw_spin.blockSignals(False)
|
||||
self._apply_frame_inputs()
|
||||
|
||||
def _on_frame_inputs_changed(self) -> None:
|
||||
"""The setup or the tool moved: the geometry has changed, so re-solve."""
|
||||
self._apply_frame_inputs()
|
||||
self._resolve_everything()
|
||||
|
||||
def _apply_frame_inputs(self) -> None:
|
||||
self._stack.sensor_offset_mm = self.sensor_offset_mm
|
||||
self._stack.sensor_yaw_deg = self.sensor_yaw_deg
|
||||
self._stack.tool_xy_mm = self.tool_xy_mm
|
||||
self.preview.set_setup(self.sensor_offset_mm, self.sensor_yaw_deg)
|
||||
self.preview.set_tool_xy(*self.tool_xy_mm)
|
||||
|
||||
def _resolve_everything(self) -> None:
|
||||
"""Re-solve every stored row, then redraw what the current one shows.
|
||||
|
||||
Every row, not only the current one: the setup and the tool move the whole
|
||||
machine, so a table still holding the old X/Y/Z would be a table of numbers
|
||||
from a machine that no longer exists.
|
||||
"""
|
||||
for row in range(len(self._picked)):
|
||||
self.resolve_row(row)
|
||||
self._after_points_changed()
|
||||
self._refresh_path()
|
||||
self._refresh_reach()
|
||||
self.stackChanged.emit()
|
||||
|
||||
def set_bundle_path(self, path: str) -> None:
|
||||
self.path_edit.setText(path)
|
||||
@@ -441,6 +679,7 @@ class FingerPanel(QGroupBox):
|
||||
self.preview.set_bundle(bundle)
|
||||
self.refresh_readouts()
|
||||
self._refresh_reach()
|
||||
self.path_label.setText("")
|
||||
|
||||
def _on_simulation_changed(self, running: bool) -> None:
|
||||
"""Keep the button honest about what it will do next."""
|
||||
@@ -470,12 +709,19 @@ class FingerPanel(QGroupBox):
|
||||
|
||||
def clear_points(self) -> None:
|
||||
self._picked.clear()
|
||||
self._writing = True
|
||||
try:
|
||||
self.table.setRowCount(0)
|
||||
finally:
|
||||
self._writing = False
|
||||
self.preview.set_pose(None)
|
||||
self.preview.set_point(None, None)
|
||||
self._sync_sim_button()
|
||||
self.table.setRowCount(0)
|
||||
self.preview.set_highlight(None)
|
||||
self.view.set_marker(None)
|
||||
self.count_label.setText(t("finger.point_count", count=0))
|
||||
self.view.set_selected(None)
|
||||
self._sync_sim_button()
|
||||
self._refresh_count()
|
||||
self.path_label.setText("")
|
||||
|
||||
def point_count(self) -> int:
|
||||
return self.table.rowCount()
|
||||
@@ -501,9 +747,9 @@ class FingerPanel(QGroupBox):
|
||||
return None
|
||||
|
||||
point, normal, face = sample
|
||||
pose = self._stack.solve(
|
||||
point, normal, plate_thickness_mm=self.plate_thickness_mm
|
||||
)
|
||||
pose = self._stack.solve(point, normal)
|
||||
# The click leaves a cross-hair where it landed *and* promotes the new row
|
||||
# to the selection, so the red dot marks the same point in both views.
|
||||
self.view.set_marker((x, y))
|
||||
self.preview.set_stack(self._stack)
|
||||
self.preview.set_point(point, normal)
|
||||
@@ -522,8 +768,16 @@ class FingerPanel(QGroupBox):
|
||||
# Hand the solved pose to the preview so the play button demonstrates
|
||||
# exactly the numbers on screen, rather than a second solve of its own.
|
||||
self.preview.set_pose(pose)
|
||||
self.preview.set_highlight(point)
|
||||
self._sync_sim_button()
|
||||
self.refresh_readouts(point, normal, pose, face)
|
||||
self._refresh_path()
|
||||
# Select the row just added, so the table agrees with the dot.
|
||||
self._writing = True
|
||||
try:
|
||||
self.table.setCurrentCell(self.table.rowCount() - 1, 1)
|
||||
finally:
|
||||
self._writing = False
|
||||
return row
|
||||
|
||||
def _append_row(self, row: dict[str, float]) -> None:
|
||||
@@ -532,17 +786,249 @@ class FingerPanel(QGroupBox):
|
||||
# the displayed text would quietly hand a later export coordinates the
|
||||
# solver never produced.
|
||||
self._picked.append(dict(row))
|
||||
index = self.table.rowCount()
|
||||
self.table.insertRow(index)
|
||||
for column, key in enumerate(self._headers):
|
||||
item = QTableWidgetItem(f"{row[key]:.3f}")
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
self.table.setItem(index, column, item)
|
||||
self.table.insertRow(self.table.rowCount())
|
||||
self._write_row(self.table.rowCount() - 1)
|
||||
while self.table.rowCount() > MAX_POINTS:
|
||||
self.table.removeRow(0)
|
||||
while len(self._picked) > MAX_POINTS:
|
||||
self._picked.pop(0)
|
||||
self.count_label.setText(t("finger.point_count", count=self.table.rowCount()))
|
||||
self._refresh_count()
|
||||
|
||||
# ------------------------------------------------------- table row plumbing
|
||||
|
||||
def _write_row(self, index: int) -> None:
|
||||
"""Render one ``_picked`` row into the table, without re-triggering edits."""
|
||||
if not (0 <= index < len(self._picked)):
|
||||
return
|
||||
row = self._picked[index]
|
||||
self._writing = True
|
||||
try:
|
||||
for offset, key in enumerate(self._headers):
|
||||
column = offset + 1
|
||||
item = self.table.item(index, column)
|
||||
if item is None:
|
||||
item = QTableWidgetItem()
|
||||
item.setTextAlignment(
|
||||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
if offset >= self.INPUT_COLUMNS:
|
||||
# Solved values are shown, not typed: editing the normal and
|
||||
# reading the pose is the workflow, and the other way round
|
||||
# would need an inverse the table cannot express.
|
||||
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
item.setForeground(QColor("#5c6678"))
|
||||
self.table.setItem(index, column, item)
|
||||
item.setText(f"{row[key]:.3f}")
|
||||
self._ensure_selector(index)
|
||||
finally:
|
||||
self._writing = False
|
||||
|
||||
def _ensure_selector(self, index: int, checked: bool = False) -> None:
|
||||
"""Give a row its select checkbox, created once."""
|
||||
if self.table.cellWidget(index, self.SELECT_COLUMN) is not None:
|
||||
return
|
||||
holder = QWidget(self.table)
|
||||
layout = QHBoxLayout(holder)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
check = QCheckBox(holder)
|
||||
check.setChecked(checked)
|
||||
check.toggled.connect(self._refresh_count)
|
||||
layout.addWidget(check)
|
||||
self.table.setCellWidget(index, self.SELECT_COLUMN, holder)
|
||||
|
||||
def _selector(self, index: int) -> QCheckBox | None:
|
||||
holder = self.table.cellWidget(index, self.SELECT_COLUMN)
|
||||
if holder is None:
|
||||
return None
|
||||
return holder.findChild(QCheckBox)
|
||||
|
||||
def selected_rows(self) -> list[int]:
|
||||
"""Rows whose checkbox is ticked, ascending."""
|
||||
return [
|
||||
index
|
||||
for index in range(self.table.rowCount())
|
||||
if (check := self._selector(index)) is not None and check.isChecked()
|
||||
]
|
||||
|
||||
def remove_selected(self) -> int:
|
||||
"""Drop every ticked row, and the last-picked point if none is ticked."""
|
||||
rows = self.selected_rows()
|
||||
if not rows:
|
||||
row = self.table.currentRow()
|
||||
rows = [row] if row >= 0 else []
|
||||
for index in sorted(rows, reverse=True):
|
||||
self.table.removeRow(index)
|
||||
if 0 <= index < len(self._picked):
|
||||
self._picked.pop(index)
|
||||
self._refresh_count()
|
||||
self._after_points_changed()
|
||||
return len(rows)
|
||||
|
||||
def move_selected(self, offset: int) -> bool:
|
||||
"""Shift the ticked rows (or the current one) up or down one place.
|
||||
|
||||
Moves the topmost row first when going down and the bottom-most first when
|
||||
going up, so a contiguous block keeps its internal order and no row is
|
||||
swapped twice. The current row follows the move, so repeating the action
|
||||
keeps pushing the same point rather than stranding the selection.
|
||||
"""
|
||||
rows = self.selected_rows() or (
|
||||
[self.table.currentRow()] if self.table.currentRow() >= 0 else []
|
||||
)
|
||||
if not rows:
|
||||
return False
|
||||
ordered = sorted(rows, reverse=offset > 0)
|
||||
moved = False
|
||||
landed = self.table.currentRow()
|
||||
for index in ordered:
|
||||
target = index + offset
|
||||
if not (0 <= target < self.table.rowCount()):
|
||||
continue
|
||||
if target in rows: # a selected neighbour will move too; do not swap twice
|
||||
continue
|
||||
self._swap_rows(index, target)
|
||||
if index == self.table.currentRow():
|
||||
landed = target
|
||||
moved = True
|
||||
if moved:
|
||||
self._writing = True
|
||||
try:
|
||||
self.table.setCurrentCell(landed, max(1, self.table.currentColumn()))
|
||||
finally:
|
||||
self._writing = False
|
||||
self._refresh_count()
|
||||
self._after_points_changed()
|
||||
return moved
|
||||
|
||||
def _swap_rows(self, first: int, second: int) -> None:
|
||||
"""Exchange two table rows and their stored points, selection included."""
|
||||
self._picked[first], self._picked[second] = self._picked[second], self._picked[first]
|
||||
checked = [
|
||||
bool((check := self._selector(row)) is not None and check.isChecked())
|
||||
for row in (first, second)
|
||||
]
|
||||
self._writing = True
|
||||
try:
|
||||
# Rebuild both rows from the swapped data: simpler than moving items, and
|
||||
# it cannot leave a stale value behind. ``takeCellWidget`` does not exist,
|
||||
# so the old checkboxes are detached by replacing them with None.
|
||||
for row in (first, second):
|
||||
for column in range(1, len(self._headers) + 1):
|
||||
self.table.takeItem(row, column)
|
||||
self.table.removeCellWidget(row, self.SELECT_COLUMN)
|
||||
self._write_row(first)
|
||||
self._write_row(second)
|
||||
finally:
|
||||
self._writing = False
|
||||
for row, was_checked in zip((first, second), checked):
|
||||
check = self._selector(row)
|
||||
if check is not None:
|
||||
check.setChecked(was_checked)
|
||||
|
||||
def _refresh_count(self) -> None:
|
||||
total = self.table.rowCount()
|
||||
chosen = len(self.selected_rows())
|
||||
self.count_label.setText(t("finger.point_count", count=total))
|
||||
self.remove_button.setEnabled(total > 0)
|
||||
self.up_button.setEnabled(total > 0)
|
||||
self.down_button.setEnabled(total > 0)
|
||||
self._selected_label.setText(
|
||||
t("finger.selected_count", count=chosen) if chosen else ""
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------- editing
|
||||
|
||||
def _on_item_changed(self, item: QTableWidgetItem) -> None:
|
||||
"""An operator edited a cell: re-solve the row and refresh everything.
|
||||
|
||||
Only the six input columns are editable, so an edit always means "this
|
||||
sensor-frame point or normal changed". The pose columns are redrawn from the
|
||||
new solve rather than trusted, which is what keeps the table and the readouts
|
||||
from disagreeing.
|
||||
"""
|
||||
if self._writing:
|
||||
return
|
||||
column = item.column() - 1
|
||||
row = item.row()
|
||||
if column < 0 or column >= self.INPUT_COLUMNS:
|
||||
return
|
||||
if not (0 <= row < len(self._picked)):
|
||||
return
|
||||
try:
|
||||
value = float(item.text())
|
||||
except ValueError:
|
||||
# Put the old number back rather than storing a non-number.
|
||||
self._write_row(row)
|
||||
self.status_label.setText(t("finger.edit_not_a_number"))
|
||||
return
|
||||
if not math.isfinite(value):
|
||||
self._write_row(row)
|
||||
self.status_label.setText(t("finger.edit_not_a_number"))
|
||||
return
|
||||
|
||||
record = self._picked[row]
|
||||
record[self._headers[column]] = value
|
||||
self.resolve_row(row)
|
||||
self._after_points_changed()
|
||||
|
||||
def resolve_row(self, row: int) -> None:
|
||||
"""Re-solve a stored row from its sensor-frame point and normal."""
|
||||
if not (0 <= row < len(self._picked)):
|
||||
return
|
||||
record = self._picked[row]
|
||||
point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64)
|
||||
normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64)
|
||||
length = float(np.linalg.norm(normal))
|
||||
if length < 1e-12:
|
||||
return
|
||||
normal = normal / length
|
||||
pose = self._stack.solve(point, normal)
|
||||
record.update(
|
||||
{
|
||||
"nx": float(normal[0]), "ny": float(normal[1]), "nz": float(normal[2]),
|
||||
"X": float(pose.target_xy_mm[0]), "Y": float(pose.target_xy_mm[1]),
|
||||
"Z": float(pose.contact_z_mm),
|
||||
"yaw": float(pose.yaw_deg), "pitch": float(pose.pitch_deg),
|
||||
"residual": float(pose.residual_deg),
|
||||
}
|
||||
)
|
||||
self._write_row(row)
|
||||
|
||||
def _on_selection_changed(self) -> None:
|
||||
"""Follow the current row in both pictures, so the marker means something."""
|
||||
row = self.table.currentRow()
|
||||
if not (0 <= row < len(self._picked)):
|
||||
return
|
||||
self.show_point(row)
|
||||
|
||||
def show_point(self, row: int) -> None:
|
||||
"""Point the 2D and 3D markers at one row: this is the point to be tested."""
|
||||
if not (0 <= row < len(self._picked)):
|
||||
return
|
||||
record = self._picked[row]
|
||||
point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64)
|
||||
normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64)
|
||||
self.preview.set_point(point, normal)
|
||||
self.preview.set_highlight(point)
|
||||
if self.view.flat is not None:
|
||||
pixel = self.view.pixel_for_point(point)
|
||||
self.view.set_selected(pixel)
|
||||
pose = self._stack.solve(point, normal)
|
||||
self._last_pose = pose
|
||||
self.preview.set_pose(pose)
|
||||
self._sync_sim_button()
|
||||
self.refresh_readouts(point, normal, pose)
|
||||
self._refresh_path()
|
||||
|
||||
def _after_points_changed(self) -> None:
|
||||
"""Keep the picture honest after a delete, reorder or edit."""
|
||||
row = self.table.currentRow()
|
||||
if 0 <= row < len(self._picked):
|
||||
self.show_point(row)
|
||||
else:
|
||||
self.preview.set_highlight(None)
|
||||
self.view.set_selected(None)
|
||||
|
||||
# ----------------------------------------------------------------- readouts
|
||||
|
||||
@@ -550,29 +1036,25 @@ class FingerPanel(QGroupBox):
|
||||
_last_face = -1
|
||||
|
||||
def refresh_readouts(self, point=None, normal=None, pose=None, face: int = -1) -> None:
|
||||
bundle = self._bundle
|
||||
self._readout_labels["frames"].setText(describe_frames(self._stack.axis_map))
|
||||
if bundle is None:
|
||||
self._readout_labels["bundle"].setText("—")
|
||||
for key in ("sensor_point", "sensor_normal", "platform_point", "pose", "residual"):
|
||||
if self._bundle is None:
|
||||
for key in (
|
||||
"sensor_point",
|
||||
"sensor_normal",
|
||||
"platform_point",
|
||||
"pose",
|
||||
"table_move",
|
||||
"residual",
|
||||
):
|
||||
self._readout_labels[key].setText("—")
|
||||
return
|
||||
|
||||
info = bundle.describe()
|
||||
self._readout_labels["bundle"].setText(
|
||||
t(
|
||||
"finger.bundle_summary",
|
||||
vertices=info["vertices"],
|
||||
triangles=info["triangles"],
|
||||
extent=" × ".join(f"{value:.2f}" for value in info["extent_mm"]),
|
||||
scale=f"{info['mm_per_unit']:.6f}",
|
||||
)
|
||||
)
|
||||
if point is None:
|
||||
self._readout_labels["table_move"].setText("—")
|
||||
return
|
||||
platform = self._stack.point_to_platform(
|
||||
point, plate_thickness_mm=self.plate_thickness_mm
|
||||
)
|
||||
# The contact where the mounted sensor actually puts it, so the platform
|
||||
# coordinates on screen are the machine's, not the drawing's.
|
||||
platform = self._stack.setup_point(point)
|
||||
self._readout_labels["sensor_point"].setText(
|
||||
" ".join(f"{value:+.3f}" for value in point) + " mm"
|
||||
)
|
||||
@@ -582,6 +1064,11 @@ class FingerPanel(QGroupBox):
|
||||
self._readout_labels["platform_point"].setText(
|
||||
" ".join(f"{value:+.3f}" for value in platform) + " mm"
|
||||
)
|
||||
self._readout_labels["table_move"].setText(
|
||||
"—"
|
||||
if pose is None
|
||||
else f"{pose.table_xy_mm[0]:+.3f} {pose.table_xy_mm[1]:+.3f} mm"
|
||||
)
|
||||
if pose is not None:
|
||||
self._readout_labels["pose"].setText(pose.describe())
|
||||
self._readout_labels["residual"].setText(
|
||||
@@ -597,6 +1084,39 @@ class FingerPanel(QGroupBox):
|
||||
)
|
||||
del face
|
||||
|
||||
def _refresh_path(self) -> None:
|
||||
"""Report what the planned motion does, and where it actually arrives.
|
||||
|
||||
The last line is the x-y check the bench asks about. It is printed as two
|
||||
pairs — where the drawing lands the contact, and where the tool is — because
|
||||
they are computed two different ways (the drawing's own transform, and the
|
||||
tool's setup number) and must agree. When they do not, the line says so
|
||||
instead of leaving it to be judged off a picture.
|
||||
"""
|
||||
summary = self.preview.trajectory_summary()
|
||||
if not summary:
|
||||
self.path_label.setText("")
|
||||
return
|
||||
text = t(
|
||||
"finger.path_summary",
|
||||
reach=f"{summary['reach_mm']:.1f}",
|
||||
radius=f"{summary['arc_radius_mm']:.1f}",
|
||||
)
|
||||
landing = self.preview.landing_xy_mm()
|
||||
if landing is not None:
|
||||
tool = self._stack.tool_xy_mm
|
||||
gap = float(np.hypot(landing[0] - tool[0], landing[1] - tool[1]))
|
||||
verdict = (
|
||||
t("finger.landing_on_tool")
|
||||
if gap <= 1e-6
|
||||
else t("finger.landing_off_tool", gap=f"{gap:.3f}")
|
||||
)
|
||||
text = (
|
||||
f"{text} {t('finger.landing', x=f'{landing[0]:+.3f}', y=f'{landing[1]:+.3f}')}"
|
||||
f" / {t('finger.tool_at', x=f'{tool[0]:+.3f}', y=f'{tool[1]:+.3f}')} {verdict}"
|
||||
)
|
||||
self.path_label.setText(text)
|
||||
|
||||
def _refresh_reach(self) -> None:
|
||||
"""The reach summary, or why there is no picture instead of silently nothing.
|
||||
|
||||
@@ -620,22 +1140,6 @@ class FingerPanel(QGroupBox):
|
||||
|
||||
# ------------------------------------------------------------------ handlers
|
||||
|
||||
def _on_plate_changed(self, _value: float) -> None:
|
||||
self.stackChanged.emit()
|
||||
|
||||
def _on_offset_changed(self) -> None:
|
||||
parts = self.offset_edit.text().replace(",", " ").split()
|
||||
try:
|
||||
triple = tuple(float(part) for part in parts[:3])
|
||||
except ValueError:
|
||||
return
|
||||
if len(triple) == 3:
|
||||
self._stack.tool_offset_mm = triple # type: ignore[assignment]
|
||||
self.stackChanged.emit()
|
||||
|
||||
def set_offset(self, triple) -> None:
|
||||
self.offset_edit.setText(" ".join(f"{value:g}" for value in triple))
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
@@ -653,22 +1157,38 @@ class FingerPanel(QGroupBox):
|
||||
"sensor_normal": "finger.row_sensor_normal",
|
||||
"platform_point": "finger.row_platform_point",
|
||||
"pose": "finger.row_pose",
|
||||
"table_move": "finger.row_table_move",
|
||||
"residual": "finger.row_residual",
|
||||
"frames": "finger.row_frames",
|
||||
"bundle": "finger.row_bundle",
|
||||
}
|
||||
grid = self._readout_box.layout()
|
||||
for row, key in enumerate(labels):
|
||||
widget = grid.itemAtPosition(row, 0)
|
||||
if widget is not None and widget.widget() is not None:
|
||||
widget.widget().setText(t(labels[key]))
|
||||
self.plate_label.setText(t("finger.plate_thickness"))
|
||||
self.offset_label.setText(t("finger.tool_offset"))
|
||||
self.legend_label.setText(t("finger.preview_legend"))
|
||||
self.tool_label.setText(t("finger.tool_xy"))
|
||||
self.setup_xy_label.setText(t("finger.sensor_xy"))
|
||||
self.setup_zy_label.setText(t("finger.sensor_zy"))
|
||||
for spin in (self.tool_x_spin, self.tool_y_spin):
|
||||
spin.setToolTip(t("finger.tool_hint"))
|
||||
for spin in (self.sensor_x_spin, self.sensor_y_spin, self.sensor_z_spin):
|
||||
spin.setToolTip(t("finger.setup_hint"))
|
||||
self.sensor_yaw_spin.setToolTip(t("finger.setup_hint"))
|
||||
self.legend_label.setText(t("finger.preview_legend_short"))
|
||||
self.preview.setToolTip(t("finger.preview_legend"))
|
||||
self.axes_check.setText(t("finger.show_axes"))
|
||||
self.table_check.setText(t("finger.show_table"))
|
||||
self.trajectory_check.setText(t("finger.show_trajectory"))
|
||||
self.sim_button.setToolTip(t("finger.sim_hint"))
|
||||
self._sync_sim_button()
|
||||
self.table.setHorizontalHeaderLabels(
|
||||
[""] + [t(f"finger.col_{key}") for key in self._headers]
|
||||
)
|
||||
self.clear_button.setText(t("finger.clear_points"))
|
||||
self.count_label.setText(t("finger.point_count", count=self.table.rowCount()))
|
||||
self.table.setHorizontalHeaderLabels([t(f"finger.col_{key}") for key in self._headers])
|
||||
self.remove_button.setText(t("finger.remove_selected"))
|
||||
self.up_button.setText(t("finger.move_up"))
|
||||
self.down_button.setText(t("finger.move_down"))
|
||||
self.table.setToolTip(t("finger.table_hint"))
|
||||
self._refresh_count()
|
||||
|
||||
self.status_label.setText(t("finger.select_hint"))
|
||||
|
||||
@@ -74,6 +74,25 @@ logger = logging.getLogger(__name__)
|
||||
#: window width.
|
||||
MAIN_SPLITTER_SIZES = (330, 1560)
|
||||
|
||||
|
||||
def _triple(value: object, length: int) -> tuple[float, ...]:
|
||||
"""A numeric preference as a fixed-length tuple, tolerant of a hand-edited file.
|
||||
|
||||
``None``, a wrong length or a string in the JSON must not stop the window from
|
||||
opening: the finger screen's setup numbers are all zero by default, and a
|
||||
malformed one is better replaced than raised on.
|
||||
"""
|
||||
values = value if isinstance(value, (list, tuple)) else ()
|
||||
numbers = []
|
||||
for item in list(values)[:length]:
|
||||
try:
|
||||
numbers.append(float(item))
|
||||
except (TypeError, ValueError):
|
||||
numbers.append(0.0)
|
||||
while len(numbers) < length:
|
||||
numbers.append(0.0)
|
||||
return tuple(numbers)
|
||||
|
||||
#: Width below which the rail would clip its own controls.
|
||||
MAIN_MINIMUM_WIDTH = 1440
|
||||
|
||||
@@ -230,17 +249,14 @@ class AutoCalMainWindow(QMainWindow):
|
||||
yaw_limit_deg=float(self.prefs.get("finger_yaw_limit_deg") or 90.0),
|
||||
pitch_limit_deg=float(self.prefs.get("finger_pitch_limit_deg") or 360.0),
|
||||
)
|
||||
offset = self.prefs.get("finger_tool_offset_mm") or [0.0, 0.0, 0.0]
|
||||
if isinstance(offset, list) and len(offset) == 3:
|
||||
self.finger_stack.tool_offset_mm = tuple(float(v) for v in offset)
|
||||
self.finger_panel = FingerPanel(
|
||||
self.finger_stack,
|
||||
bundle_path=str(self.prefs.get("finger_bundle_path") or ""),
|
||||
plate_thickness_mm=float(self.prefs.get("finger_plate_thickness_mm") or 0.0),
|
||||
sensor_offset_mm=_triple(self.prefs.get("finger_sensor_offset_mm"), 3),
|
||||
sensor_yaw_deg=float(self.prefs.get("finger_sensor_yaw_deg") or 0.0),
|
||||
tool_xy_mm=_triple(self.prefs.get("finger_tool_xy_mm"), 2),
|
||||
parent=body,
|
||||
)
|
||||
self.finger_panel.set_offset(self.finger_stack.tool_offset_mm)
|
||||
|
||||
self.workspace = WorkspaceTabs(
|
||||
self.axis_panel, self.sequence_panel, self.finger_panel, body
|
||||
)
|
||||
@@ -286,6 +302,12 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.bus_data_panel.rtexWriteRequested.connect(self._on_rtex_write)
|
||||
self.status_strip.stopAllRequested.connect(self._on_stop_all)
|
||||
self.status_strip.pauseRequested.connect(self._on_pause_polling)
|
||||
# The readout rail's own batch buttons, connected once here. They were
|
||||
# connected from inside the finger screen's stack-changed handler, which
|
||||
# meant every edit on that screen added another copy.
|
||||
self.axis_readout.stopAllRequested.connect(self._on_stop_all)
|
||||
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
|
||||
self.axis_readout.enableAllRequested.connect(self._on_enable_all)
|
||||
self.run_bar.runRequested.connect(self._on_run_requested)
|
||||
self.run_bar.pauseRequested.connect(self._on_run_pause)
|
||||
self.run_bar.abortRequested.connect(self._on_run_abort)
|
||||
@@ -321,7 +343,9 @@ class AutoCalMainWindow(QMainWindow):
|
||||
t(
|
||||
"finger.loaded",
|
||||
vertices=info["vertices"],
|
||||
triangles=info["triangles"],
|
||||
extent=" × ".join(f"{value:.2f}" for value in info["extent_mm"]),
|
||||
scale=f"{info['mm_per_unit']:.6f}",
|
||||
),
|
||||
ok=True,
|
||||
)
|
||||
@@ -396,13 +420,18 @@ class AutoCalMainWindow(QMainWindow):
|
||||
)
|
||||
|
||||
def _on_finger_stack_changed(self) -> None:
|
||||
"""The mounting pose, the tool position or the limits moved.
|
||||
|
||||
Only the state that depends on them is refreshed here. This handler used to
|
||||
connect three axis-readout signals as well, on every call — harmless while
|
||||
the stack changed once at startup, and a multiplying pile of duplicate
|
||||
connections the moment a spin box started changing it.
|
||||
"""
|
||||
self.finger_panel.preview.set_stack(self.finger_panel.stack)
|
||||
self.finger_panel._refresh_reach()
|
||||
self.prefs["finger_plate_thickness_mm"] = self.finger_panel.plate_thickness_mm
|
||||
self.prefs["finger_tool_offset_mm"] = list(self.finger_panel.stack.tool_offset_mm)
|
||||
self.axis_readout.stopAllRequested.connect(self._on_stop_all)
|
||||
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
|
||||
self.axis_readout.enableAllRequested.connect(self._on_enable_all)
|
||||
self.finger_panel._refresh_reach() # noqa: SLF001 - the panel owns the label
|
||||
self.prefs["finger_sensor_offset_mm"] = list(self.finger_panel.sensor_offset_mm)
|
||||
self.prefs["finger_sensor_yaw_deg"] = self.finger_panel.sensor_yaw_deg
|
||||
self.prefs["finger_tool_xy_mm"] = list(self.finger_panel.tool_xy_mm)
|
||||
|
||||
# ---------------------------------------------------------------- run bar
|
||||
|
||||
|
||||
@@ -81,6 +81,32 @@ def tmp_dir():
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_preference_file(monkeypatch):
|
||||
"""Send every preference write to scratch, whatever the test does.
|
||||
|
||||
Not a convenience: a single test that built a window without this redirect
|
||||
left its own scratch bundle path in the *real* ``apps/autocal/preference.json``
|
||||
through ``closeEvent``, so the installed app then started up remembering a
|
||||
bundle that did not exist. A test-side leak that presents as a bench bug is
|
||||
worth a session-wide net rather than a note in each test.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
|
||||
scratch = _scratch_dir()
|
||||
prefs_file = scratch / "preference.json"
|
||||
monkeypatch.setattr(paths_mod, "preference_path", lambda: prefs_file)
|
||||
monkeypatch.setattr(prefs_pkg.store, "preference_path", lambda: prefs_file)
|
||||
monkeypatch.setattr(prefs_mod, "preference_path", lambda: prefs_file)
|
||||
yield
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_window(qt_app, monkeypatch):
|
||||
"""Build a window whose preference writes stay inside a scratch directory.
|
||||
|
||||
@@ -62,7 +62,7 @@ _SCOPED_ENUMS = {
|
||||
"TextFlag",
|
||||
"AspectRatioMode",
|
||||
),
|
||||
"QTableWidget": ("SelectionMode", "EditTrigger"),
|
||||
"QTableWidget": ("SelectionMode", "EditTrigger", "SelectionBehavior"),
|
||||
"QSizePolicy": ("Policy",),
|
||||
"QHeaderView": ("ResizeMode",),
|
||||
"QMessageBox": ("StandardButton", "Icon"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
||||
"""The closed-form solve: exactness, agreement with the sweep, and what it reports.
|
||||
|
||||
The sweep in ``test_kinematics.py`` is the reference this path is checked against.
|
||||
It is slow but line-by-line verifiable, and solvers in this file have been
|
||||
confidently wrong five times; a fast path with no reference is how that happens.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion.kinematics import (
|
||||
PlatformStack,
|
||||
Branch,
|
||||
rotation_about,
|
||||
)
|
||||
from orisys_autocal.sensor import load_bundle
|
||||
|
||||
#: The revision the bench confirmed. Skipped rather than failed when absent: the
|
||||
#: export lives in a sibling design repo, and a machine without it has not broken
|
||||
#: AutoCal.
|
||||
BUNDLE = Path(r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def bundle():
|
||||
if not BUNDLE.is_dir():
|
||||
pytest.skip(f"no sensor export at {BUNDLE}")
|
||||
return load_bundle(BUNDLE)
|
||||
|
||||
|
||||
def _random_units(count: int, seed: int) -> list[np.ndarray]:
|
||||
generator = np.random.default_rng(seed)
|
||||
vectors = generator.normal(size=(count, 3))
|
||||
return [vector / np.linalg.norm(vector) for vector in vectors]
|
||||
|
||||
|
||||
def _apply(stack: PlatformStack, unit: np.ndarray, yaw_deg: float, pitch_deg: float) -> np.ndarray:
|
||||
"""Re-apply a returned pose with the library's own rotations."""
|
||||
return (
|
||||
rotation_about(stack.pitch_axis, pitch_deg)
|
||||
@ rotation_about(stack.yaw_axis, yaw_deg)
|
||||
@ unit
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ exactness
|
||||
|
||||
|
||||
def test_the_closed_form_is_exact_on_random_normals():
|
||||
"""Two atan2 calls should land on the answer, not near it."""
|
||||
stack = PlatformStack()
|
||||
worst = 0.0
|
||||
for unit in _random_units(2000, seed=20240517):
|
||||
branches = stack.alignment_branches(unit, stack.tool_direction)
|
||||
assert branches, f"no branch found for {unit}"
|
||||
worst = max(worst, min(branch.residual_deg for branch in branches))
|
||||
assert worst < 1e-4, f"closed form left {worst:.3e} deg on the table"
|
||||
|
||||
|
||||
def test_every_branch_reproduces_its_own_claimed_residual():
|
||||
"""The number reported must be the number the rotations actually give."""
|
||||
stack = PlatformStack()
|
||||
for unit in _random_units(200, seed=7):
|
||||
for branch in stack.alignment_branches(unit, stack.tool_direction):
|
||||
landed = _apply(stack, unit, branch.yaw_deg, branch.pitch_deg)
|
||||
measured = math.degrees(
|
||||
math.acos(
|
||||
max(-1.0, min(1.0, float(landed @ stack.tool_direction)))
|
||||
)
|
||||
)
|
||||
# ``acos`` near 1 loses about sqrt(eps) in radians because the
|
||||
# derivative blows up there; 1e-5 deg is the floating-point floor,
|
||||
# not slack in the answer.
|
||||
assert measured == pytest.approx(branch.residual_deg, abs=1e-5)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ agreement
|
||||
|
||||
|
||||
def test_the_closed_form_is_no_worse_than_the_sweep():
|
||||
"""The sweep is the reference; the fast path may beat it but never lose."""
|
||||
stack = PlatformStack()
|
||||
for unit in _random_units(400, seed=99):
|
||||
fast = stack._search_alignment(unit, stack.tool_direction) # noqa: SLF001
|
||||
branches = stack.alignment_branches(unit, stack.tool_direction)
|
||||
best = min(branch.residual_deg for branch in branches)
|
||||
assert best <= fast[2] + 1e-9, (
|
||||
f"closed form left {best:.6f} deg where the sweep found {fast[2]:.6f}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_two_methods_agree_inside_the_envelope():
|
||||
stack = PlatformStack()
|
||||
for unit in _random_units(200, seed=1234):
|
||||
analytical = stack.solve(np.zeros(3), unit, method="analytical")
|
||||
search = stack.solve(np.zeros(3), unit, method="search")
|
||||
assert analytical.residual_deg <= search.residual_deg + 1e-6
|
||||
for pose in (analytical, search):
|
||||
assert abs(pose.yaw_deg) <= stack.yaw_limit_deg + 1e-6
|
||||
assert abs(pose.pitch_deg) <= stack.pitch_limit_deg + 1e-6
|
||||
|
||||
|
||||
# ----------------------------------------------------------- axis equivalence
|
||||
|
||||
|
||||
def test_the_two_pitch_bindings_are_the_same_family():
|
||||
"""Confirmed at the bench: a 90 degree yaw takes one binding to the other.
|
||||
|
||||
``R_y(p)·R_z(t)`` and ``R_x(p)·R_z(t − 90)`` agree to machine precision. So
|
||||
which axis is called pitch is a naming and zero-offset choice, not a
|
||||
correctness question — the two reach exactly the same set of alignments.
|
||||
"""
|
||||
unit = np.array([0.3, 0.5, -0.81])
|
||||
unit = unit / np.linalg.norm(unit)
|
||||
tool = np.array([0.0, 0.0, -1.0])
|
||||
worst = 0.0
|
||||
for yaw in (-120.0, -45.0, 0.0, 37.0, 90.0, 150.0):
|
||||
for pitch in (-90.0, -20.0, 0.0, 15.0, 75.0):
|
||||
about_y = rotation_about("y", pitch) @ rotation_about("z", yaw) @ unit
|
||||
about_x = rotation_about("x", pitch) @ rotation_about("z", yaw - 90.0) @ unit
|
||||
worst = max(worst, abs(float(about_y @ tool) - float(about_x @ tool)))
|
||||
assert worst == pytest.approx(0.0, abs=1e-12), (
|
||||
f"the two pitch bindings diverge in alignment by {worst:.3e}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_offset_between_the_bindings_is_a_constant_ninety_degrees():
|
||||
"""A constant shift, so it is a commissioning zero rather than a correction."""
|
||||
unit = np.array([0.3, 0.5, -0.81])
|
||||
unit = unit / np.linalg.norm(unit)
|
||||
tool = np.array([0.0, 0.0, -1.0])
|
||||
for yaw in (-120.0, -30.0, 0.0, 45.0, 120.0):
|
||||
for pitch in (-90.0, 0.0, 25.0, 75.0):
|
||||
about_y = rotation_about("y", pitch) @ rotation_about("z", yaw) @ unit
|
||||
same = rotation_about("x", pitch) @ rotation_about("z", yaw - 90.0) @ unit
|
||||
assert float(about_y @ tool) == pytest.approx(float(same @ tool), abs=1e-12)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- reporting
|
||||
|
||||
|
||||
def test_the_normal_is_aligned_to_plus_z_not_minus_z(bundle):
|
||||
"""The regression that would have caught the sensor being turned over.
|
||||
|
||||
``approach_sign`` was ``-1``, which asked the solver to bring every normal onto
|
||||
**-Z** — the sensing face is the *top* surface of a sensor mounted at the bottom
|
||||
of its shell, so it points up, and the tool feeds down onto it. Nothing caught
|
||||
it because ``arccos`` is sign-blind: a face turned fully over still scored
|
||||
``0.0000`` degrees, and every test here asserted only that the residual was
|
||||
small. The failure showed up exclusively as the model flipping upside down.
|
||||
|
||||
So this asserts the *direction* the normal lands in, which is the thing the
|
||||
residual cannot express.
|
||||
"""
|
||||
stack = PlatformStack()
|
||||
assert stack.approach_sign == 1
|
||||
assert np.allclose(stack.tool_direction, [0.0, 0.0, 1.0])
|
||||
|
||||
for normal in bundle.vertex_normals():
|
||||
unit = stack.normal_to_platform(normal)
|
||||
unit = unit / np.linalg.norm(unit)
|
||||
pose = stack.solve(np.zeros(3), normal)
|
||||
# Rotate in the **platform** frame, which is where the residual is measured.
|
||||
# ``stage_rotation`` is the same rotation conjugated into the mesh's frame,
|
||||
# so using it here compares a sensor-frame vector against a platform-frame
|
||||
# target and reports a mismatch that does not exist.
|
||||
landed = _apply(stack, unit, pose.yaw_deg, pose.pitch_deg)
|
||||
landed = landed / np.linalg.norm(landed)
|
||||
# Aligned with the face's aim, not merely parallel to its line.
|
||||
assert landed[2] > 0.0, (
|
||||
f"the normal landed on {np.round(landed, 3)} — pointing away from +Z"
|
||||
)
|
||||
|
||||
|
||||
def test_the_nominal_pose_is_upright(bundle):
|
||||
"""No rotation is the commissioned pose, and it must read as upright."""
|
||||
stack = PlatformStack()
|
||||
assert stack.uprightness(0.0, 0.0) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_the_surface_normals_point_up_so_the_face_faces_the_tool(bundle):
|
||||
"""A sanity check on the geometry the polarity has to agree with.
|
||||
|
||||
The mean normal has a positive platform-Z component: the patch faces upward.
|
||||
If this ever inverted, ``approach_sign`` would need revisiting rather than the
|
||||
solver.
|
||||
"""
|
||||
stack = PlatformStack()
|
||||
platform = np.array(
|
||||
[stack.normal_to_platform(n) for n in bundle.vertex_normals()]
|
||||
)
|
||||
platform /= np.linalg.norm(platform, axis=1)[:, None]
|
||||
assert float(platform[:, 2].mean()) > 0.0, (
|
||||
f"mean platform-Z component of the normals is {platform[:, 2].mean():+.3f}"
|
||||
)
|
||||
|
||||
|
||||
def test_few_vertices_end_turned_over_and_those_are_edge_on(bundle):
|
||||
"""Turning the face over is the failure mode; measure it and bound it.
|
||||
|
||||
The remainder are vertices whose normals are 60-90 degrees off the tool axis —
|
||||
the rim of the dome. Aligning an edge-on face has to roll the body about that
|
||||
normal, and the sign is decided by rounding, so they cannot be pushed to zero
|
||||
and should not be asserted away.
|
||||
"""
|
||||
stack = PlatformStack()
|
||||
normals = bundle.vertex_normals()
|
||||
flipped = 0
|
||||
for normal in normals:
|
||||
pose = stack.solve(np.zeros(3), normal)
|
||||
if stack.uprightness(pose.yaw_deg, pose.pitch_deg) < -0.5:
|
||||
flipped += 1
|
||||
assert flipped <= len(normals) * 0.03, (
|
||||
f"{flipped} of {len(normals)} vertices ended turned over"
|
||||
)
|
||||
|
||||
|
||||
def test_a_pose_reports_whether_it_is_upright():
|
||||
"""A pose can align the normal and still turn the sensor over."""
|
||||
stack = PlatformStack()
|
||||
seen = {True: 0, False: 0}
|
||||
for unit in _random_units(300, seed=3):
|
||||
pose = stack.solve(np.zeros(3), unit)
|
||||
seen[pose.upright] += 1
|
||||
# Whatever it says must match the measured orientation.
|
||||
assert pose.upright == (stack.uprightness(pose.yaw_deg, pose.pitch_deg) >= 0.0)
|
||||
assert seen[True] > 0
|
||||
|
||||
|
||||
def test_an_out_of_range_pose_is_reported_and_never_silently_clamped():
|
||||
"""Tight travel must give a measured residual, not a pose the stage cannot reach."""
|
||||
stack = PlatformStack(pitch_limit_deg=45.0)
|
||||
for unit in _random_units(50, seed=5):
|
||||
pose = stack.solve(np.zeros(3), unit)
|
||||
assert abs(pose.pitch_deg) <= 45.0 + 1e-6
|
||||
assert abs(pose.yaw_deg) <= stack.yaw_limit_deg + 1e-6
|
||||
if not pose.in_range:
|
||||
assert "travel" in pose.reason
|
||||
|
||||
|
||||
def test_nothing_is_reported_out_of_range_when_the_travel_is_ample():
|
||||
stack = PlatformStack()
|
||||
for unit in _random_units(300, seed=11):
|
||||
pose = stack.solve(np.zeros(3), unit)
|
||||
assert pose.in_range, f"{unit} reported out of range at full travel"
|
||||
assert pose.reason == "" or "indeterminate" in pose.reason
|
||||
|
||||
|
||||
def test_reachability_agrees_with_the_envelope():
|
||||
"""Every normal is reachable when the yaw can turn a full half-turn."""
|
||||
stack = PlatformStack()
|
||||
for unit in _random_units(500, seed=13):
|
||||
reachable, why = stack.reachable(unit, stack.tool_direction)
|
||||
assert reachable, f"{unit} unreachable: {why}"
|
||||
# And with the pitch stage able to turn, no normal is rejected either.
|
||||
assert stack.reachable(
|
||||
np.array([0.0, 0.0, 1.0]), stack.tool_direction
|
||||
)[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- poles
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"normal",
|
||||
[
|
||||
np.array([0.0, 1.0, 0.0]), # on the tool axis
|
||||
np.array([0.0, -1.0, 0.0]), # on the tool axis, other way
|
||||
np.array([1.0, 0.0, 0.0]), # on the yaw plane
|
||||
np.array([0.0, 0.0, 1.0]), # on the yaw axis
|
||||
],
|
||||
)
|
||||
def test_poles_still_return_a_usable_pose(normal):
|
||||
stack = PlatformStack()
|
||||
pose = stack.solve(np.zeros(3), normal)
|
||||
assert pose.ok
|
||||
assert math.isfinite(pose.yaw_deg) and math.isfinite(pose.pitch_deg)
|
||||
landed = _apply(stack, stack.normal_to_platform(normal) / np.linalg.norm(
|
||||
stack.normal_to_platform(normal)
|
||||
), pose.yaw_deg, pose.pitch_deg)
|
||||
measured = math.degrees(
|
||||
math.acos(max(-1.0, min(1.0, float(landed @ stack.tool_direction))))
|
||||
)
|
||||
assert measured <= stack.tolerance_deg + 1e-6
|
||||
|
||||
|
||||
def test_the_degenerate_normal_is_still_refused():
|
||||
stack = PlatformStack()
|
||||
pose = stack.solve(np.zeros(3), np.zeros(3))
|
||||
assert not pose.ok
|
||||
assert "degenerate" in pose.reason
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- selection
|
||||
|
||||
|
||||
def test_select_branch_prefers_in_range_then_upright_then_small():
|
||||
reachable_upright = Branch(10.0, 5.0, 1e-6, True, True)
|
||||
reachable_inverted = Branch(10.0, 5.0, 1e-6, False, True)
|
||||
unreachable_upright = Branch(179.0, 5.0, 1e-6, True, False)
|
||||
|
||||
# In range beats everything: an inverted pose the machine can reach is worth
|
||||
# more than an upright one it cannot.
|
||||
assert PlatformStack.select_branch(
|
||||
[unreachable_upright, reachable_inverted, reachable_upright]
|
||||
) is reachable_upright
|
||||
# Among reachable poses, upright wins.
|
||||
assert PlatformStack.select_branch(
|
||||
[reachable_inverted, reachable_upright]
|
||||
) is reachable_upright
|
||||
# Among poses that are out of range and one of them upright, upright wins.
|
||||
unreachable_inverted = Branch(179.0, 5.0, 1e-6, False, False)
|
||||
assert PlatformStack.select_branch(
|
||||
[unreachable_inverted, unreachable_upright]
|
||||
) is unreachable_upright
|
||||
# A materially better alignment beats an upright pose.
|
||||
much_better = Branch(10.0, 5.0, 0.0, False, True)
|
||||
worse_upright = Branch(10.0, 5.0, 5.0, True, True)
|
||||
assert PlatformStack.select_branch([much_better, worse_upright]) is much_better
|
||||
# And with everything else equal, the smaller move wins.
|
||||
smaller = Branch(5.0, 5.0, 1e-6, True, True)
|
||||
larger = Branch(60.0, 5.0, 1e-6, True, True)
|
||||
assert PlatformStack.select_branch([larger, smaller]) is smaller
|
||||
|
||||
|
||||
def test_an_unknown_method_is_refused():
|
||||
stack = PlatformStack()
|
||||
with pytest.raises(ValueError):
|
||||
stack.solve(np.zeros(3), np.array([0.0, 1.0, 0.0]), method="nonsense")
|
||||
@@ -1,19 +1,20 @@
|
||||
"""The simulated 5-axis solve: residuals, the stage envelope, and the frames.
|
||||
|
||||
The solver is the part of this feature that is easiest to get wrong and hardest
|
||||
to notice: a wrong derivation still returns two plausible angles. So the central
|
||||
test here does not check a formula — it brute-forces the stage envelope and
|
||||
requires the solver to be no worse than the best pose that exists.
|
||||
The shipped solver is **closed form** (see ``test_ik_analytical.py``, which checks
|
||||
it against both the sweep and an exhaustive brute force). What this module covers
|
||||
is everything around it: the mount mapping, the envelope, the frames, the reported
|
||||
contact point, and the selectable sweep path.
|
||||
|
||||
That check is what caught the four wrong versions before it: rotating the
|
||||
sensor-frame normal instead of the platform-frame one, composing the stages in
|
||||
the wrong order, a seeded search that skipped the right basin, and a sweep that
|
||||
ranked yaws by the wrong quantity and so reported a quarter of the patch as
|
||||
unreachable when all of it aligns.
|
||||
The solver is the part of this feature that is easiest to get wrong and hardest
|
||||
to notice — a wrong derivation still returns two plausible angles — so its results
|
||||
are still checked against a brute-force search, just not with a fine grid over
|
||||
seven normals when the closed form now guarantees exactness on its own.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
@@ -47,7 +48,13 @@ NORMALS = (
|
||||
def _brute_force_best(
|
||||
stack: PlatformStack, unit_platform: np.ndarray, *, step: float = 0.25
|
||||
) -> float:
|
||||
"""The lowest residual any pose in the envelope achieves — by search."""
|
||||
"""The lowest residual any pose in the envelope achieves — by search.
|
||||
|
||||
Kept, but no longer run over the whole grid for every normal: the shipped
|
||||
solver is closed form and ``test_ik_analytical.py`` checks it against an
|
||||
exhaustive sweep. This survives as the reference for the *sweep* path, which
|
||||
is still selectable and still needs a floor to be measured against.
|
||||
"""
|
||||
tool = stack.tool_direction
|
||||
yaw_limit = min(180.0, stack.yaw_limit_deg)
|
||||
pitch_limit = min(360.0, stack.pitch_limit_deg)
|
||||
@@ -122,20 +129,61 @@ def test_swapping_the_mapping_changes_the_answer():
|
||||
|
||||
def test_the_bench_envelope_is_the_default():
|
||||
stack = PlatformStack()
|
||||
assert stack.yaw_limit_deg == 90.0
|
||||
# Confirmed at the bench and against the trunnion drawing: the vertical stage
|
||||
# turns a full half-turn either way, the horizontal one turns continuously.
|
||||
assert stack.yaw_limit_deg == 180.0
|
||||
assert stack.pitch_limit_deg == 360.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("normal", NORMALS)
|
||||
def test_the_solver_is_never_worse_than_a_brute_force_search(normal):
|
||||
"""The test that pins the derivation down."""
|
||||
def test_the_nominal_normal_needs_no_rotation_and_lands_on_plus_z():
|
||||
"""The polarity check, and the anchor for the whole solve.
|
||||
|
||||
The sensor sits at the bottom of its shell with the sensing face up, so the
|
||||
face points along ``+tool_axis`` and the nominal pose is *no rotation at all*.
|
||||
|
||||
This is the test that was missing. ``approach_sign`` was ``-1``, aiming every
|
||||
normal at ``-Z``; the solver dutifully used pitch 180 and turned the sensor
|
||||
over, and nothing noticed because the residual is sign-blind — a face turned
|
||||
fully over still scored 0.0000 degrees. Assert the direction landed in, not
|
||||
just the angle between the lines.
|
||||
"""
|
||||
stack = PlatformStack()
|
||||
assert stack.approach_sign == 1
|
||||
assert np.allclose(stack.tool_direction, [0.0, 0.0, 1.0])
|
||||
|
||||
nominal = np.array([0.0, 1.0, 0.0]) # sensor +Y, the direction the face looks
|
||||
unit = stack.normal_to_platform(nominal)
|
||||
unit = unit / np.linalg.norm(unit)
|
||||
pose = stack.solve(np.array([0.0, 5.0, 0.0]), nominal)
|
||||
|
||||
landed = rotation_about(stack.pitch_axis, pose.pitch_deg) @ rotation_about(
|
||||
stack.yaw_axis, pose.yaw_deg
|
||||
) @ unit
|
||||
landed = landed / np.linalg.norm(landed)
|
||||
assert landed[2] > 0.0, f"the face was aimed at {np.round(landed, 3)}, not +Z"
|
||||
# And it gets there without turning: the commissioned pose is the identity.
|
||||
assert abs(pose.pitch_deg) < 1e-6
|
||||
assert abs(pose.yaw_deg) < 1e-6
|
||||
assert pose.upright
|
||||
|
||||
|
||||
def test_the_sweep_is_never_worse_than_a_brute_force_search():
|
||||
"""The sweep path still needs a floor, but one normal is enough to pin it.
|
||||
|
||||
The shipped solver is closed form and ``test_ik_analytical.py`` checks it
|
||||
against an exhaustive sweep over many normals. This keeps the *search* path
|
||||
honest with one coarse brute force, rather than seven fine ones — the fine
|
||||
version cost about three seconds per normal to confirm something the closed
|
||||
form now guarantees outright.
|
||||
"""
|
||||
stack = PlatformStack()
|
||||
normal = NORMALS[4]
|
||||
unit = stack.normal_to_platform(normal)
|
||||
unit = unit / np.linalg.norm(unit)
|
||||
pose = stack.solve(np.array([0.0, 5.0, 0.0]), normal)
|
||||
best = _brute_force_best(stack, unit, step=0.5)
|
||||
assert pose.residual_deg <= best + 0.05, (
|
||||
f"the solver returned {pose.residual_deg:.3f} deg where "
|
||||
pose = stack.solve(np.array([0.0, 5.0, 0.0]), normal, method="search")
|
||||
best = _brute_force_best(stack, unit, step=2.0)
|
||||
assert pose.residual_deg <= best + 0.1, (
|
||||
f"the sweep returned {pose.residual_deg:.3f} deg where "
|
||||
f"{best:.3f} deg is achievable"
|
||||
)
|
||||
|
||||
@@ -361,13 +409,134 @@ def test_the_plate_lifts_the_point_along_the_tool_axis():
|
||||
assert np.allclose(lifted - plain, [0.0, 0.0, 2.5])
|
||||
|
||||
|
||||
def test_the_tool_offset_shifts_the_reported_point():
|
||||
stack = PlatformStack(tool_offset_mm=(1.0, 2.0, 3.0))
|
||||
point = np.array([0.0, 5.0, 0.0])
|
||||
def test_the_tool_position_is_the_same_number_the_move_uses():
|
||||
"""One field for "where the tool is", not two that can drift apart.
|
||||
|
||||
There used to be a separate ``tool_offset_mm`` added to the *reported* contact
|
||||
while the drawing left the geometry alone: the panel then reported one landing
|
||||
point and drew another, 10 mm away. The reported move, the drawn move and the
|
||||
landing check all read ``tool_xy_mm`` now, and this pins that.
|
||||
"""
|
||||
stack = PlatformStack(tool_xy_mm=(10.0, -4.0))
|
||||
point = np.array([2.0, 9.0, 1.0])
|
||||
normal = np.array([0.3, 0.9, 0.2])
|
||||
normal = normal / np.linalg.norm(normal)
|
||||
pose = stack.solve(point, normal)
|
||||
landed = stack.platform_rotation(pose.yaw_deg, pose.pitch_deg) @ stack.setup_point(point)
|
||||
final = landed + np.array([pose.table_xy_mm[0], pose.table_xy_mm[1], 0.0])
|
||||
assert final[0] == pytest.approx(10.0, abs=1e-9)
|
||||
assert final[1] == pytest.approx(-4.0, abs=1e-9)
|
||||
# And there is no second place a tool position could hide.
|
||||
assert not hasattr(stack, "tool_offset_mm")
|
||||
|
||||
|
||||
def test_the_reported_xy_is_where_the_point_is_not_where_the_table_goes():
|
||||
"""Two different numbers, kept apart on purpose."""
|
||||
stack = PlatformStack(tool_xy_mm=(3.0, 3.0))
|
||||
point = np.array([2.0, 5.0, 3.0])
|
||||
stored = stack.setup_point(point)
|
||||
pose = stack.solve(point, np.array([0.0, 1.0, 0.0]))
|
||||
plain = PlatformStack().solve(point, np.array([0.0, 1.0, 0.0]))
|
||||
assert pose.target_xy_mm[0] == pytest.approx(plain.target_xy_mm[0] + 1.0)
|
||||
assert pose.target_xy_mm[1] == pytest.approx(plain.target_xy_mm[1] + 2.0)
|
||||
assert pose.target_xy_mm[0] == pytest.approx(stored[0])
|
||||
assert pose.target_xy_mm[1] == pytest.approx(stored[1])
|
||||
# The move is what carries that point to the tool, so it is the rest of the way.
|
||||
assert pose.table_xy_mm[0] == pytest.approx(3.0 - stored[0])
|
||||
assert pose.table_xy_mm[1] == pytest.approx(3.0 - stored[1])
|
||||
|
||||
|
||||
# ------------------------------------------------- the setup and the tool position
|
||||
|
||||
|
||||
def test_the_default_tool_is_above_the_table_origin():
|
||||
"""The default has to be the origin, or every reported move is off by a patch."""
|
||||
stack = PlatformStack()
|
||||
assert stack.tool_xy_mm == (0.0, 0.0)
|
||||
point = np.array([3.0, 8.0, 1.0])
|
||||
pose = stack.solve(point, np.array([0.0, 1.0, 0.0]))
|
||||
# With the tool at the origin, the move is the contact's position negated: the
|
||||
# table has to bring a point that is over there back to the middle.
|
||||
assert pose.table_xy_mm[0] == pytest.approx(-pose.target_xy_mm[0])
|
||||
assert pose.table_xy_mm[1] == pytest.approx(-pose.target_xy_mm[1])
|
||||
|
||||
|
||||
def test_the_table_move_lands_the_contact_on_the_tool():
|
||||
"""The one invariant the whole machine exists to satisfy."""
|
||||
stack = PlatformStack(
|
||||
tool_xy_mm=(4.0, -7.0),
|
||||
sensor_offset_mm=(1.0, 2.0, 3.0),
|
||||
sensor_yaw_deg=25.0,
|
||||
)
|
||||
for point, normal in (
|
||||
(np.array([2.0, 9.0, 1.0]), np.array([0.3, 0.9, 0.2])),
|
||||
(np.array([-4.0, 6.0, 3.0]), np.array([-0.6, 0.5, 0.4])),
|
||||
(np.array([0.0, 11.0, 0.0]), np.array([0.0, 1.0, 0.0])),
|
||||
):
|
||||
pose = stack.solve(point, normal / np.linalg.norm(normal))
|
||||
assert pose.ok
|
||||
landed = stack.platform_rotation(pose.yaw_deg, pose.pitch_deg) @ stack.setup_point(
|
||||
point
|
||||
)
|
||||
final = landed + np.array([pose.table_xy_mm[0], pose.table_xy_mm[1], 0.0])
|
||||
assert final[0] == pytest.approx(stack.tool_xy_mm[0], abs=1e-9)
|
||||
assert final[1] == pytest.approx(stack.tool_xy_mm[1], abs=1e-9)
|
||||
|
||||
|
||||
def test_the_mounting_offset_moves_the_point_and_the_mounting_yaw_the_whole_frame():
|
||||
"""x/y translate the contact; the yaw turns it about the table origin."""
|
||||
stack = PlatformStack()
|
||||
point = np.array([2.0, 6.0, 1.0])
|
||||
normal = np.array([0.2, 0.95, 0.1])
|
||||
normal = normal / np.linalg.norm(normal)
|
||||
base = stack.solve(point, normal)
|
||||
|
||||
shifted = dataclasses.replace(stack, sensor_offset_mm=(1.5, -2.5, 0.0))
|
||||
moved = shifted.solve(point, normal)
|
||||
assert moved.target_xy_mm[0] - base.target_xy_mm[0] == pytest.approx(1.5)
|
||||
assert moved.target_xy_mm[1] - base.target_xy_mm[1] == pytest.approx(-2.5)
|
||||
assert moved.contact_z_mm == pytest.approx(base.contact_z_mm)
|
||||
|
||||
turned = dataclasses.replace(stack, sensor_yaw_deg=35.0)
|
||||
yawed = turned.solve(point, normal)
|
||||
# A yaw about the table's z keeps the radius from the origin and the height.
|
||||
assert float(np.hypot(*yawed.target_xy_mm)) == pytest.approx(
|
||||
float(np.hypot(*base.target_xy_mm)), rel=1e-9
|
||||
)
|
||||
assert yawed.contact_z_mm == pytest.approx(base.contact_z_mm)
|
||||
# The mounting yaw is absorbed by the stage: it has to turn that much less.
|
||||
difference = (base.yaw_deg - yawed.yaw_deg) % 360.0
|
||||
assert min(difference, 360.0 - difference) == pytest.approx(35.0, abs=1e-9)
|
||||
assert yawed.pitch_deg == pytest.approx(base.pitch_deg)
|
||||
assert yawed.residual_deg == pytest.approx(base.residual_deg, abs=1e-9)
|
||||
|
||||
|
||||
def test_the_setup_z_is_the_mounting_plate():
|
||||
"""The plate is not a separate input: it is the z of the mounting pose."""
|
||||
stack = PlatformStack()
|
||||
point = np.array([1.0, 7.0, 2.0])
|
||||
normal = np.array([0.1, 0.99, 0.0])
|
||||
normal = normal / np.linalg.norm(normal)
|
||||
plain = stack.solve(point, normal)
|
||||
|
||||
lifted = dataclasses.replace(stack, sensor_offset_mm=(0.0, 0.0, 4.25))
|
||||
raised = lifted.solve(point, normal)
|
||||
assert raised.contact_z_mm - plain.contact_z_mm == pytest.approx(4.25)
|
||||
assert raised.target_xy_mm == pytest.approx(plain.target_xy_mm)
|
||||
# And it is the same number as the plate parameter it replaced.
|
||||
assert lifted.setup_point(point)[2] == pytest.approx(
|
||||
stack.point_to_platform(point, plate_thickness_mm=4.25)[2]
|
||||
)
|
||||
|
||||
|
||||
def test_the_setup_normal_is_the_mounting_yaw_applied_to_the_mount():
|
||||
stack = PlatformStack(sensor_yaw_deg=90.0)
|
||||
normal = np.array([1.0, 0.0, 0.0])
|
||||
# Sensor +x is platform +x; a 90 degree mounting yaw sends it to platform +y.
|
||||
assert np.allclose(stack.setup_normal(normal), [0.0, 1.0, 0.0], atol=1e-12)
|
||||
assert stack.normal_to_platform(normal) == pytest.approx([1.0, 0.0, 0.0])
|
||||
|
||||
|
||||
def test_a_non_finite_sensor_yaw_is_refused():
|
||||
with pytest.raises(ValueError):
|
||||
PlatformStack(sensor_yaw_deg=float("nan")).setup_rotation()
|
||||
|
||||
|
||||
def test_the_approach_sign_is_validated():
|
||||
|
||||
@@ -1,586 +0,0 @@
|
||||
"""The single-page shell, from an operator's point of view.
|
||||
|
||||
These assertions are about the layout contract rather than any one panel:
|
||||
|
||||
* the motion status strip is outside the tabs, so it survives every tab change;
|
||||
* all five tabs exist, are labelled, and their pages fit;
|
||||
* the right-hand readout rail shows every drive and issues no per-axis command;
|
||||
* the window refuses to shrink below the size at which the page would clip.
|
||||
|
||||
The overflow check lives in ``test_panel_smoke.py`` because it needs the colour
|
||||
tables; what is here is the structure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import pump
|
||||
|
||||
from orisys_autocal.sequence_io import SequenceFormatError, SequencePoint
|
||||
from orisys_autocal.ui.workspace import TABS
|
||||
|
||||
|
||||
def _show(window, size=(1600, 900)):
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window.resize(*size)
|
||||
window.show()
|
||||
QApplication.processEvents()
|
||||
return window
|
||||
|
||||
|
||||
def test_the_tab_set_is_the_documented_one(make_window):
|
||||
window = _show(make_window())
|
||||
assert window.workspace.page_keys() == [key for key, _ in TABS]
|
||||
|
||||
|
||||
def test_every_tab_has_a_localised_label(make_window):
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
window = _show(make_window())
|
||||
for index, (_key, locale_key) in enumerate(TABS):
|
||||
label = window.workspace.tabText(index)
|
||||
assert label, f"tab {index} has no label"
|
||||
assert label == t(locale_key)
|
||||
assert label != locale_key, f"tab {index} shows its raw key"
|
||||
|
||||
|
||||
def test_the_status_strip_survives_every_tab_change(make_window):
|
||||
"""The whole point of putting the strip above the tabs."""
|
||||
window = _show(make_window())
|
||||
for key in window.workspace.page_keys():
|
||||
window.workspace.set_current_key(key)
|
||||
assert window.status_strip.isVisible(), f"the strip hid on tab {key}"
|
||||
assert window.status_strip.height() <= 48
|
||||
|
||||
|
||||
def test_the_strip_reports_connection_and_bus_state(make_window):
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from orisys_autocal.motion import BusStatus, Snapshot
|
||||
|
||||
window = _show(make_window())
|
||||
strip = window.status_strip
|
||||
|
||||
strip.set_snapshot(Snapshot(connected=False))
|
||||
assert strip.chip_connection.text() == t("connection.state_closed")
|
||||
assert strip.chip_init.text() == t("bus.state_pending")
|
||||
|
||||
strip.set_snapshot(
|
||||
Snapshot(connected=True, bus=BusStatus(bus_type="ecat", init_status=1, total_axes=5))
|
||||
)
|
||||
assert strip.chip_connection.text() == t("connection.state_open")
|
||||
assert strip.chip_bus.text() == t("bus.type_ecat")
|
||||
assert strip.chip_init.text() == t("bus.state_ok")
|
||||
|
||||
|
||||
def test_a_refusal_stays_visible_on_the_strip(make_window):
|
||||
"""A command refused while the operator was on another tab must not vanish."""
|
||||
window = _show(make_window())
|
||||
window.workspace.set_current_key("sensor")
|
||||
window.status_strip.set_action_result("轴0 回零模式未设置", False)
|
||||
assert "回零模式" in window.status_strip.action_label.text()
|
||||
|
||||
|
||||
def test_the_readout_rail_covers_every_axis(make_window):
|
||||
window = _show(make_window())
|
||||
assert len(window.axis_readout._rows) == window.axis_panel.axis_count == 5
|
||||
|
||||
|
||||
def test_the_readout_rail_shows_position_and_state(make_window):
|
||||
from orisys_autocal.motion import AxisState, Snapshot
|
||||
|
||||
window = _show(make_window())
|
||||
snapshot = Snapshot(
|
||||
connected=True,
|
||||
axes=(
|
||||
AxisState(axis=0, enabled=True, units=10.0, dpos=1000.0, idle=True),
|
||||
AxisState(axis=1, enabled=False, units=10.0, dpos=0.0, idle=True),
|
||||
),
|
||||
)
|
||||
window.axis_readout.update_snapshot(snapshot)
|
||||
# 1000 user units at units=10 is the documented 2.000 mm, and the readout
|
||||
# shows both so an operator never has to convert in their head.
|
||||
text = window.axis_readout._rows[0]["position"].text()
|
||||
assert "1000.000" in text and "2.000 mm" in text, text
|
||||
assert window.axis_readout._rows[1]["state"].text()
|
||||
|
||||
|
||||
def test_the_readout_rail_issues_no_per_axis_motion(make_window):
|
||||
"""Per-axis commands live in one place only: the Axis-debug tab."""
|
||||
window = _show(make_window())
|
||||
rail = window.axis_readout
|
||||
assert not hasattr(rail, "jogRequested")
|
||||
assert not hasattr(rail, "moveAbsRequested")
|
||||
assert hasattr(rail, "stopAllRequested")
|
||||
assert hasattr(rail, "enableAllRequested")
|
||||
|
||||
|
||||
def test_enable_all_toggles_with_the_current_state(make_window):
|
||||
from orisys_autocal.motion import AxisState, Snapshot
|
||||
|
||||
window = _show(make_window())
|
||||
requested: list[bool] = []
|
||||
window.axis_readout.enableAllRequested.connect(requested.append)
|
||||
|
||||
window.axis_readout.update_snapshot(
|
||||
Snapshot(connected=True, axes=tuple(AxisState(axis=a, enabled=True) for a in range(5)))
|
||||
)
|
||||
window.axis_readout.enable_all_button.click()
|
||||
assert requested == [False], "enable-all did not become disable-all"
|
||||
|
||||
window.axis_readout.update_snapshot(Snapshot(connected=True, axes=()))
|
||||
window.axis_readout.enable_all_button.click()
|
||||
assert requested == [False, True]
|
||||
|
||||
|
||||
def test_the_window_refuses_to_shrink_below_the_page(make_window):
|
||||
window = _show(make_window())
|
||||
window.resize(800, 600)
|
||||
assert window.width() >= window.minimumWidth()
|
||||
assert window.height() >= window.minimumHeight()
|
||||
assert window.minimumWidth() >= 1300
|
||||
|
||||
|
||||
def test_the_bus_data_group_starts_collapsed(make_window):
|
||||
"""It is commissioning-only; expanded, it cost the page ~270 px."""
|
||||
window = _show(make_window())
|
||||
assert not window.bus_data_group.isChecked()
|
||||
assert not window.bus_data_panel.isVisible()
|
||||
|
||||
|
||||
def test_expanding_bus_data_does_not_resize_the_window(make_window):
|
||||
window = _show(make_window())
|
||||
before = (window.width(), window.height())
|
||||
window.bus_data_group.setChecked(True)
|
||||
pump(_app(), 0.1)
|
||||
assert (window.width(), window.height()) == before
|
||||
|
||||
|
||||
def _app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance()
|
||||
|
||||
|
||||
def test_the_sequence_tab_shows_the_documented_profile_fields(make_window):
|
||||
from orisys_autocal.ui.sequence_panel import PROFILE_FIELDS
|
||||
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
assert panel.profile_keys() == [key for key, _locale, _kind in PROFILE_FIELDS]
|
||||
|
||||
|
||||
def test_the_sequence_panel_exposes_its_three_zones(make_window):
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
assert panel.validation_checks() == [
|
||||
"on_surface",
|
||||
"within_travel",
|
||||
"contact_confirmable",
|
||||
]
|
||||
assert panel.point_row_count() > 0, "the point list is empty"
|
||||
assert panel.summary_label.text()
|
||||
|
||||
|
||||
def test_every_pending_control_names_the_phase_that_owns_it(make_window):
|
||||
"""A disabled control must say why, or it reads as a broken button."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
assert panel.pending_controls() == ["generate", "simulate", "dry_run"]
|
||||
for name, phase in panel.pending_phases().items():
|
||||
button = panel._pending_buttons[name]
|
||||
assert not button.isEnabled(), f"{name} is usable but not implemented"
|
||||
assert phase in button.toolTip(), f"{name} does not name its phase"
|
||||
assert button.text(), f"{name} has no label"
|
||||
|
||||
|
||||
def test_import_and_export_are_live_controls(make_window):
|
||||
"""A sequence is a document, and P8 says it is a versioned artifact."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
for name in ("export", "import"):
|
||||
button = panel._live_buttons[name]
|
||||
assert button.isEnabled(), f"{name} is not usable"
|
||||
assert button.text(), f"{name} has no label"
|
||||
assert button.toolTip()
|
||||
|
||||
|
||||
def test_the_point_list_has_no_empty_action_column(make_window):
|
||||
"""An empty action cell reads as a broken control."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
assert "actions" not in panel.column_keys()
|
||||
|
||||
|
||||
def test_the_sequence_tab_hands_out_no_runnable_steps(make_window):
|
||||
"""A runner must not be able to start from points nothing has validated."""
|
||||
window = _show(make_window())
|
||||
assert window.sequence_panel.planned_steps() == []
|
||||
|
||||
|
||||
def test_point_coordinates_are_generated_not_typed(make_window):
|
||||
"""A typed coordinate is one nothing has checked against the sensor."""
|
||||
from orisys_autocal.ui.sequence_panel import _EDITABLE_POINT_COLUMNS
|
||||
|
||||
assert _EDITABLE_POINT_COLUMNS == ()
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
# The point list is a real table, so it scrolls instead of clipping rows.
|
||||
assert panel.table.rowCount() == panel.point_row_count()
|
||||
assert panel.point_row(0)["x"] == "0.000"
|
||||
# Every cell is read-only: the table's edit triggers are off, so a generated
|
||||
# coordinate cannot be typed over.
|
||||
from PySide6.QtWidgets import QTableWidget
|
||||
|
||||
assert panel.table.editTriggers() == QTableWidget.EditTrigger.NoEditTriggers
|
||||
|
||||
|
||||
def test_the_profile_round_trips_through_preferences(make_window):
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.field("pitch_mm").setText("3.5")
|
||||
panel.field("repeats").setText("4")
|
||||
window._persist_prefs()
|
||||
stored = window.prefs["sequence_profile"]
|
||||
assert stored["pitch_mm"] == 3.5
|
||||
assert stored["repeats"] == 4
|
||||
|
||||
|
||||
def test_an_unusable_profile_number_falls_back_rather_than_raising(make_window):
|
||||
from orisys_autocal.preferences import default_preferences
|
||||
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.field("pitch_mm").setText("not a number")
|
||||
assert panel.profile()["pitch_mm"] == 0.0
|
||||
# A blank repeats field must not become zero repeats.
|
||||
panel.field("repeats").setText("")
|
||||
assert panel.profile()["repeats"] >= 1
|
||||
assert "pitch_mm" in default_preferences()["sequence_profile"]
|
||||
|
||||
|
||||
def test_the_stored_profile_is_loaded_into_the_form(make_window):
|
||||
window = _show(make_window(sequence_profile={"pitch_mm": 7.5, "order": "row"}))
|
||||
panel = window.sequence_panel
|
||||
assert panel.profile()["pitch_mm"] == 7.5
|
||||
assert panel.profile()["order"] == "row"
|
||||
|
||||
|
||||
def test_the_axis_table_shows_seven_columns(make_window):
|
||||
from orisys_autocal.ui.axis_panel import COLUMNS
|
||||
|
||||
window = _show(make_window())
|
||||
assert len(COLUMNS) == 7
|
||||
assert window.axis_panel.table.columnCount() == 8 # seven data + actions
|
||||
|
||||
|
||||
def test_the_axis_detail_line_carries_what_the_table_dropped(make_window):
|
||||
"""Axis type, accel, feedback and the status word must stay reachable."""
|
||||
from orisys_autocal.motion import AxisState, Snapshot
|
||||
|
||||
window = _show(make_window())
|
||||
window.axis_panel.set_selected_axis(3)
|
||||
window.axis_panel.update_snapshot(
|
||||
Snapshot(
|
||||
connected=True,
|
||||
axes=(AxisState(axis=3, enabled=True, atype=65, accel=1234.0, mpos=5.0),),
|
||||
)
|
||||
)
|
||||
detail = window.axis_panel._detail_label.text()
|
||||
assert "65" in detail and "1234" in detail
|
||||
assert "3" in detail
|
||||
|
||||
|
||||
def test_the_axis_detail_follows_the_selected_axis(make_window):
|
||||
from orisys_autocal.motion import AxisState, Snapshot
|
||||
|
||||
window = _show(make_window())
|
||||
window.axis_panel.update_snapshot(
|
||||
Snapshot(
|
||||
connected=True,
|
||||
axes=(AxisState(axis=0, atype=65), AxisState(axis=1, atype=67)),
|
||||
)
|
||||
)
|
||||
window.axis_panel.set_selected_axis(0)
|
||||
assert "65" in window.axis_panel._detail_label.text()
|
||||
window.axis_panel.set_selected_axis(1)
|
||||
assert "67" in window.axis_panel._detail_label.text()
|
||||
|
||||
|
||||
def test_the_window_defaults_to_the_design_resolution(make_window):
|
||||
from orisys_autocal.ui.window import DEFAULT_WINDOW_SIZE, MINIMUM_WINDOW_SIZE
|
||||
|
||||
assert DEFAULT_WINDOW_SIZE == (1920, 1080)
|
||||
assert MINIMUM_WINDOW_SIZE[0] >= 1440
|
||||
|
||||
|
||||
def test_the_default_theme_is_light(make_window):
|
||||
from orisys_autocal.preferences import DEFAULT_THEME
|
||||
|
||||
assert DEFAULT_THEME == "light"
|
||||
window = _show(make_window())
|
||||
assert window._theme == "light"
|
||||
|
||||
|
||||
def test_pausing_the_strip_pauses_the_poller(make_window):
|
||||
window = _show(make_window())
|
||||
window.status_strip.pause_button.setChecked(True)
|
||||
window._on_pause_polling()
|
||||
assert window.poller.paused
|
||||
window.status_strip.pause_button.setChecked(False)
|
||||
window._on_pause_polling()
|
||||
assert not window.poller.paused
|
||||
|
||||
|
||||
def test_the_diagnostics_footer_does_not_move_the_layout(make_window):
|
||||
window = _show(make_window())
|
||||
splitters = window.main_splitter.sizes()
|
||||
window._set_diagnostics(True)
|
||||
window._set_diagnostics(False)
|
||||
assert window.main_splitter.sizes() == splitters
|
||||
|
||||
|
||||
def test_splitter_sizes_round_trip_through_preferences(make_window):
|
||||
window = _show(make_window())
|
||||
window.main_splitter.setSizes([340, 1500])
|
||||
window._persist_prefs()
|
||||
stored = window.prefs["main_splitter_sizes"]
|
||||
assert len(stored) == 2
|
||||
assert stored[1] > stored[0]
|
||||
|
||||
|
||||
def test_the_rail_is_a_single_scrollable_column(make_window):
|
||||
"""Two thin rails bracketing the workspace cost it 240 px at every size."""
|
||||
from orisys_autocal.ui.window import MAIN_SPLITTER_SIZES
|
||||
|
||||
assert len(MAIN_SPLITTER_SIZES) == 2, "the layout is back to three columns"
|
||||
window = _show(make_window())
|
||||
assert window.main_splitter.count() == 2
|
||||
assert window.left_rail.widget() is window.rail_content
|
||||
# The readout lives in the rail now, not in a rail of its own.
|
||||
assert window.rail_content.findChild(type(window.axis_readout)) is window.axis_readout
|
||||
|
||||
|
||||
def test_the_rail_scrolls_rather_than_clipping_its_controls(make_window):
|
||||
"""At 900 px high the three rail groups do not fit; the rail must scroll."""
|
||||
window = _show(make_window(), (1440, 900))
|
||||
viewport = window.left_rail.viewport().height()
|
||||
content = window.rail_content.sizeHint().height()
|
||||
scroll = window.left_rail.verticalScrollBar()
|
||||
assert content <= viewport or scroll.maximum() > 0, (
|
||||
f"rail content is {content}px in a {viewport}px viewport and cannot scroll"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- run controls
|
||||
|
||||
|
||||
def test_the_run_bar_is_visible_from_every_tab(make_window):
|
||||
"""Abort must never require finding the tab that started the run."""
|
||||
window = _show(make_window())
|
||||
for key in window.workspace.page_keys():
|
||||
window.workspace.set_current_key(key)
|
||||
assert window.run_bar.isVisible(), f"the run bar hid on tab {key}"
|
||||
|
||||
|
||||
def test_run_is_wired_and_refuses_by_naming_its_phase(make_window):
|
||||
window = _show(make_window())
|
||||
window.run_bar.run_button.click()
|
||||
assert "P7" in window.status_strip.action_label.text()
|
||||
|
||||
|
||||
def test_abort_stops_the_axes_and_is_enabled_once_a_run_exists(make_window):
|
||||
window = _show(make_window())
|
||||
assert not window.run_bar.abort_button.isEnabled()
|
||||
|
||||
requested = []
|
||||
window.run_bar.abortRequested.connect(lambda: requested.append(True))
|
||||
window.run_bar.set_run(True)
|
||||
assert window.run_bar.abort_button.isEnabled()
|
||||
window.run_bar.abort_button.click()
|
||||
assert requested == [True]
|
||||
assert window.run_bar.phase == "failed"
|
||||
|
||||
|
||||
def test_the_run_bar_shows_progress_and_timing(make_window):
|
||||
window = _show(make_window())
|
||||
window.run_bar.set_progress(7, 40)
|
||||
assert "7" in window.run_bar.step_label.text()
|
||||
assert "40" in window.run_bar.step_label.text()
|
||||
assert window.run_bar.progress.maximum() == 40
|
||||
assert window.run_bar.progress.value() == 7
|
||||
|
||||
window.run_bar.set_timing(125.0, 300.0)
|
||||
assert "2:05" in window.run_bar.time_label.text()
|
||||
# An unknown estimate must say so rather than showing a wrong number.
|
||||
window.run_bar.set_timing(125.0, None)
|
||||
assert "2:05" in window.run_bar.time_label.text()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- poll health
|
||||
|
||||
|
||||
def test_the_health_chip_reports_the_age_of_the_last_reading(make_window):
|
||||
from orisys_autocal.motion import Snapshot
|
||||
|
||||
window = _show(make_window())
|
||||
strip = window.status_strip
|
||||
strip.set_snapshot(Snapshot(connected=True))
|
||||
assert strip.chip_health.text()
|
||||
assert strip.chip_health._tone == "ok"
|
||||
|
||||
|
||||
def test_the_health_chip_ages_when_the_poll_stops(make_window):
|
||||
"""A stalled poll must not keep looking healthy."""
|
||||
from orisys_autocal.motion import Snapshot
|
||||
|
||||
window = _show(make_window())
|
||||
strip = window.status_strip
|
||||
strip.set_poll_interval_ms(100)
|
||||
strip.set_snapshot(Snapshot(connected=True))
|
||||
strip._last_snapshot_at -= 1.5 # fifteen intervals ago
|
||||
strip.refresh_health()
|
||||
assert strip.chip_health._tone == "error", strip.chip_health.text()
|
||||
|
||||
|
||||
def test_the_health_chip_says_paused_rather_than_ageing(make_window):
|
||||
from orisys_autocal.motion import Snapshot
|
||||
|
||||
window = _show(make_window())
|
||||
strip = window.status_strip
|
||||
strip.set_snapshot(Snapshot(connected=True))
|
||||
strip.set_paused(True)
|
||||
strip.refresh_health()
|
||||
assert strip.chip_health._tone == "muted"
|
||||
assert strip.chip_health.text() != ""
|
||||
|
||||
|
||||
def test_the_status_bar_uses_its_own_theme_token(make_window):
|
||||
"""The brand navy reads well as a header and heavy as a footer."""
|
||||
from orisys_app_shared.theme import load_colors
|
||||
|
||||
for theme in ("light", "dark"):
|
||||
colours = load_colors(theme)
|
||||
assert "status_bar_bg" in colours, theme
|
||||
assert "status_bar_text" in colours, theme
|
||||
window = _show(make_window())
|
||||
assert "QStatusBar" in window.statusBar().styleSheet()
|
||||
|
||||
|
||||
def test_the_sequence_summary_reports_count_and_span(make_window):
|
||||
"""The two numbers an operator reasons with, before anything runs."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.set_points(
|
||||
[
|
||||
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
|
||||
SequencePoint(id=2, target_mm={"x": 10.0, "y": 0.0, "z": 12.4}),
|
||||
]
|
||||
)
|
||||
text = panel.summary_label.text()
|
||||
assert "2" in text
|
||||
# A nil Z span prints once, and the real X span prints both ends.
|
||||
assert "0.000–10.000" in text
|
||||
assert "12.400" in text
|
||||
|
||||
|
||||
def test_the_sequence_summary_updates_with_the_profile(make_window):
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.set_points([SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 1.0})])
|
||||
before = panel.summary_label.text()
|
||||
panel.field("repeats").setText("10")
|
||||
assert panel.summary_label.text() != before
|
||||
|
||||
|
||||
def test_an_empty_sequence_says_so_rather_than_showing_zeros(make_window):
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.set_points([])
|
||||
assert panel.summary_label.text()
|
||||
assert "0" not in panel.summary_label.text()[:2]
|
||||
|
||||
|
||||
def test_the_design_is_dirty_until_it_is_saved_or_loaded(make_window):
|
||||
"""Import replaces the design, and there is no autosave."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.mark_clean()
|
||||
assert not panel.dirty
|
||||
panel.field("pitch_mm").setText("9")
|
||||
assert panel.dirty, "a profile edit did not mark the design dirty"
|
||||
|
||||
|
||||
def test_export_then_import_restores_the_design(make_window, tmp_dir):
|
||||
"""The round trip through the real panel, not just the format module."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.set_name("bench sweep")
|
||||
panel.field("pitch_mm").setText("3.5")
|
||||
panel.set_points(
|
||||
[
|
||||
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
|
||||
SequencePoint(id=2, target_mm={"x": 3.5, "y": 0.0, "z": 12.4}),
|
||||
]
|
||||
)
|
||||
path = tmp_dir / "sweep.oryseq"
|
||||
window.export_sequence(str(path))
|
||||
assert path.is_file()
|
||||
assert not panel.dirty, "export did not clear the dirty flag"
|
||||
|
||||
# Change everything, then import it back.
|
||||
panel.set_name("")
|
||||
panel.field("pitch_mm").setText("1")
|
||||
panel.set_points([])
|
||||
document = window.import_sequence(str(path))
|
||||
|
||||
assert document.point_count == 2
|
||||
assert panel.name() == "bench sweep"
|
||||
assert panel.profile()["pitch_mm"] == 3.5
|
||||
assert panel.point_row_count() == 2
|
||||
assert not panel.dirty
|
||||
|
||||
|
||||
def test_importing_a_bad_file_reports_and_changes_nothing(make_window, tmp_dir):
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.field("pitch_mm").setText("4")
|
||||
path = tmp_dir / "bad.oryseq"
|
||||
path.write_text("not json at all", encoding="utf-8")
|
||||
with pytest.raises(SequenceFormatError) as excinfo:
|
||||
window.import_sequence(str(path))
|
||||
assert "not valid JSON" in str(excinfo.value)
|
||||
# The design is untouched: a refused import is not a partial import.
|
||||
assert panel.profile()["pitch_mm"] == 4
|
||||
|
||||
|
||||
def test_an_exported_sequence_names_the_sensor_it_was_designed_for(make_window, tmp_dir):
|
||||
import json
|
||||
|
||||
window = _show(make_window())
|
||||
window.prefs["sensor_setup"] = {
|
||||
"sensor_type": "finger",
|
||||
"geometry_id": "finger-1.0.0",
|
||||
"probe_tip_set": True,
|
||||
}
|
||||
path = tmp_dir / "named.oryseq"
|
||||
window.export_sequence(str(path))
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert document["device"]["sensor_type"] == "finger"
|
||||
assert document["device"]["geometry_id"] == "finger-1.0.0"
|
||||
assert document["versions"]["app"]
|
||||
|
||||
|
||||
def test_language_switch_relabels_the_new_shell(make_window):
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
window = _show(make_window(language="zh_CN"))
|
||||
zh_tabs = [window.workspace.tabText(i) for i in range(window.workspace.count())]
|
||||
window._set_language("en_US")
|
||||
assert window.status_strip.title.text() == t("status.motion")
|
||||
assert window.run_bar.title.text() == t("run.title")
|
||||
en_tabs = [window.workspace.tabText(i) for i in range(window.workspace.count())]
|
||||
assert en_tabs != zh_tabs
|
||||
assert all(en_tabs)
|
||||
@@ -1,328 +0,0 @@
|
||||
"""Guard and state-machine behaviour of the motion service.
|
||||
|
||||
Driven against the simulated platform, so every assertion here is about AutoCal's
|
||||
own logic: what it refuses, what it reads, and when. No test in this file touches
|
||||
a controller or a DLL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import (
|
||||
INIT_FAILED,
|
||||
INIT_OK,
|
||||
INIT_RUNNING,
|
||||
AxisParams,
|
||||
ConnectionSpec,
|
||||
DemoTransport,
|
||||
MotionService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service():
|
||||
return MotionService(DemoTransport())
|
||||
|
||||
|
||||
def _initialise(service, timeout: float = 5.0) -> None:
|
||||
"""Download a BAS program and wait for the simulated bus to report ready."""
|
||||
service.download_bas(__file__, 1)
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline and service.bus.init_status != INIT_OK:
|
||||
service.poll([0])
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- connection
|
||||
|
||||
|
||||
def test_a_fresh_service_is_disconnected_and_touches_nothing(service):
|
||||
assert not service.connected
|
||||
assert not service.bas_loaded
|
||||
assert service.poll([0]).connected is False
|
||||
|
||||
|
||||
def test_connect_reports_success_and_an_unknown_bus_without_a_bas(service):
|
||||
result = service.connect(ConnectionSpec(mode="demo"))
|
||||
assert result.ok
|
||||
assert service.connected
|
||||
# No BAS program has run, so the bus type must not be guessed at.
|
||||
snapshot = service.poll([0])
|
||||
assert snapshot.bus.bus_type == "unknown"
|
||||
assert not snapshot.bus.initialized
|
||||
|
||||
|
||||
def test_connect_failure_is_reported_without_raising(service):
|
||||
result = service.connect(ConnectionSpec(mode="eth", ip="127.0.0.1"))
|
||||
assert not result.ok
|
||||
assert not service.connected
|
||||
|
||||
|
||||
def test_disconnect_clears_bus_state(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
service.disconnect()
|
||||
assert not service.connected
|
||||
assert service.bus.bus_type == "unknown"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- bus init
|
||||
|
||||
|
||||
def test_download_requires_a_real_file(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
assert not service.download_bas("", 1).ok
|
||||
assert not service.download_bas(r"D:\nope\missing.bas", 1).ok
|
||||
|
||||
|
||||
def test_download_needs_a_connection(service):
|
||||
assert not service.download_bas(__file__, 1).ok
|
||||
|
||||
|
||||
def test_download_then_poll_reaches_initialised(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
assert service.download_bas(__file__, 1).ok
|
||||
_initialise(service)
|
||||
assert service.bus.init_status == INIT_OK
|
||||
assert service.bus.bus_type == "ecat"
|
||||
assert service.bus.total_axes == 5
|
||||
assert service.bus.node_count == 5
|
||||
|
||||
|
||||
def test_bus_is_running_immediately_after_download(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
service.download_bas(__file__, 1)
|
||||
assert service.bus.init_status == INIT_RUNNING
|
||||
|
||||
|
||||
def test_reinitialise_is_refused_while_already_running(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
service.download_bas(__file__, 1)
|
||||
assert not service.reinit_bus().ok
|
||||
|
||||
|
||||
def test_reinitialise_is_refused_without_a_bas_program(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
result = service.reinit_bus()
|
||||
assert not result.ok
|
||||
assert "BAS" in result.message or "Bas" in result.message
|
||||
|
||||
|
||||
def test_init_timeout_is_reported_rather_than_raising():
|
||||
"""A controller that never publishes its globals must time out, not hang."""
|
||||
|
||||
class SilentTransport(DemoTransport):
|
||||
def user_var(self, name):
|
||||
from orisys_app_shared.zmotion import ZAuxError
|
||||
|
||||
raise ZAuxError(30002, f"{name} is not defined")
|
||||
|
||||
service = MotionService(SilentTransport())
|
||||
service._init_timeout = 0.05 # noqa: SLF001 - shortening the vendor window
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
service.download_bas(__file__, 1)
|
||||
time.sleep(0.1)
|
||||
snapshot = service.poll([0])
|
||||
assert snapshot.bus.init_status == INIT_FAILED
|
||||
assert snapshot.bus.message # carries the ZDevelop hint, not an empty string
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- guards
|
||||
|
||||
|
||||
def test_motion_is_refused_before_the_bus_is_initialised(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
for attempt in (
|
||||
service.jog(0, True),
|
||||
service.move_abs(0, 100.0),
|
||||
service.move_rel(0, 10.0),
|
||||
service.home(0),
|
||||
):
|
||||
assert not attempt.ok
|
||||
assert attempt.message
|
||||
|
||||
|
||||
def test_motion_is_refused_when_not_connected(service):
|
||||
assert not service.jog(0, True).ok
|
||||
assert not service.move_abs(0, 1.0).ok
|
||||
assert not service.stop(0).ok
|
||||
|
||||
|
||||
def test_motion_on_an_unknown_axis_is_refused(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
result = service.jog(99, True)
|
||||
assert not result.ok
|
||||
assert "99" in result.message or "0-4" in result.message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
|
||||
def test_a_non_finite_target_is_refused(service, bad):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
result = service.move_abs(0, bad)
|
||||
assert not result.ok
|
||||
assert result.message
|
||||
|
||||
|
||||
def test_home_needs_a_configured_mode(service):
|
||||
"""0 is not a valid homing mode; inheriting one would be worse than saying so."""
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
result = service.home(0)
|
||||
assert not result.ok
|
||||
assert "0" in result.message
|
||||
|
||||
|
||||
def test_home_runs_once_a_mode_is_set(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
service.set_axis_params(0, AxisParams(home_mode=17))
|
||||
assert service.home(0).ok
|
||||
|
||||
|
||||
def test_home_is_refused_on_rtex(service):
|
||||
"""The vendor example refuses RTEX homing; so do we."""
|
||||
|
||||
class RtexTransport(DemoTransport):
|
||||
def user_var(self, name):
|
||||
return 1.0 if name == "BUS_TYPE" else super().user_var(name)
|
||||
|
||||
rtex = MotionService(RtexTransport())
|
||||
rtex.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(rtex)
|
||||
assert rtex.bus.bus_type == "rtex"
|
||||
result = rtex.home(0)
|
||||
assert not result.ok
|
||||
assert result.message
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- motion
|
||||
|
||||
|
||||
def test_jog_and_stop_move_the_state_machine(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
assert service.jog(0, True).ok
|
||||
state = service.poll([0]).axis(0)
|
||||
assert state is not None and not state.idle
|
||||
|
||||
assert service.stop(0).ok
|
||||
state = service.poll([0]).axis(0)
|
||||
assert state is not None and state.idle
|
||||
|
||||
|
||||
def test_absolute_and_relative_moves_change_the_command_position(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
assert service.move_abs(1, 250.0).ok
|
||||
assert service.poll([1]).axis(1).dpos == pytest.approx(250.0)
|
||||
assert service.move_rel(1, -50.0).ok
|
||||
assert service.poll([1]).axis(1).dpos == pytest.approx(200.0)
|
||||
|
||||
|
||||
def test_set_axis_params_writes_and_remembers(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
params = AxisParams(units=5.0, speed=200.0, accel=2000.0)
|
||||
assert service.set_axis_params(2, params).ok
|
||||
assert service.params(2) == params
|
||||
assert service.to_mm(2, 1000.0) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_enable_toggles_the_axis(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
assert service.set_axis_enable(3, False).ok
|
||||
assert service.poll([3]).axis(3).enabled is False
|
||||
assert service.set_axis_enable(3, True).ok
|
||||
assert service.poll([3]).axis(3).enabled is True
|
||||
|
||||
|
||||
def test_stop_all_covers_every_axis(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
results = service.stop_all()
|
||||
assert len(results) == service.axis_count
|
||||
assert all(result.ok for result in results)
|
||||
|
||||
|
||||
def test_clear_alarm_reaches_the_drive(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
assert service.clear_alarm(0).ok
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- bus data
|
||||
|
||||
|
||||
def test_sdo_round_trips_on_ethercat(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
assert service.sdo_write(0, 0x6040, 0, 3, 6).ok
|
||||
result, value = service.sdo_read(0, 0x6040, 0, 3)
|
||||
assert result.ok
|
||||
assert value == 6
|
||||
|
||||
|
||||
def test_sdo_is_refused_on_rtex(service):
|
||||
class RtexTransport(DemoTransport):
|
||||
def user_var(self, name):
|
||||
return 1.0 if name == "BUS_TYPE" else super().user_var(name)
|
||||
|
||||
rtex = MotionService(RtexTransport())
|
||||
rtex.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(rtex)
|
||||
result, _value = rtex.sdo_read(0, 0x6040, 0, 3)
|
||||
assert not result.ok
|
||||
|
||||
|
||||
def test_rtex_is_refused_on_ethercat(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
_initialise(service)
|
||||
result, _value = service.rtex_read(0, 1)
|
||||
assert not result.ok
|
||||
|
||||
|
||||
def test_bus_data_is_refused_before_the_bus_type_is_known(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
result, _value = service.sdo_read(0, 0x6040, 0, 3)
|
||||
assert not result.ok
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- polling
|
||||
|
||||
|
||||
def test_polling_reports_every_requested_axis(service):
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
snapshot = service.poll([0, 1, 2, 3, 4])
|
||||
assert [state.axis for state in snapshot.axes] == [0, 1, 2, 3, 4]
|
||||
assert all(state.read_ok for state in snapshot.axes)
|
||||
|
||||
|
||||
def test_a_read_failure_is_marked_not_silently_zeroed():
|
||||
"""A failed read must not look like a position of zero."""
|
||||
|
||||
class FailingReads(DemoTransport):
|
||||
def get_dpos(self, axis):
|
||||
from orisys_app_shared.zmotion import ZAuxError
|
||||
|
||||
raise ZAuxError(30000, "no response")
|
||||
|
||||
service = MotionService(FailingReads())
|
||||
service.connect(ConnectionSpec(mode="demo"))
|
||||
snapshot = service.poll([0])
|
||||
state = snapshot.axis(0)
|
||||
assert state is not None
|
||||
assert state.read_ok is False
|
||||
assert state.error
|
||||
|
||||
|
||||
def test_language_switches_the_refusal_text(service):
|
||||
assert not service.jog(0, True).ok
|
||||
zh = service.jog(0, True).message
|
||||
service.set_language("en_US")
|
||||
en = service.jog(0, True).message
|
||||
assert zh != en
|
||||
@@ -1,331 +0,0 @@
|
||||
"""Offscreen UI smoke tests: construction, layout, gating, and preferences.
|
||||
|
||||
The window is built for real, with the simulated platform standing in for the
|
||||
controller. Nothing here needs a display or hardware.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import pump, visible_descendants
|
||||
|
||||
RESOLUTIONS = ((1440, 900), (1600, 900), (1920, 1080))
|
||||
|
||||
|
||||
def _show(window, size=(1440, 880)):
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window.resize(*size)
|
||||
window.show()
|
||||
QApplication.processEvents()
|
||||
return window
|
||||
|
||||
|
||||
def _enabled(widget) -> bool:
|
||||
"""Whether ``widget`` is effectively enabled, shown or not.
|
||||
|
||||
A plain ``isEnabled()`` is False for every child of a window that has not
|
||||
been shown, which makes an assertion about gating depend on whether the
|
||||
test happened to realise the window. ``isEnabledTo`` answers the question
|
||||
the tests actually mean: would a user be able to operate this control?
|
||||
"""
|
||||
return widget.isEnabledTo(widget.window())
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ structure
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("theme", "language"), [
|
||||
("dark", "zh_CN"),
|
||||
("light", "zh_CN"),
|
||||
("dark", "en_US"),
|
||||
("light", "en_US"),
|
||||
])
|
||||
def test_the_window_builds_in_every_theme_and_language(make_window, theme, language):
|
||||
window = _show(make_window(theme=theme, language=language))
|
||||
assert window.windowTitle()
|
||||
assert window.axis_panel.table.rowCount() == 5
|
||||
|
||||
|
||||
def test_the_product_name_is_localised_in_the_window_title(make_window):
|
||||
window = _show(make_window(language="zh_CN"))
|
||||
zh = window.windowTitle()
|
||||
window._set_language("en_US")
|
||||
assert window.windowTitle() != zh
|
||||
|
||||
|
||||
def test_every_axis_row_has_a_labelled_control(make_window):
|
||||
window = _show(make_window())
|
||||
panel = window.axis_panel
|
||||
for row in range(panel.axis_count):
|
||||
widgets = panel._rows[row]
|
||||
for key in ("enable_button", "fwd_button", "rev_button", "stop_button"):
|
||||
assert widgets[key].text().strip(), f"row {row} {key} has no label"
|
||||
|
||||
|
||||
def test_the_table_shows_one_column_per_documented_quantity(make_window):
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from orisys_autocal.ui.axis_panel import COLUMNS
|
||||
|
||||
window = _show(make_window())
|
||||
headers = [
|
||||
window.axis_panel.table.horizontalHeaderItem(index).text()
|
||||
for index in range(window.axis_panel.table.columnCount())
|
||||
]
|
||||
assert headers == [t(key) for key in COLUMNS] + [t("axes.controls")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", RESOLUTIONS)
|
||||
def test_every_axis_row_is_reachable(make_window, size):
|
||||
"""No drive may be hidden by the splitter.
|
||||
|
||||
The panel asks for three rows without scrolling; at the default window size
|
||||
all five must be visible, because a clipped fifth row on a five-axis platform
|
||||
reads as a missing drive rather than as a scrollbar. If the panel is ever
|
||||
squeezed below that, the table has to scroll rather than silently cut a row.
|
||||
"""
|
||||
window = _show(make_window(), size)
|
||||
table = window.axis_panel.table
|
||||
rows_height = sum(
|
||||
table.rowHeight(row) for row in range(window.axis_panel.axis_count)
|
||||
)
|
||||
header = table.horizontalHeader().height()
|
||||
available = table.viewport().height()
|
||||
|
||||
assert available >= rows_height or table.verticalScrollBar().maximum() > 0, (
|
||||
f"{size}: rows need {rows_height}px, viewport has {available}px "
|
||||
f"(header {header}px) and it cannot scroll"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", RESOLUTIONS)
|
||||
def test_no_visible_child_escapes_its_parent(make_window, size):
|
||||
window = _show(make_window(), size)
|
||||
oversize = []
|
||||
for child in visible_descendants(window):
|
||||
if _inside_scroll_area(child):
|
||||
# A scroll area's content is *meant* to exceed its viewport: the rail
|
||||
# scrolls instead of squeezing its controls into an unreadable column.
|
||||
continue
|
||||
parent = child.parentWidget()
|
||||
if parent is None or not parent.isVisible():
|
||||
continue
|
||||
geometry = child.geometry()
|
||||
if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1:
|
||||
oversize.append(
|
||||
f"{type(child).__name__} {geometry.width()}x{geometry.height()}"
|
||||
f" > parent {parent.width()}x{parent.height()}"
|
||||
)
|
||||
assert not oversize, oversize
|
||||
|
||||
|
||||
def _inside_scroll_area(widget) -> bool:
|
||||
"""Whether ``widget`` is content of a QAbstractScrollArea, not a laid-out child."""
|
||||
from PySide6.QtWidgets import QAbstractScrollArea
|
||||
|
||||
parent = widget.parentWidget()
|
||||
while parent is not None:
|
||||
if isinstance(parent, QAbstractScrollArea):
|
||||
return True
|
||||
parent = parent.parentWidget()
|
||||
return False
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- gating
|
||||
|
||||
|
||||
def test_motion_controls_start_disabled_without_a_bus(make_window):
|
||||
window = _show(make_window())
|
||||
assert not _enabled(window.axis_panel._rows[0]["fwd_button"])
|
||||
assert not _enabled(window.axis_panel._rows[0]["rev_button"])
|
||||
|
||||
|
||||
def test_bus_data_panels_follow_the_reported_bus_type(make_window):
|
||||
window = _show(make_window())
|
||||
window.bus_data_panel.set_connected(True)
|
||||
|
||||
window.bus_data_panel.set_bus_type("ecat")
|
||||
assert _enabled(window.bus_data_panel.ecat_box)
|
||||
assert not _enabled(window.bus_data_panel.rtex_box)
|
||||
|
||||
window.bus_data_panel.set_bus_type("rtex")
|
||||
assert _enabled(window.bus_data_panel.rtex_box)
|
||||
assert not _enabled(window.bus_data_panel.ecat_box)
|
||||
|
||||
window.bus_data_panel.set_bus_type("unknown")
|
||||
assert not _enabled(window.bus_data_panel.ecat_box)
|
||||
assert not _enabled(window.bus_data_panel.rtex_box)
|
||||
|
||||
|
||||
def test_bus_data_panels_are_disabled_while_disconnected(make_window):
|
||||
window = _show(make_window())
|
||||
window.bus_data_panel.set_bus_type("ecat")
|
||||
window.bus_data_panel.set_connected(False)
|
||||
assert not _enabled(window.bus_data_panel.ecat_box)
|
||||
|
||||
|
||||
def test_axis_rows_dim_when_the_bus_reports_fewer_drives(make_window):
|
||||
window = _show(make_window())
|
||||
window.axis_panel.set_connected(True)
|
||||
window.axis_panel.set_axis_count(3)
|
||||
assert _enabled(window.axis_panel._rows[2]["enable_button"])
|
||||
assert not _enabled(window.axis_panel._rows[3]["enable_button"])
|
||||
|
||||
|
||||
def test_unknown_connection_modes_report_instead_of_pretending(make_window):
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
window = _show(make_window())
|
||||
panel = window.connection_panel
|
||||
index = panel.mode_combo.findData("com")
|
||||
panel.mode_combo.setCurrentIndex(index)
|
||||
panel._emit_connect()
|
||||
assert panel.status_label.text() != t("connection.state_open")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- actions
|
||||
|
||||
|
||||
def test_the_selected_axis_follows_a_table_click(make_window):
|
||||
window = _show(make_window())
|
||||
window.axis_panel._on_cell_clicked(3, 0)
|
||||
assert window.axis_panel.selected_axis == 3
|
||||
assert window.axis_panel.axis_index_label.text() == "3"
|
||||
|
||||
|
||||
def test_blank_numeric_fields_fall_back_to_defaults(make_window):
|
||||
from orisys_autocal.motion import DEFAULT_UNITS
|
||||
|
||||
window = _show(make_window())
|
||||
panel = window.axis_panel
|
||||
panel.units_edit.setText("")
|
||||
assert panel.current_params().units == DEFAULT_UNITS
|
||||
panel.units_edit.setText("not a number")
|
||||
assert panel.current_params().units == DEFAULT_UNITS
|
||||
|
||||
|
||||
def test_a_blank_target_does_not_emit_a_move(make_window):
|
||||
window = _show(make_window())
|
||||
emitted = []
|
||||
window.axis_panel.moveAbsRequested.connect(lambda axis, value: emitted.append(value))
|
||||
window.axis_panel.target_edit.setText("")
|
||||
window.axis_panel._on_move_abs()
|
||||
assert emitted == []
|
||||
|
||||
|
||||
def test_applying_parameters_persists_them_in_the_panel(make_window):
|
||||
from orisys_autocal.motion import AxisParams
|
||||
|
||||
window = _show(make_window())
|
||||
panel = window.axis_panel
|
||||
panel.set_selected_axis(1)
|
||||
panel.units_edit.setText("5")
|
||||
panel.speed_edit.setText("250")
|
||||
panel.accel_edit.setText("2500")
|
||||
panel._on_apply()
|
||||
stored = panel.stored_params()[1]
|
||||
assert isinstance(stored, AxisParams)
|
||||
assert stored.units == 5.0
|
||||
|
||||
|
||||
# --------------------------------------------------------------- preferences
|
||||
|
||||
|
||||
def test_the_window_geometry_round_trips_through_preferences(make_window):
|
||||
# Above the enforced minimum (1400x860): a smaller request would be clamped,
|
||||
# which tests Qt's constraint rather than the preference round-trip.
|
||||
window = _show(make_window(), (1520, 920))
|
||||
window._persist_prefs()
|
||||
geometry = window.prefs["window_geometry"]
|
||||
assert len(geometry) == 4
|
||||
assert geometry[2] == pytest.approx(1520, abs=5)
|
||||
assert geometry[3] == pytest.approx(920, abs=5)
|
||||
|
||||
|
||||
def test_a_malformed_geometry_is_ignored_rather_than_crashing(make_window):
|
||||
window = _show(make_window(window_geometry=["nonsense"]))
|
||||
assert window.width() > 0
|
||||
|
||||
|
||||
def test_axis_parameters_survive_a_persist_reload(make_window):
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
|
||||
window = _show(make_window())
|
||||
window.axis_panel.set_selected_axis(2)
|
||||
window.axis_panel.units_edit.setText("7")
|
||||
window.axis_panel._on_apply()
|
||||
window._persist_prefs()
|
||||
|
||||
reloaded = prefs_mod.load_preferences()
|
||||
assert reloaded["axis_params"]["2"]["units"] == 7.0
|
||||
|
||||
|
||||
def test_the_bus_path_is_remembered(make_window):
|
||||
window = _show(make_window())
|
||||
window.bus_panel.set_bas_path(r"D:\somewhere\ECAT.bas")
|
||||
window._persist_prefs()
|
||||
assert window.prefs["bas_path"] == r"D:\somewhere\ECAT.bas"
|
||||
|
||||
|
||||
def test_sdo_fields_are_remembered(make_window):
|
||||
window = _show(make_window())
|
||||
panel = window.bus_data_panel
|
||||
panel.node_edit.setText("2")
|
||||
panel.index_edit.setText("24608")
|
||||
panel.subindex_edit.setText("1")
|
||||
window._persist_prefs()
|
||||
assert window.prefs["sdo_last"]["node"] == 2
|
||||
assert window.prefs["sdo_last"]["index"] == 24608
|
||||
|
||||
|
||||
# --------------------------------------------------------------- demo session
|
||||
|
||||
|
||||
def test_a_demo_session_fills_the_table_and_gates_motion(make_window, qt_app):
|
||||
"""End to end through the real signal path: connect, initialise, jog."""
|
||||
from orisys_autocal.motion import ConnectionSpec
|
||||
|
||||
window = _show(make_window())
|
||||
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
|
||||
assert _wait_for(lambda: window._last_snapshot.connected, qt_app)
|
||||
|
||||
# Download the shipped ECAT.bas, then wait for the simulated scan.
|
||||
window.bus_panel.set_bas_path(str(_shipped_bas("ECAT.bas")))
|
||||
window._on_download_bas()
|
||||
assert _wait_for(lambda: window._last_snapshot.bus.init_status == 1, qt_app, timeout=8.0)
|
||||
|
||||
assert window._last_snapshot.bus.bus_type == "ecat"
|
||||
assert _enabled(window.bus_data_panel.ecat_box)
|
||||
assert _enabled(window.axis_panel._rows[0]["fwd_button"])
|
||||
# ATYPE is no longer a table column — the seven-column table shows position
|
||||
# and status, and the drive's full frame is on the selected-axis detail line.
|
||||
window.axis_panel.set_selected_axis(0)
|
||||
assert "65" in window.axis_panel._detail_label.text()
|
||||
pump(qt_app, 0.2)
|
||||
|
||||
|
||||
def test_closing_the_window_stops_the_poller(make_window, qt_app):
|
||||
window = _show(make_window())
|
||||
window.close()
|
||||
pump(qt_app, 0.2)
|
||||
assert not window.poller._thread.isRunning()
|
||||
|
||||
|
||||
def _shipped_bas(name: str):
|
||||
from orisys_autocal.ui.bus_panel import default_bas_dir
|
||||
|
||||
return default_bas_dir() / name
|
||||
|
||||
|
||||
def _wait_for(predicate, app, timeout: float = 5.0) -> bool:
|
||||
import time
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.02)
|
||||
return False
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Preferences must never be written outside the test scratch directory.
|
||||
|
||||
The installed app reads ``apps/autocal/preference.json`` at startup. A test that
|
||||
builds a window and closes it saves preferences through ``closeEvent``, so a test
|
||||
that forgets to redirect the path rewrites the operator's real settings — which is
|
||||
exactly what happened: the finger screen started up pointing at a scratch bundle
|
||||
from a previous run and reported it as missing.
|
||||
|
||||
``conftest._isolate_preference_file`` is the net. These tests pin it, because a
|
||||
fixture that silently stopped applying would look like nothing at all until the app
|
||||
was next launched by hand.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
INSTALLED = APP_ROOT / "preference.json"
|
||||
|
||||
|
||||
def test_the_preference_path_is_redirected_under_the_scratch_root():
|
||||
from orisys_autocal.preferences import path
|
||||
|
||||
resolved = Path(path()).resolve()
|
||||
scratch = (APP_ROOT / "outputs" / "_test_scratch").resolve()
|
||||
assert resolved != INSTALLED.resolve()
|
||||
assert scratch in resolved.parents, resolved
|
||||
|
||||
|
||||
def test_saving_preferences_leaves_the_installed_file_alone():
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
|
||||
before = INSTALLED.read_bytes() if INSTALLED.is_file() else None
|
||||
|
||||
prefs = prefs_mod.default_preferences()
|
||||
prefs["finger_bundle_path"] = str(APP_ROOT / "outputs" / "_test_scratch" / "gone")
|
||||
written = Path(prefs_mod.save_preferences(prefs)).resolve()
|
||||
|
||||
assert written != INSTALLED.resolve()
|
||||
assert written.is_file(), "the save did not reach the redirected file either"
|
||||
|
||||
after = INSTALLED.read_bytes() if INSTALLED.is_file() else None
|
||||
assert after == before, "a test rewrote the installed preference.json"
|
||||
|
||||
|
||||
def test_a_window_that_closes_still_lands_in_the_scratch_directory(qt_app):
|
||||
"""The failure mode itself: construct, close, check where it wrote."""
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
|
||||
before = INSTALLED.read_bytes() if INSTALLED.is_file() else None
|
||||
|
||||
prefs = prefs_mod.default_preferences()
|
||||
prefs["finger_bundle_path"] = str(APP_ROOT / "outputs" / "_test_scratch" / "gone")
|
||||
window = AutoCalMainWindow(prefs=prefs, auto_load_bundle=False)
|
||||
try:
|
||||
window.poller.shutdown()
|
||||
window.close()
|
||||
finally:
|
||||
window.poller.shutdown()
|
||||
|
||||
after = INSTALLED.read_bytes() if INSTALLED.is_file() else None
|
||||
assert after == before, "closing a test window rewrote the installed preferences"
|
||||
@@ -25,6 +25,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -41,8 +42,16 @@ if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
|
||||
if _fonts.is_dir():
|
||||
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
|
||||
|
||||
from PySide6.QtCore import QPoint, QRect, Qt # noqa: E402
|
||||
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPixmap, QPolygon # noqa: E402
|
||||
from PySide6.QtCore import QPoint, QPointF, QRect, Qt # noqa: E402
|
||||
from PySide6.QtGui import ( # noqa: E402
|
||||
QColor,
|
||||
QFont,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPen,
|
||||
QPixmap,
|
||||
QPolygon,
|
||||
)
|
||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------- geometry ---
|
||||
@@ -157,7 +166,6 @@ STRINGS = {
|
||||
"finger_initial_view": "初始视角",
|
||||
"finger_reset": "复位视角",
|
||||
"finger_textured_note": "UV 贴图:patch_texture_compute.png",
|
||||
"finger_axes_note": "黄色为工具轴 · X/Y 为工作台,Z 为工具行程 · 绿=对齐,红=不可对齐",
|
||||
"finger_bundle_summary": "1032 顶点 · 1974 三角面 · 15.00 × 7.50 × 15.81 mm · 0.018388 mm/单位",
|
||||
"finger_flat_title": "展平图像(点击取点)",
|
||||
"finger_pick_hint": "点击传感面上的像素即可取点;落在 UV 缝隙上的像素会被拒绝。",
|
||||
@@ -166,21 +174,32 @@ STRINGS = {
|
||||
["传感器 (mm)", "2.841, 3.512, 0.940", None],
|
||||
["法向", "-0.18, -0.88, 0.43", None],
|
||||
["平台 (mm)", "2.841, 0.940, -3.512", None],
|
||||
["X / Y (mm)", "2.841, 0.940", None],
|
||||
["yaw / pitch", "-78.42° / 23.10°", None],
|
||||
["位姿 X / Y / yaw / pitch", "-2.841 0.940 -78.42 23.10", None],
|
||||
["工作台移动 X / Y", "+2.841 -0.940 mm", None],
|
||||
["对轴残差", "0.03° 可对轴", "#12b76a"],
|
||||
["坐标系映射", "x→x, y→z, z→-y", None],
|
||||
],
|
||||
"finger_plate_label": "安装板厚 (mm)",
|
||||
"finger_plate_value": "0.000",
|
||||
"finger_tool_label": "刀具 X / Y",
|
||||
"finger_tool_values": ["0.000", "0.000"],
|
||||
"finger_sensor_xy_label": "传感器 X / Y",
|
||||
"finger_sensor_xy_values": ["0.000", "0.000"],
|
||||
"finger_sensor_zy_label": "传感器 Z / yaw",
|
||||
"finger_sensor_zy_values": ["0.000", "0.000"],
|
||||
"finger_preview_title": "三维预览",
|
||||
"finger_preview_legend": "曲面颜色 = 对轴残差(绿为对正,红为对不上) · 黄色为 Z 轴",
|
||||
"finger_point_cols": ["#", "像素 u, v", "sensor x", "sensor y", "sensor z", "nx", "ny", "nz", "X / Y", "Z", "yaw", "pitch"],
|
||||
"finger_point_rows": [
|
||||
["1", "0.35, 0.40", "2.841", "3.512", "0.940", "-0.18", "-0.88", "0.43", "2.841 / 0.940", "7.958", "-78.42", "23.10"],
|
||||
["2", "0.62, 0.55", "5.113", "2.204", "-1.870", "0.21", "0.55", "-0.81", "5.113 / -1.870", "7.958", "12.05", "8.44"],
|
||||
["3", "0.50, 0.72", "3.902", "6.118", "2.405", "0.55", "0.19", "0.81", "3.902 / 2.405", "7.958", "-90.00", "51.66"],
|
||||
"finger_preview_checks": ["显示运动轴", "显示转台原点平面", "显示刀具路径"],
|
||||
"finger_play": "播放动作",
|
||||
"finger_preview_notes": [
|
||||
"颜色含义:绿→红 = 表面对齐残差;红/绿/蓝三色轴 = 工作台 X/Y/Z(位于原点);橙点 = 工作台原点。",
|
||||
"黄色直线 = 刀具轴(+Z,位置即刀具 X/Y);黄色方框 = 传感器安装基面(高度 = 传感器 Z,含安装板)。",
|
||||
"青色线 = 规划路径;紫圈 = 转台当前姿态、浅紫圈 = 结束姿态,与固定方格网对比即看出整个变换;白点+蓝线 = 接触点及法向。",
|
||||
],
|
||||
"finger_point_cols": ["sensor x", "sensor y", "sensor z", "nx", "ny", "nz", "X", "Y", "Z", "yaw", "pitch", "残差"],
|
||||
"finger_point_rows": [
|
||||
["2.841", "3.512", "0.940", "-0.18", "-0.88", "0.43", "2.841", "0.940", "7.958", "-78.42", "23.10", "0.03"],
|
||||
["5.113", "2.204", "-1.870", "0.21", "0.55", "-0.81", "5.113", "-1.870", "7.958", "12.05", "8.44", "0.02"],
|
||||
["3.902", "6.118", "2.405", "0.55", "0.19", "0.81", "3.902", "2.405", "7.958", "-90.00", "51.66", "0.02"],
|
||||
],
|
||||
"finger_table_buttons": [("删除所选", False), ("上移", False), ("下移", False), ("清空标定点", False)],
|
||||
"finger_bad_word": "撞 yaw 行程限位",
|
||||
"finger_point_count": "3 个取点 · 展平 1024 × 1024 · 有效像素 72.9%",
|
||||
"sensor_note": "设备与信号源、实时图像、力曲线",
|
||||
@@ -284,7 +303,6 @@ STRINGS = {
|
||||
"finger_initial_view": "Initial view",
|
||||
"finger_reset": "Reset view",
|
||||
"finger_textured_note": "UV texture: patch_texture_compute.png",
|
||||
"finger_axes_note": "Yellow is the tool axis · X/Y are the table, Z the tool travel · green aligned, red not",
|
||||
"finger_bundle_summary": "1032 vertices · 1974 triangles · 15.00 × 7.50 × 15.81 mm · 0.018388 mm per unit",
|
||||
"finger_flat_title": "Flat image (click to pick)",
|
||||
"finger_pick_hint": "Click a pixel on the sensing surface to pick a point; pixels in a UV gap are refused.",
|
||||
@@ -293,21 +311,33 @@ STRINGS = {
|
||||
["Sensor (mm)", "2.841, 3.512, 0.940", None],
|
||||
["Normal", "-0.18, -0.88, 0.43", None],
|
||||
["Platform (mm)", "2.841, 0.940, -3.512", None],
|
||||
["X / Y (mm)", "2.841, 0.940", None],
|
||||
["yaw / pitch", "-78.42° / 23.10°", None],
|
||||
["Pose X / Y / yaw / pitch", "-2.841 0.940 -78.42 23.10", None],
|
||||
["Table move X / Y", "+2.841 -0.940 mm", None],
|
||||
["Residual", "0.03° can align", "#12b76a"],
|
||||
["Frame map", "x→x, y→z, z→-y", None],
|
||||
],
|
||||
"finger_plate_label": "Mounting plate (mm)",
|
||||
"finger_plate_value": "0.000",
|
||||
"finger_tool_label": "Tool X / Y",
|
||||
"finger_tool_values": ["0.000", "0.000"],
|
||||
"finger_sensor_xy_label": "Sensor X / Y",
|
||||
"finger_sensor_xy_values": ["0.000", "0.000"],
|
||||
"finger_sensor_zy_label": "Sensor Z / yaw",
|
||||
"finger_sensor_zy_values": ["0.000", "0.000"],
|
||||
"finger_preview_title": "3D preview",
|
||||
"finger_preview_legend": "Surface colour = alignment residual (green aligned, red not) · yellow is the Z axis",
|
||||
"finger_point_cols": ["#", "pixel u, v", "sensor x", "sensor y", "sensor z", "nx", "ny", "nz", "X / Y", "Z", "yaw", "pitch"],
|
||||
"finger_point_rows": [
|
||||
["1", "0.35, 0.40", "2.841", "3.512", "0.940", "-0.18", "-0.88", "0.43", "2.841 / 0.940", "7.958", "-78.42", "23.10"],
|
||||
["2", "0.62, 0.55", "5.113", "2.204", "-1.870", "0.21", "0.55", "-0.81", "5.113 / -1.870", "7.958", "12.05", "8.44"],
|
||||
["3", "0.50, 0.72", "3.902", "6.118", "2.405", "0.55", "0.19", "0.81", "3.902 / 2.405", "7.958", "-90.00", "51.66"],
|
||||
"finger_preview_checks": ["Show motion axes", "Show table origin plane", "Show tool path"],
|
||||
"finger_play": "Play motion",
|
||||
"finger_preview_notes": [
|
||||
"What the colours mean: green→red = the surface's alignment residual; the red/green/blue triad is the table's X/Y/Z at the origin; the orange dot is the origin.",
|
||||
"Yellow line = the tool axis (+Z, at the tool's own X/Y); the amber outline = the sensor base plane, at the sensor's Z offset (the plate).",
|
||||
"Cyan line = the planned path; the violet ring is the table's pose now and the faint one where it finishes, against the fixed grid that is where it started; white dot with blue line = the contact and its normal.",
|
||||
],
|
||||
"finger_preview_legend": "Surface colour = alignment residual (green aligned, red not) · yellow is the Z axis",
|
||||
"finger_point_cols": ["sensor x", "sensor y", "sensor z", "nx", "ny", "nz", "X", "Y", "Z", "yaw", "pitch", "residual"],
|
||||
"finger_point_rows": [
|
||||
["2.841", "3.512", "0.940", "-0.18", "-0.88", "0.43", "2.841", "0.940", "7.958", "-78.42", "23.10", "0.03"],
|
||||
["5.113", "2.204", "-1.870", "0.21", "0.55", "-0.81", "5.113", "-1.870", "7.958", "12.05", "8.44", "0.02"],
|
||||
["3.902", "6.118", "2.405", "0.55", "0.19", "0.81", "3.902", "2.405", "7.958", "-90.00", "51.66", "0.02"],
|
||||
],
|
||||
"finger_table_buttons": [("Remove selected", False), ("Move up", False), ("Move down", False), ("Clear points", False)],
|
||||
"finger_bad_word": "hits yaw travel limit",
|
||||
"finger_point_count": "3 picked points · flat 1024 × 1024 · 72.9% valid pixels",
|
||||
"sensor_note": "Device and source, live image, force curves",
|
||||
@@ -352,6 +382,28 @@ def _font(size: int = 11, *, bold: bool = False) -> QFont:
|
||||
return font
|
||||
|
||||
|
||||
def _arrowhead(painter: QPainter, path: QPainterPath, share: float, colour: QColor, size: int) -> None:
|
||||
"""An arrowhead on ``path`` at ``share``, pointing the way the path runs.
|
||||
|
||||
The direction comes from two nearby points on the path rather than from
|
||||
``angleAtPercent``: that angle is measured counter-clockwise from +x while Qt's
|
||||
y axis grows downward, and every attempt to reconcile the two by hand has
|
||||
produced an arrow pointing backwards.
|
||||
"""
|
||||
tip = path.pointAtPercent(share)
|
||||
ahead = path.pointAtPercent(min(1.0, share + 0.01))
|
||||
dx, dy = ahead.x() - tip.x(), ahead.y() - tip.y()
|
||||
length = math.hypot(dx, dy) or 1.0
|
||||
ux, uy = dx / length, dy / length
|
||||
painter.setPen(QPen(colour, 2))
|
||||
for sign in (1.0, -1.0):
|
||||
barb = QPointF(
|
||||
tip.x() - ux * size + sign * -uy * size * 0.6,
|
||||
tip.y() - uy * size + sign * ux * size * 0.6,
|
||||
)
|
||||
painter.drawLine(tip, barb)
|
||||
|
||||
|
||||
def _panel(painter: QPainter, rect: QRect, palette: Palette) -> QRect:
|
||||
painter.fillRect(rect, palette.panel)
|
||||
painter.setPen(QPen(palette.border, 1))
|
||||
@@ -1008,45 +1060,141 @@ def draw_finger_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -
|
||||
_text(painter, QRect(inner_read.x(), row_y, label_w, 20), label, palette, size=9, muted=True)
|
||||
_text(painter, QRect(inner_read.x() + label_w, row_y, inner_read.width() - label_w, 20), value, palette, size=9, colour=QColor(colour) if colour else None)
|
||||
row_y += 20
|
||||
_text(painter, QRect(inner_read.x(), inner_read.bottom() - 36, inner_read.width(), 18), s["finger_plate_label"], palette, size=9, muted=True)
|
||||
_field(painter, QRect(inner_read.x(), inner_read.bottom() - 20, inner_read.width(), 20), s["finger_plate_value"], palette)
|
||||
# The setup and tool fields: pairs of numbers, as in the app, where they are
|
||||
# spin boxes two to a row because they get dialled in against the real machine.
|
||||
pairs = (
|
||||
("finger_tool_label", "finger_tool_values"),
|
||||
("finger_sensor_xy_label", "finger_sensor_xy_values"),
|
||||
("finger_sensor_zy_label", "finger_sensor_zy_values"),
|
||||
)
|
||||
pair_y = row_y + 4
|
||||
for label_key, values_key in pairs:
|
||||
_text(painter, QRect(inner_read.x(), pair_y, label_w, 20), s[label_key], palette, size=9, muted=True)
|
||||
field_w = int((inner_read.width() - label_w - 6) / 2)
|
||||
for index, value in enumerate(s[values_key][:2]):
|
||||
_field(
|
||||
painter,
|
||||
QRect(inner_read.x() + label_w + index * (field_w + 6), pair_y, field_w, 20),
|
||||
value,
|
||||
palette,
|
||||
)
|
||||
pair_y += 22
|
||||
|
||||
# 3D preview: the tool axis and a normal with its residual.
|
||||
# 3D preview: the machine's frame at the table origin, the sensor on it, and the
|
||||
# planned path. Schematic, but the parts and their colours are the ones the real
|
||||
# view draws — a proposal that disagrees with the app is worse than no proposal.
|
||||
inner_preview = _panel(painter, preview, palette)
|
||||
_text(painter, QRect(inner_preview.x(), inner_preview.y(), inner_preview.width(), 18), s["finger_preview_title"], palette, size=10, bold=True)
|
||||
view = QRect(inner_preview.x(), inner_preview.y() + 22, inner_preview.width(), inner_preview.height() - 22 - 34)
|
||||
checks_h = 20
|
||||
play_h = 28
|
||||
notes_h = 3 * 14 + 4
|
||||
view = QRect(
|
||||
inner_preview.x(),
|
||||
inner_preview.y() + 22,
|
||||
inner_preview.width(),
|
||||
inner_preview.height() - 22 - checks_h - play_h - notes_h,
|
||||
)
|
||||
painter.fillRect(view, QColor("#12161c"))
|
||||
painter.setPen(QPen(QColor("#2a313c"), 1))
|
||||
for i in range(1, 4):
|
||||
painter.drawLine(view.x() + i * view.width() // 4, view.y(), view.x() + i * view.width() // 4, view.bottom())
|
||||
cx, cy = view.center().x(), view.center().y() + 40
|
||||
axis = QPoint(cx, view.y() + 24)
|
||||
painter.setPen(QPen(QColor("#f2c94c"), 3))
|
||||
painter.drawLine(axis, QPoint(cx, cy))
|
||||
painter.setBrush(QColor("#f2c94c"))
|
||||
painter.drawEllipse(QPoint(cx, cy), 3, 3)
|
||||
# The motion axes: the platform frame, drawn in the sensor frame.
|
||||
|
||||
cx, cy = view.center().x(), view.center().y() + int(view.height() * 0.12)
|
||||
# The table origin's plane: a grid, not a filled surface.
|
||||
half = int(view.width() * 0.30)
|
||||
floor = QRect(cx - half, cy - int(half * 0.26), 2 * half, int(half * 0.52))
|
||||
painter.setPen(QPen(QColor("#8c95ab"), 1))
|
||||
for index in range(5):
|
||||
fx = floor.x() + index * floor.width() // 4
|
||||
fy = floor.y() + index * floor.height() // 4
|
||||
painter.drawLine(fx, floor.y(), fx, floor.bottom())
|
||||
painter.drawLine(floor.x(), fy, floor.right(), fy)
|
||||
|
||||
# The sensor: the sensing face as an arc, the shell below it as a wireframe-ish
|
||||
# outline. Grey, because the surface carries the residual colour instead.
|
||||
dome_w = int(view.width() * 0.34)
|
||||
dome_h = int(view.height() * 0.34)
|
||||
body = QRect(cx - dome_w // 2, cy - dome_h, dome_w, dome_h + int(dome_h * 0.45))
|
||||
painter.setPen(QPen(QColor("#5b6273"), 1))
|
||||
painter.setBrush(QColor("#26b847"))
|
||||
painter.drawChord(QRect(body.x(), body.y(), body.width(), body.height() * 2 // 3), 0, 180 * 16)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRect(QRect(body.x(), body.y() + body.height() // 4, body.width(), body.height() // 2))
|
||||
|
||||
# The base plane, lifted off the origin by the sensor's z setup offset (the
|
||||
# mounting plate), and the table's own frame: a circle at the pose it ends in,
|
||||
# against the square grid that is where it started.
|
||||
base = QRect(body.x() - 8, body.bottom() - 6, body.width() + 16, int(half * 0.30))
|
||||
painter.setPen(QPen(QColor("#fab829"), 2))
|
||||
painter.drawRect(base)
|
||||
|
||||
ring = QRect(0, 0, int(half * 1.5), int(half * 0.78))
|
||||
ring.moveCenter(QPoint(cx + int(half * 0.30), cy + int(half * 0.05)))
|
||||
painter.setPen(QPen(QColor("#8c6ff7"), 2))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawEllipse(ring)
|
||||
painter.drawLine(ring.center().x() - ring.width() // 2, ring.center().y(),
|
||||
ring.center().x() + ring.width() // 2, ring.center().y())
|
||||
painter.drawLine(ring.center().x(), ring.center().y() - ring.height() // 2,
|
||||
ring.center().x(), ring.center().y() + ring.height() // 2)
|
||||
painter.setPen(QPen(QColor(140, 111, 247, 130), 2))
|
||||
painter.drawEllipse(ring.adjusted(-14, -7, -14, -7))
|
||||
|
||||
# The planned path: the stages swing the contact, then the table slides it in.
|
||||
contact = QPoint(body.x() + 10, body.y() + int(body.height() * 0.28))
|
||||
landed = QPoint(cx + 6, cy - int(half * 0.30))
|
||||
path = QPainterPath(contact)
|
||||
path.quadTo(QPoint(contact.x() + int(half * 0.75), contact.y() - int(half * 0.35)), landed)
|
||||
painter.setPen(QPen(QColor("#40d1f2"), 2))
|
||||
painter.drawPath(path)
|
||||
for share in (0.35, 0.7):
|
||||
_arrowhead(painter, path, share, QColor("#ccf4ff"), 7)
|
||||
painter.setBrush(QColor("#40d1f2"))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawEllipse(landed, 5, 5)
|
||||
|
||||
# The table triad at the origin: the only full-strength one in the picture.
|
||||
origin = QPoint(floor.center().x(), floor.center().y() + int(half * 0.12))
|
||||
for colour, end in (
|
||||
("#e65454", QPoint(cx - 96, cy + 18)),
|
||||
("#61cc61", QPoint(cx + 30, cy + 92)),
|
||||
("#5c8cf2", QPoint(cx + 88, cy - 26)),
|
||||
("#e64c4c", QPoint(origin.x(), origin.y() + int(half * 0.62))),
|
||||
("#59c761", QPoint(origin.x() + int(half * 0.85), origin.y())),
|
||||
("#5787f2", QPoint(origin.x(), origin.y() - int(half * 0.42))),
|
||||
):
|
||||
painter.setPen(QPen(QColor(colour), 2))
|
||||
painter.drawLine(QPoint(cx, cy), end)
|
||||
painter.setPen(QPen(QColor("#12b76a"), 3))
|
||||
painter.drawLine(QPoint(cx, cy), QPoint(cx + 26, cy - 96))
|
||||
painter.setPen(QPen(QColor("#d92d20"), 3))
|
||||
painter.drawLine(QPoint(cx, cy), QPoint(cx - 74, cy - 78))
|
||||
painter.drawLine(origin, end)
|
||||
painter.setBrush(QColor("#fa9933"))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawEllipse(origin, 5, 5)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.setPen(QPen(QColor("#4a5568"), 1))
|
||||
painter.drawLine(QPoint(cx - 90, cy), QPoint(cx + 90, cy))
|
||||
painter.setPen(QPen(QColor("#e65454"), 1))
|
||||
painter.drawText(QPoint(cx - 108, cy + 24), "X")
|
||||
painter.setPen(QPen(QColor("#61cc61"), 1))
|
||||
painter.drawText(QPoint(cx + 34, cy + 100), "Y")
|
||||
painter.setPen(QPen(QColor("#5c8cf2"), 1))
|
||||
painter.drawText(QPoint(cx + 92, cy - 28), "Z")
|
||||
_text(painter, QRect(inner_preview.x(), view.bottom() + 2, inner_preview.width(), 30), s["finger_axes_note"], palette, size=8, muted=True)
|
||||
|
||||
# The tool axis, through the point the tool touches.
|
||||
painter.setPen(QPen(QColor("#f2e04c"), 2))
|
||||
painter.drawLine(QPoint(landed.x(), view.y() + 16), QPoint(landed.x(), landed.y()))
|
||||
_text(painter, QRect(landed.x() - 8, view.y() + 2, 90, 16), "+Z", palette, size=8, colour=QColor("#f2e04c"))
|
||||
|
||||
# The picked contact and its normal, and the point selected in the table.
|
||||
painter.setPen(QPen(QColor("#59a6ff"), 2))
|
||||
painter.drawLine(contact, QPoint(contact.x() - int(half * 0.30), contact.y() - int(half * 0.34)))
|
||||
painter.setPen(QPen(QColor("#ffffff"), 2))
|
||||
painter.setBrush(QColor("#ffffff"))
|
||||
painter.drawEllipse(contact, 4, 4)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#eb2133"))
|
||||
painter.drawEllipse(QPoint(contact.x() + 14, contact.y() + 4), 5, 5)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
|
||||
checks = s["finger_preview_checks"]
|
||||
check_w = inner_preview.width() // max(1, len(checks))
|
||||
for index, label in enumerate(checks):
|
||||
box = QRect(inner_preview.x() + index * check_w, view.bottom() + 2, check_w, checks_h)
|
||||
painter.setPen(QPen(palette.accent, 2))
|
||||
painter.drawLine(box.x() + 2, box.y() + 9, box.x() + 5, box.y() + 13)
|
||||
painter.drawLine(box.x() + 5, box.y() + 13, box.x() + 10, box.y() + 5)
|
||||
_text(painter, QRect(box.x() + 15, box.y(), check_w - 15, checks_h), label, palette, size=9, muted=True)
|
||||
|
||||
play = QRect(inner_preview.x(), view.bottom() + checks_h + 2, 110, play_h - 4)
|
||||
_button(painter, play, s["finger_play"], palette, primary=True)
|
||||
notes_y = play.bottom() + 4
|
||||
for text in s["finger_preview_notes"]:
|
||||
_text(painter, QRect(inner_preview.x(), notes_y, inner_preview.width(), 14), text, palette, size=8, muted=True)
|
||||
notes_y += 14
|
||||
|
||||
# Picked points.
|
||||
table = QRect(inner.x(), y + body_h + 8, inner.width(), max(60, table_h))
|
||||
@@ -1055,9 +1203,16 @@ def draw_finger_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -
|
||||
painter.drawRect(table.adjusted(0, 0, -1, -1))
|
||||
header_h = 24
|
||||
painter.fillRect(QRect(table.x(), table.y(), table.width(), header_h), palette.panel)
|
||||
# Leading select column, then the data columns: the real table has a checkbox
|
||||
# per row so points can be picked out, edited, reordered or removed.
|
||||
select_w = 34
|
||||
painter.setPen(QPen(palette.border, 1))
|
||||
painter.drawLine(table.x() + select_w, table.y(), table.x() + select_w, table.bottom())
|
||||
columns = s["finger_point_cols"]
|
||||
widths = [int(table.width() * share) for share in (0.06, 0.09, 0.1, 0.09, 0.09, 0.07, 0.07, 0.07, 0.09, 0.09, 0.09, 0.09)]
|
||||
x = table.x()
|
||||
widths = [int((table.width() - select_w) * share)
|
||||
for share in (0.09, 0.1, 0.09, 0.09, 0.08, 0.08, 0.08, 0.11,
|
||||
0.07, 0.07, 0.07, 0.07)]
|
||||
x = table.x() + select_w
|
||||
for name, width in zip(columns, widths):
|
||||
_text(painter, QRect(x, table.y(), width, header_h), name, palette, size=9, muted=True, align=Qt.AlignmentFlag.AlignCenter)
|
||||
x += width
|
||||
@@ -1065,12 +1220,28 @@ def draw_finger_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -
|
||||
row_y = table.y() + header_h + index * 26
|
||||
if index % 2 == 0:
|
||||
painter.fillRect(QRect(table.x() + 1, row_y, table.width() - 2, 25), palette.panel)
|
||||
x = table.x()
|
||||
# Tick the first row, as the selected point being tested.
|
||||
box = QRect(table.x() + select_w // 2 - 6, row_y + 8, 12, 12)
|
||||
painter.setPen(QPen(palette.muted, 1))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRect(box)
|
||||
if index == 0:
|
||||
painter.setPen(QPen(palette.accent, 2))
|
||||
painter.drawLine(box.x() + 2, box.y() + 6, box.x() + 5, box.y() + 10)
|
||||
painter.drawLine(box.x() + 5, box.y() + 10, box.x() + 10, box.y() + 3)
|
||||
x = table.x() + select_w
|
||||
for value, width in zip(row, widths):
|
||||
colour = palette.danger if value == s["finger_bad_word"] else None
|
||||
_text(painter, QRect(x, row_y, width, 26), value, palette, size=10, align=Qt.AlignmentFlag.AlignCenter, colour=colour)
|
||||
x += width
|
||||
_text(painter, QRect(table.x() + 8, table.bottom() + 2, table.width(), 18), s["finger_point_count"], palette, size=9, muted=True)
|
||||
buttons = s["finger_table_buttons"]
|
||||
bx = table.x()
|
||||
by = table.bottom() + 26
|
||||
for label, primary in buttons:
|
||||
width = 30 + 8 * len(label)
|
||||
_button(painter, QRect(bx, by, width, 26), label, palette, primary=primary)
|
||||
bx += width + 8
|
||||
|
||||
|
||||
TAB_PAINTERS = {
|
||||
|
||||
@@ -30,6 +30,17 @@ if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
|
||||
if _fonts.is_dir():
|
||||
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
|
||||
|
||||
# The report quotes labels, and half of them are Chinese. A Windows console
|
||||
# defaults to a legacy code page that cannot encode them, so the run ended in a
|
||||
# UnicodeEncodeError *after* every check had passed — the worst kind of noise.
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
try:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
except (ValueError, OSError): # pragma: no cover - stream already detached
|
||||
pass
|
||||
|
||||
RESOLUTIONS = ((1440, 900), (1920, 1080))
|
||||
|
||||
|
||||
@@ -394,6 +405,10 @@ def main() -> int:
|
||||
)
|
||||
window.workspace.set_current_key("axes")
|
||||
|
||||
try:
|
||||
window.poller.shutdown()
|
||||
except Exception: # noqa: BLE001 - a shutdown must not mask the result
|
||||
pass
|
||||
window.close()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the finger page's 3D preview to a PNG, using a real GL context.
|
||||
|
||||
python apps/autocal/tools/preview_snapshot.py --out shot.png
|
||||
|
||||
``tools/panel_snapshot.py`` renders the whole window with the *offscreen* Qt
|
||||
platform, which cannot create an OpenGL context at all (``QOpenGLWidget is not
|
||||
supported on this platform``), so the preview pane in those shots is empty. This
|
||||
tool is the one that can photograph the 3D view, and it needs the real platform
|
||||
plugin and a visible window: Qt only builds a QOpenGLWidget's framebuffer once the
|
||||
widget has been shown and painted.
|
||||
|
||||
It therefore opens the window briefly, picks a point on the sensing surface, and
|
||||
reads the framebuffer back. Options:
|
||||
|
||||
--points 3 pick several points, so the table and the red dot show
|
||||
--play 0.5 run the motion simulation and capture it at 50%
|
||||
--view initial reset the camera before the shot
|
||||
--whole save the whole window instead of just the GL pane
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
for path in (APP_ROOT / "src", APP_ROOT.parents[1] / "shared" / "src"):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
os.environ.setdefault("ORISYS_APP_ID", "autocal")
|
||||
# The offscreen plugin cannot make a GL context, so drop it if the caller's
|
||||
# environment set it (the test suite does).
|
||||
if os.environ.get("QT_QPA_PLATFORM") == "offscreen":
|
||||
del os.environ["QT_QPA_PLATFORM"]
|
||||
# Force the native desktop GL backend. Qt's default choice on Windows has been seen
|
||||
# to hand out a context that validates but rejects every command — a bare
|
||||
# QOpenGLWidget with no application code fails the same way, with
|
||||
# GL_INVALID_OPERATION on the first call after ``makeCurrent`` — and every render
|
||||
# then comes out blank with no clue why. ``setdefault``, so an explicit choice in
|
||||
# the environment still wins.
|
||||
os.environ.setdefault("QT_OPENGL", "desktop")
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--theme", default="dark", choices=("dark", "light"))
|
||||
parser.add_argument("--language", default="zh_CN", choices=("zh_CN", "en_US"))
|
||||
parser.add_argument("--size", default="1180x760", help="WIDTHxHEIGHT of the window")
|
||||
parser.add_argument(
|
||||
"--pane",
|
||||
default="",
|
||||
metavar="WIDTHxHEIGHT",
|
||||
help="force the 3D pane to this size before the shot, for a readable view",
|
||||
)
|
||||
parser.add_argument("--out", default="preview.png")
|
||||
parser.add_argument(
|
||||
"--bundle",
|
||||
default="",
|
||||
help="bundle directory to load; defaults to the remembered preference",
|
||||
)
|
||||
parser.add_argument("--points", type=int, default=1, help="how many points to pick")
|
||||
parser.add_argument(
|
||||
"--setup",
|
||||
default="",
|
||||
metavar="X,Y,Z,YAW",
|
||||
help="sensor mounting pose: mm in the table frame plus the yaw in degrees",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tool",
|
||||
default="",
|
||||
metavar="X,Y",
|
||||
help="tool position in the table frame, mm",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--play",
|
||||
type=float,
|
||||
default=None,
|
||||
metavar="SHARE",
|
||||
help="start the motion simulation and capture it after this share, 0..1",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--view",
|
||||
default="",
|
||||
choices=("", "initial"),
|
||||
help="initial resets the camera before the shot",
|
||||
)
|
||||
parser.add_argument("--tab", default="finger", help="workspace tab to open first")
|
||||
parser.add_argument(
|
||||
"--whole", action="store_true", help="save the whole window, not just the pane"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug", action="store_true", help="report the GL state of the pane"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.theme import build_qss
|
||||
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
from orisys_autocal.ui.pose_preview import configure_compatible_gl
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
|
||||
width, height = (int(part) for part in args.size.lower().split("x"))
|
||||
|
||||
set_locale(args.language)
|
||||
app = QApplication.instance() or QApplication([])
|
||||
app.setApplicationName("Orisys AutoCal")
|
||||
# Must follow QApplication and precede the first GL surface.
|
||||
configure_compatible_gl()
|
||||
app.setStyleSheet(build_qss(args.theme))
|
||||
|
||||
prefs = load_preferences()
|
||||
prefs["theme"] = args.theme
|
||||
prefs["language"] = args.language
|
||||
prefs["show_diagnostics"] = False
|
||||
|
||||
window = AutoCalMainWindow(prefs=prefs)
|
||||
window.workspace.set_current_key(args.tab)
|
||||
window.show()
|
||||
# Resized *after* showing: the window restores its remembered geometry on
|
||||
# construction, so a resize before ``show`` is silently overwritten and every
|
||||
# shot comes out at whatever size the operator last used.
|
||||
window.resize(width, height)
|
||||
_settle(app, 0.5)
|
||||
|
||||
if args.bundle:
|
||||
window.load_bundle(args.bundle)
|
||||
_settle(app, 0.6)
|
||||
|
||||
panel = window.finger_panel
|
||||
if panel.view.flat is None:
|
||||
print("no bundle loaded; set finger_bundle_path in preferences", file=sys.stderr)
|
||||
_finish(app, window)
|
||||
return 1
|
||||
if args.setup:
|
||||
numbers = [float(part) for part in args.setup.split(",")]
|
||||
while len(numbers) < 4:
|
||||
numbers.append(0.0)
|
||||
panel.set_sensor_setup(tuple(numbers[:3]), numbers[3])
|
||||
_settle(app, 0.2)
|
||||
if args.tool:
|
||||
numbers = [float(part) for part in args.tool.split(",")]
|
||||
while len(numbers) < 2:
|
||||
numbers.append(0.0)
|
||||
panel.set_tool_xy(numbers[0], numbers[1])
|
||||
_settle(app, 0.2)
|
||||
if args.pane:
|
||||
pane_w, pane_h = (int(part) for part in args.pane.lower().split("x"))
|
||||
panel.preview.setFixedSize(pane_w, pane_h)
|
||||
_settle(app, 0.3)
|
||||
if args.view == "initial":
|
||||
panel.preview.reset_view()
|
||||
|
||||
for x, y in _pick_pixels(panel, max(1, args.points)):
|
||||
panel.handle_pick(x, y)
|
||||
_settle(app, 0.25)
|
||||
|
||||
if args.play is not None:
|
||||
panel.preview.start_simulation()
|
||||
_run_simulation_to(app, panel.preview, args.play)
|
||||
_settle(app, 0.35)
|
||||
|
||||
# The pane is hidden for the read-back, and only 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; hidden, Qt
|
||||
# renders on demand for grabFramebuffer and the picture is real. Hiding it
|
||||
# afterwards does not disturb the layout, so the corner is taken first.
|
||||
from PySide6.QtCore import QPoint
|
||||
|
||||
corner = panel.preview.mapTo(window, QPoint(0, 0))
|
||||
pane_size = panel.preview.size()
|
||||
panel.preview.hide()
|
||||
_settle(app, 0.25)
|
||||
try:
|
||||
gl_image = _grab_gl(app, panel.preview)
|
||||
finally:
|
||||
panel.preview.show()
|
||||
panel.preview.resize(pane_size)
|
||||
_settle(app, 0.2)
|
||||
if args.debug:
|
||||
context = panel.preview.context()
|
||||
summary = panel.preview.trajectory_summary()
|
||||
print(
|
||||
f"debug: platform={app.platformName()} valid={panel.preview.isValid()} "
|
||||
f"size={panel.preview.width()}x{panel.preview.height()} "
|
||||
f"tab={window.workspace.current_key()} "
|
||||
f"context={'yes' if context is not None else 'none'} "
|
||||
f"problem={panel.preview.problem!r} "
|
||||
f"image={'none' if gl_image is None else f'{gl_image.width()}x{gl_image.height()}'}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
f"debug: trajectory={summary or 'empty'} "
|
||||
f"show={panel.preview.show_trajectory} table={panel.preview.show_table} "
|
||||
f"axes={panel.preview.show_axes} "
|
||||
f"scene_radius={panel.preview.scene_radius():.2f}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
f"debug: heights panel={panel.height()} preview={panel.preview.height()} "
|
||||
f"point_table={panel.table.height()} window={window.height()}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# Where the claims land on screen: the path's end must project onto the tool,
|
||||
# and the contact onto both. Printed because "they look close in the picture"
|
||||
# is exactly the judgement this tool exists to replace.
|
||||
path = panel.preview.trajectory()
|
||||
if path.size:
|
||||
tool = panel.preview.tool_point()
|
||||
gap = float(sum((a - b) ** 2 for a, b in zip(path[-1], tool)) ** 0.5)
|
||||
screen = panel.preview.project(tool)
|
||||
print(
|
||||
f"debug: path end at {panel.preview.project(path[-1])} "
|
||||
f"tool at {screen} offscreen {gap:.6f} mm",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# The projection says where the tool line *is* on screen; check the
|
||||
# pixels, because a projection that disagrees with the render looks
|
||||
# perfectly healthy on its own — that is how the labels drifted off
|
||||
# their arrows and the scene sat off to one side without a word.
|
||||
from orisys_autocal.ui.pose_preview import COLOUR
|
||||
|
||||
for label, point, colour in _landmarks(panel):
|
||||
screen = panel.preview.project(point)
|
||||
print(
|
||||
f"debug: {label} at {screen} -> "
|
||||
f"{_find_colour(gl_image, screen, colour)} matching pixels",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# The two markers must be the same point: the contact is what is solved for,
|
||||
# the red dot is the row selected in the table, and they are the same pick.
|
||||
contact = panel.preview._point_mm # noqa: SLF001
|
||||
highlight = panel.preview._highlight_mm # noqa: SLF001
|
||||
pose = panel.preview._pose # noqa: SLF001
|
||||
stack = panel.preview.stack
|
||||
print(
|
||||
f"debug: share={panel.preview.motion_share()} "
|
||||
f"simulating={panel.preview.simulating} "
|
||||
f"elapsed={panel.preview._sim_elapsed:.0f} " # noqa: SLF001
|
||||
f"pose={'none' if pose is None else pose.describe()}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if contact is not None:
|
||||
drawn = panel.preview._placed(contact) # noqa: SLF001
|
||||
tool = panel.preview.tool_point()
|
||||
print(
|
||||
f"debug: drawn contact {[round(value, 4) for value in stack.mount @ drawn]} "
|
||||
f"tool {[round(value, 4) for value in stack.mount @ tool]} "
|
||||
f"gap {float(sum((a - b) ** 2 for a, b in zip(drawn, tool)) ** 0.5):.6f} mm",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if gl_image is None:
|
||||
print(
|
||||
f"the GL pane produced no framebuffer (valid={panel.preview.isValid()}, "
|
||||
f"size={panel.preview.width()}x{panel.preview.height()}, "
|
||||
f"backend={os.environ.get('QT_OPENGL', 'default')}, "
|
||||
f"problem={panel.preview.problem!r})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
_finish(app, window)
|
||||
return 1
|
||||
|
||||
if args.whole:
|
||||
image = window.grab()
|
||||
_paint_pane(image, corner, gl_image)
|
||||
else:
|
||||
image = gl_image
|
||||
|
||||
output = Path(args.out).resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not image.save(str(output)):
|
||||
print(f"could not write {output}", file=sys.stderr)
|
||||
_finish(app, window)
|
||||
return 1
|
||||
print(
|
||||
f"wrote {output} ({image.width()}x{image.height()}, pane={gl_image.width()}"
|
||||
f"x{gl_image.height()}, theme={args.theme}, points={panel.table.rowCount()}, "
|
||||
f"play={args.play})"
|
||||
)
|
||||
_finish(app, window)
|
||||
return 0
|
||||
|
||||
|
||||
def preview_place(panel, point):
|
||||
"""A sensor point as the preview draws it right now."""
|
||||
return panel.preview._placed(point) # noqa: SLF001
|
||||
|
||||
|
||||
def _landmarks(panel):
|
||||
"""``(name, mesh point, palette colour)`` for pixels that identify themselves.
|
||||
|
||||
Each pair is a point whose position is known from the model and an element drawn
|
||||
there in a colour nothing else uses, so "is the projection telling the truth"
|
||||
becomes a pixel count instead of an opinion.
|
||||
"""
|
||||
from orisys_autocal.sensor.frames import to_sensor
|
||||
from orisys_autocal.ui.pose_preview import COLOUR
|
||||
|
||||
preview = panel.preview
|
||||
frame = preview.table_frame()
|
||||
tool = preview.tool_point()
|
||||
direction = to_sensor(preview.stack.tool_axis_direction, preview.stack.axis_map)
|
||||
length = float((direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2) ** 0.5) or 1.0
|
||||
direction = direction / length
|
||||
found = [
|
||||
# Unoccluded by construction: the origin dot is drawn last with depth off.
|
||||
("origin", frame["origin"], COLOUR["origin"]),
|
||||
]
|
||||
# The tool line is depth-tested, so parts of it are behind the body: try several
|
||||
# heights along it and take the best, rather than insisting on one that happens
|
||||
# to be visible in one particular pose.
|
||||
for offset_mm in (12.0, 9.0, 6.0, 3.0):
|
||||
found.append(
|
||||
(
|
||||
f"tool line +{offset_mm:.0f}mm",
|
||||
tool + direction * offset_mm,
|
||||
COLOUR["tool_axis"],
|
||||
)
|
||||
)
|
||||
ring = preview.table_marker_points()
|
||||
if ring.size:
|
||||
found.append(("table marker", ring[0], COLOUR["table_marker"]))
|
||||
return found
|
||||
|
||||
|
||||
def _find_colour(image, screen, colour, radius: int = 6) -> int:
|
||||
"""How many pixels near ``screen`` match ``colour``, within a small tolerance.
|
||||
|
||||
The check that ties the projection to the picture. Deliberately tolerant about
|
||||
position (a few pixels) and strict about colour, because the colour is what says
|
||||
*which* element is there: finding the tool-axis yellow near where the tool is
|
||||
claimed to be is the difference between a working projection and a plausible one.
|
||||
"""
|
||||
if screen is None or image is None:
|
||||
return -1
|
||||
from PySide6.QtGui import QColor
|
||||
|
||||
wanted = QColor(
|
||||
int(colour[0] * 255), int(colour[1] * 255), int(colour[2] * 255)
|
||||
)
|
||||
found = 0
|
||||
for dy in range(-radius, radius + 1):
|
||||
for dx in range(-radius, radius + 1):
|
||||
x, y = int(round(screen[0])) + dx, int(round(screen[1])) + dy
|
||||
if not (0 <= x < image.width() and 0 <= y < image.height()):
|
||||
continue
|
||||
pixel = image.pixelColor(x, y)
|
||||
if (
|
||||
abs(pixel.red() - wanted.red()) <= 24
|
||||
and abs(pixel.green() - wanted.green()) <= 24
|
||||
and abs(pixel.blue() - wanted.blue()) <= 24
|
||||
):
|
||||
found += 1
|
||||
return found
|
||||
|
||||
|
||||
def _finish(app, window) -> None:
|
||||
"""Close the window, stopping the poller thread first.
|
||||
|
||||
Without the explicit shutdown Qt tears the thread down with the process and
|
||||
prints "QThread: Destroyed while thread is still running", which reads like a
|
||||
fault in the app rather than a tool that left in a hurry.
|
||||
"""
|
||||
try:
|
||||
window.poller.shutdown()
|
||||
except Exception: # noqa: BLE001 - a shutdown must not mask the result
|
||||
pass
|
||||
window.close()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def _grab_gl(app, preview, attempts: int = 60):
|
||||
"""The GL pane's framebuffer, once it actually has something drawn in it.
|
||||
|
||||
``grabFramebuffer`` returns a valid but uniformly blank image when the widget
|
||||
has not been painted yet, which is easy to mistake for a rendering bug — the
|
||||
first version of this tool saved a white rectangle and the pane looked broken.
|
||||
So it is called repeatedly until the pixels differ from one another.
|
||||
"""
|
||||
import time
|
||||
|
||||
grab = getattr(preview, "grabFramebuffer", None)
|
||||
last = None
|
||||
for _ in range(attempts):
|
||||
app.processEvents()
|
||||
preview.update()
|
||||
app.processEvents()
|
||||
if grab is not None:
|
||||
try:
|
||||
image = grab()
|
||||
except Exception as error: # noqa: BLE001 - reported below
|
||||
print(f"grabFramebuffer failed: {error}", file=sys.stderr)
|
||||
break
|
||||
if image is not None and not image.isNull():
|
||||
last = image
|
||||
if _has_content(image):
|
||||
return image
|
||||
time.sleep(0.05)
|
||||
if last is not None and _has_content(last):
|
||||
return last
|
||||
# Fall back to a plain widget grab only when there is no GL context at all;
|
||||
# a blank framebuffer is reported instead of saved as a picture of nothing.
|
||||
if not preview.isValid():
|
||||
return preview.grab()
|
||||
return None
|
||||
|
||||
|
||||
def _has_content(image) -> bool:
|
||||
"""True when the image is not one flat colour, i.e. something was drawn."""
|
||||
small = image.scaled(24, 24)
|
||||
first = small.pixel(0, 0)
|
||||
for y in range(small.height()):
|
||||
for x in range(small.width()):
|
||||
if small.pixel(x, y) != first:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _paint_pane(image, corner, pane) -> None:
|
||||
"""Draw the GL content into a whole-window grab, where Qt leaves it blank.
|
||||
|
||||
``QWidget.grab`` on a window containing a ``QOpenGLWidget`` does not composite
|
||||
the GL surface, so the pane comes out empty. Painting the framebuffer in at the
|
||||
widget's own position is what makes the page shot show what the operator sees.
|
||||
"""
|
||||
from PySide6.QtGui import QPainter
|
||||
|
||||
painter = QPainter(image)
|
||||
try:
|
||||
painter.drawImage(corner, pane)
|
||||
finally:
|
||||
painter.end()
|
||||
|
||||
|
||||
def _pick_pixels(panel, count: int) -> list[tuple[int, int]]:
|
||||
"""UV-flat pixels that land on the surface, spread around the island.
|
||||
|
||||
Sampled from the flat view rather than hard-coded, so the shot follows the
|
||||
bundle instead of a coordinate that was right for one revision.
|
||||
"""
|
||||
view = panel.view
|
||||
flat = view.flat
|
||||
if flat is None:
|
||||
return []
|
||||
found: list[tuple[int, int]] = []
|
||||
fractions = [(0.5, 0.5), (0.35, 0.45), (0.65, 0.55), (0.5, 0.3), (0.5, 0.7)]
|
||||
for share_x, share_y in fractions:
|
||||
if len(found) >= count:
|
||||
break
|
||||
x = int(flat.width * share_x)
|
||||
y = int(flat.height * share_y)
|
||||
if flat.sample(x, y) is not None:
|
||||
found.append((x, y))
|
||||
return found
|
||||
|
||||
|
||||
def _settle(app, seconds: float) -> None:
|
||||
import time
|
||||
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
def _run_simulation_to(app, preview, share: float) -> None:
|
||||
"""Let the simulation run until ``share`` of it has played.
|
||||
|
||||
Driven off the widget's own clock — the animation is a QTimer, and stepping the
|
||||
event loop is what advances it. Reading the elapsed time rather than sleeping a
|
||||
fixed duration keeps the shot reproducible when the frame budget differs.
|
||||
"""
|
||||
import time
|
||||
|
||||
from orisys_autocal.ui.pose_preview import SIM_DURATION_MS
|
||||
|
||||
target = SIM_DURATION_MS * max(0.0, min(1.0, float(share))) / 1000.0
|
||||
deadline = time.monotonic() + target + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
if getattr(preview, "_sim_elapsed", 0.0) / 1000.0 >= target: # noqa: SLF001
|
||||
break
|
||||
time.sleep(0.005)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -393,6 +393,15 @@ QComboBox:disabled, QLineEdit:disabled, QSpinBox:disabled, QDoubleSpinBox:disabl
|
||||
background-color: {c['window_bg']};
|
||||
border-color: {c['separator']};
|
||||
}}
|
||||
/* A field that sits several to a row. The padding above is what makes an input
|
||||
40 px tall rather than 28, which is right for a form and too tall for a grid of
|
||||
paired numbers — the pair rows overflow their cells and clip. Same look,
|
||||
tighter box: opt in with ``setObjectName("compactField")``. */
|
||||
QComboBox#compactField, QLineEdit#compactField,
|
||||
QSpinBox#compactField, QDoubleSpinBox#compactField {{
|
||||
padding: 1px 4px;
|
||||
min-height: 20px;
|
||||
}}
|
||||
QComboBox::drop-down {{
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: top right;
|
||||
|
||||
Reference in New Issue
Block a user