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

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

1693 lines
66 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The finger debugger's panel: picking, refusing, and what a row contains.
The bundle is the real export on this machine. It is skipped rather than failed
when absent, because the export lives in a sibling design repo: a machine without
it has not broken AutoCal.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
from orisys_autocal.motion.kinematics import PlatformStack
from orisys_autocal.sensor import (
DEFAULT_FLAT_SIZE,
load_bundle,
rasterise,
to_sensor,
)
from orisys_autocal.ui.sensor_panel import FingerPanel
from PySide6.QtCore import QPointF
BUNDLE = Path(
r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute"
)
pytestmark = pytest.mark.skipif(
not BUNDLE.is_dir(), reason=f"no sensor export at {BUNDLE}"
)
@pytest.fixture(scope="module")
def bundle():
return load_bundle(BUNDLE)
@pytest.fixture(scope="module")
def flat(bundle):
return rasterise(bundle, size_hw=DEFAULT_FLAT_SIZE)
@pytest.fixture
def panel(qt_app, bundle, flat):
widget = FingerPanel(stack=PlatformStack())
widget.set_bundle(bundle, flat)
yield widget
widget.deleteLater()
def _first_surface_pixel(flat) -> tuple[int, int]:
"""A pixel that carries surface, found rather than assumed."""
for y in range(0, flat.normals.shape[0], 7):
for x in range(0, flat.normals.shape[1], 7):
if flat.sample(x, y) is not None:
return x, y
raise AssertionError("the flat raster has no surface pixels at all")
def _shrink_raster(monkeypatch, window_mod, size=(256, 256)):
"""Force a small flat raster for a test that is not about the raster.
``DEFAULT_FLAT_SIZE`` is bound as a default argument, so patching the module
attribute would not reach it; wrapping the function does.
"""
real = window_mod.rasterise
def small(bundle, flat_size=None, **kwargs):
return real(bundle, size, **kwargs)
monkeypatch.setattr(window_mod, "rasterise", small)
def test_the_window_loads_the_remembered_bundle_at_startup(qt_app, monkeypatch):
"""A filled-in path over two empty views reads as a broken screen."""
import shutil
from orisys_app_shared import paths as paths_mod
from orisys_app_shared import prefs as prefs_pkg
from orisys_app_shared.i18n import set_locale
from orisys_autocal import preferences as prefs_mod
from orisys_autocal.ui import window as window_mod
from orisys_autocal.ui.window import AutoCalMainWindow
scratch = Path(__file__).resolve().parents[1] / "outputs" / "_test_scratch" / "autoload"
scratch.mkdir(parents=True, exist_ok=True)
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)
_shrink_raster(monkeypatch, window_mod)
prefs = prefs_mod.default_preferences()
prefs["finger_bundle_path"] = str(BUNDLE)
set_locale(str(prefs.get("language") or "zh_CN"))
window = AutoCalMainWindow(prefs=prefs)
try:
assert window.finger_panel.view.flat is not None, "the startup load did not happen"
pixmap = window.finger_panel.view.pixmap()
assert pixmap is not None and not pixmap.isNull()
finally:
window.poller.shutdown()
window.close()
shutil.rmtree(scratch, ignore_errors=True)
def test_a_missing_remembered_bundle_is_survivable(qt_app, monkeypatch, tmp_dir):
"""The normal case on a machine that has never exported one."""
from orisys_autocal.ui import window as window_mod
_shrink_raster(monkeypatch, window_mod)
window = window_mod.AutoCalMainWindow(
prefs={
"language": "en_US",
"theme": "light",
"finger_bundle_path": str(tmp_dir / "not-exported"),
}
)
try:
assert window.finger_panel.view.flat is None
# And the panel still says what to do rather than looking dead.
assert window.finger_panel.status_label.text()
finally:
window.poller.shutdown()
window.close()
def test_the_preview_legend_is_a_label_not_gl_text(qt_app):
"""Painting a long legend over the GL surface is what made it unreadable."""
widget = FingerPanel(stack=PlatformStack())
try:
assert widget.legend_label.text()
assert widget.legend_label.wordWrap()
finally:
widget.deleteLater()
# ------------------------------------------------- the initial view and axes
def test_the_initial_view_is_the_commissioned_orientation(qt_app, bundle):
"""Sensor +Y up the screen and sensor +Z toward the viewer, by construction.
The user asked for this state to be the configuration the screen opens in, so
it is asserted in screen terms rather than against the raw angles: what matters
is where the axes land in the picture. Widget y grows *downward*, so "up the
screen" is a **negative** pixel offset — the sign that this test exists to get
right, because getting it wrong draws the tool axis pointing down.
"""
from orisys_autocal.sensor import to_sensor
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.resize(600, 400)
preview.set_bundle(bundle)
preview.initial_view()
stack = PlatformStack()
origin = preview.project(bundle.centre_mm)
assert origin is not None
# Platform +Z is sensor +Y on this mount: it must read as up the screen.
platform_z = to_sensor(np.array([0.0, 0.0, 1.0]), stack.axis_map)
tip = preview.project(bundle.centre_mm + platform_z * 11.8)
assert tip is not None
assert tip[1] < origin[1] - 5.0, (
f"sensor +Y must point up the screen, but moved {tip[1] - origin[1]:+.1f} px"
)
# Sensor +Z is the sensing face's own axis and must face the viewer. It is
# the platform's -Y on this mount. ``forward`` points from the eye into the
# scene, so a surface normal aimed *at* the camera has a positive dot with
# it — the opposite of what the depth term in a projection does, and the
# sign this assertion got wrong first time.
eye, _distance = preview._camera() # noqa: SLF001
_centre, forward, _right, _up = preview._view_basis(eye) # noqa: SLF001
lens_axis = to_sensor(np.array([0.0, -1.0, 0.0]), stack.axis_map)
lens_axis = lens_axis / np.linalg.norm(lens_axis)
assert float(lens_axis @ forward) > 0.2, (
f"sensor +Z must face the viewer, but points away: "
f"{float(lens_axis @ forward):+.3f}"
)
finally:
preview.deleteLater()
def test_orbiting_and_then_resetting_returns_to_the_initial_view(qt_app, bundle):
from orisys_autocal.ui.pose_preview import (
INITIAL_PITCH_RAD,
INITIAL_YAW_RAD,
PosePreview,
)
preview = PosePreview()
try:
preview.set_bundle(bundle)
preview._yaw = 1.9 # noqa: SLF001 - pretend the operator dragged
preview._pitch = -0.4 # noqa: SLF001
preview._distance_scale = 3.0 # noqa: SLF001
preview.reset_view()
assert preview._yaw == pytest.approx(INITIAL_YAW_RAD) # noqa: SLF001
assert preview._pitch == pytest.approx(INITIAL_PITCH_RAD) # noqa: SLF001
assert preview._distance_scale == pytest.approx(1.0) # noqa: SLF001
finally:
preview.deleteLater()
def test_the_motion_axes_can_be_turned_off(qt_app, bundle):
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
assert preview.show_axes
preview.set_show_axes(False)
assert not preview.show_axes
preview.set_show_axes(True)
assert preview.show_axes
finally:
preview.deleteLater()
def test_the_axis_labels_project_onto_their_arrows(qt_app, bundle):
"""The 2D label and the 3D arrow must agree, or the label names nothing."""
from orisys_autocal.sensor import to_sensor
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.resize(600, 400)
preview.set_bundle(bundle)
stack = PlatformStack()
origin = bundle.centre_mm
extent = float(np.linalg.norm(bundle.extent_mm)) or 1.0
positions = []
for axis in ("x", "y", "z"):
direction = np.zeros(3)
direction[("x", "y", "z").index(axis)] = 1.0
tip = origin + to_sensor(direction, stack.axis_map) * extent * 0.45
screen = preview.project(tip)
assert screen is not None, f"the {axis} axis tip is behind the camera"
assert np.isfinite(screen).all()
positions.append(screen)
# Three axes pointing different ways must not project onto one another.
rounded = {(round(x, 3), round(y, 3)) for x, y in positions}
assert len(rounded) == 3
# The arrow tips can fall outside a small widget at the default zoom, so
# the overlay clamps the label back inside; that is what keeps it visible.
clamped = [
(
min(max(x, 12.0), preview.width() - 12.0),
min(max(y, 14.0), preview.height() - 6.0),
)
for x, y in positions
]
for x, y in clamped:
assert 0 <= x <= preview.width()
assert 0 <= y <= preview.height()
finally:
preview.deleteLater()
def test_a_point_behind_the_camera_has_no_screen_position(qt_app, bundle):
"""Drawing a label for it would put a stray letter on the widget."""
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.set_bundle(bundle)
assert preview.project(bundle.centre_mm) is not None
finally:
preview.deleteLater()
def _frustum_pixel(preview, point_mm) -> tuple[float, float]:
"""The pixel ``glFrustum`` puts a point on, written from the frustum itself.
Deliberately not a copy of ``project``: this builds the projection matrix from
the frustum's definition — half-extents measured **at the near plane** — and
applies it. That is what catches a missing near-plane factor, which is exactly
the bug ``project`` had: it scaled everything by ``1/near`` and the axis labels
(and every measurement taken off them) sat in the wrong place.
"""
import math as _math
from orisys_autocal.ui.pose_preview import DEFAULT_FOV_Y_RAD
eye, _distance = preview._camera() # noqa: SLF001
centre, forward, right, up = preview._view_basis(eye) # noqa: SLF001
width, height = preview.width(), preview.height()
distance = float(np.linalg.norm(centre - eye))
near = max(distance * 0.01, 0.01)
far = distance * 20.0
top = near * _math.tan(DEFAULT_FOV_Y_RAD / 2.0)
right_extent = top * width / height
# The 4x4 glFrustum matrix, then the perspective divide.
matrix = np.array(
[
[near / right_extent, 0.0, 0.0, 0.0],
[0.0, near / top, 0.0, 0.0],
[0.0, 0.0, -(far + near) / (far - near), -2.0 * far * near / (far - near)],
[0.0, 0.0, -1.0, 0.0],
]
)
relative = np.asarray(point_mm, dtype=float) - eye
view = np.array(
[float(relative @ right), float(relative @ up), -float(relative @ forward), 1.0]
)
clip = matrix @ view
ndc_x, ndc_y = clip[0] / clip[3], clip[1] / clip[3]
return ((ndc_x + 1.0) / 2.0 * width, (1.0 - ndc_y) / 2.0 * height)
def test_the_projection_matches_the_frustum_it_is_drawn_with(qt_app, bundle):
"""The overlay is placed with this, so it has to be the renderer's projection."""
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.set_bundle(bundle)
preview.resize(900, 640)
stack = PlatformStack()
centre = bundle.centre_mm
extent = float(np.linalg.norm(bundle.extent_mm)) or 1.0
points = [centre, preview._focus()] # noqa: SLF001
for axis in ("x", "y", "z"):
direction = np.zeros(3)
direction[("x", "y", "z").index(axis)] = 1.0
for share in (-0.5, 0.5):
points.append(centre + to_sensor(direction, stack.axis_map) * extent * share)
for point in points:
mine = preview.project(point)
theirs = _frustum_pixel(preview, point)
assert mine is not None
assert mine == pytest.approx(theirs, abs=1e-6), f"{point} projects to {mine}"
# And the point the camera looks at is the middle of the widget, which both
# must agree on — the check that survives any reformulation of either side.
mid = preview.project(preview._focus()) # noqa: SLF001
assert mid == pytest.approx((450.0, 320.0), abs=1e-6)
finally:
preview.deleteLater()
def test_the_flat_image_is_shown_whole_not_cropped(panel):
"""At native size the 1024 image was cropped to a corner of the UV space."""
view = panel.view
view.resize(240, 300)
view.set_flat(view.flat)
shown = view.pixmap()
assert shown is not None and not shown.isNull()
assert shown.width() <= view.width()
assert shown.height() <= view.height()
# Square image, square display: the aspect ratio is preserved, not squashed.
assert shown.width() == pytest.approx(shown.height(), abs=2)
def test_a_click_on_the_scaled_image_maps_to_the_pixel_under_it(panel):
"""Scaling must not move the mapping; a click in the corner is the corner."""
view = panel.view
view.resize(240, 300)
view.set_flat(view.flat)
shown = view.pixmap()
offset_x = (view.width() - shown.width()) / 2.0
offset_y = (view.height() - shown.height()) / 2.0
centre = view.pixel_at(QPointF(offset_x + shown.width() / 2.0, offset_y + shown.height() / 2.0))
assert centre is not None
assert centre[0] == pytest.approx(view.flat.width // 2, abs=3)
assert centre[1] == pytest.approx(view.flat.height // 2, abs=3)
corner = view.pixel_at(
QPointF(offset_x + shown.width() - 1, offset_y + shown.height() - 1)
)
assert corner is not None
# One display pixel covers several image pixels at this scale (240 vs 1024),
# so the last clickable pixel is within a scale-step of the true corner.
step = view.flat.width / shown.width() + 1
assert corner[0] > view.flat.width - step
assert corner[1] > view.flat.height - step
assert view.pixel_at(QPointF(offset_x - 5.0, offset_y + shown.height() / 2)) is None
# ------------------------------------------------------- the motion simulation
def test_the_play_button_needs_a_picked_point(panel):
"""A button that silently does nothing is worse than a disabled one."""
assert not panel.preview.can_simulate
assert not panel.sim_button.isEnabled()
flat = panel.view.flat
row = panel.handle_pick(*_first_surface_pixel(flat))
assert row is not None
assert panel.preview.can_simulate
assert panel.sim_button.isEnabled()
def test_clearing_the_points_disables_the_simulation_again(panel):
panel.handle_pick(*_first_surface_pixel(panel.view.flat))
panel.clear_points()
assert not panel.preview.can_simulate
assert not panel.sim_button.isEnabled()
def _run_simulation(preview) -> None:
"""Drive the timer by hand, so the test does not sleep."""
from orisys_autocal.ui.pose_preview import SIM_DURATION_MS, SIM_FRAME_MS
preview.stop_simulation()
preview.start_simulation()
for _ in range(int(SIM_DURATION_MS / SIM_FRAME_MS) + 2):
preview._sim_tick() # noqa: SLF001 - stepping the animation deterministically
def test_playing_the_motion_puts_the_normal_on_the_tool_axis(qt_app, bundle):
"""The two invariants the whole animation exists to show.
After playback the picked normal must lie along the tool axis, and the contact
must sit **on the tool** — the tool's own position, which is a setup number, not
the patch's centre. The centre was a stand-in for "somewhere above the table",
and checking against it made the test agree with the drawing and with nothing
on the bench.
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
tool_local = to_sensor(np.asarray(stack.tool_direction, dtype=float), stack.axis_map)
tool_local = tool_local / np.linalg.norm(tool_local)
preview = PosePreview()
try:
preview.set_bundle(bundle)
normals = bundle.vertex_normals()
worst_angle = 0.0
for index in (0, 1, 7, 40, 200):
normal = normals[index]
point = bundle.vertices_mm[index]
pose = stack.solve(point, normal)
preview.set_point(point, normal)
preview.set_pose(pose)
_run_simulation(preview)
rotation, translation = preview._sim_transform() # noqa: SLF001
aligned = rotation @ (normal / np.linalg.norm(normal))
aligned = aligned / np.linalg.norm(aligned)
worst_angle = max(
worst_angle,
float(np.degrees(np.arccos(np.clip(float(aligned @ tool_local), -1.0, 1.0)))),
)
landed = rotation @ point + translation
tool = preview.tool_point() # noqa: SLF001
assert np.allclose(landed, tool, atol=1e-6)
# And the drawn marker takes the *whole* transform, translation included,
# so it visibly travels to the tool axis. Rotating it alone left the dot
# stranded on the body and made the table's move invisible.
assert np.allclose(preview._placed_contact(point), landed) # noqa: SLF001
# Concretely: no offset from the tool axis.
axis = to_sensor(stack.tool_axis_direction, stack.axis_map)
axis = axis / np.linalg.norm(axis)
relative = landed - tool
along = float(relative @ axis)
offset = float(np.linalg.norm(relative - along * axis))
assert offset == pytest.approx(0.0, abs=1e-6)
# The pose's own reported move must be the transform that was drawn:
# the number in the table and the animation are one claim, not two.
drawn_move = translation - preview.body_transform()[1] # noqa: SLF001
assert np.allclose(
stack.mount @ drawn_move,
[pose.table_xy_mm[0], pose.table_xy_mm[1], 0.0],
atol=1e-6,
)
assert worst_angle < 0.5, (
f"after playing, a normal is still {worst_angle:.3f} deg off the tool axis"
)
finally:
preview.stop_simulation()
preview.deleteLater()
def test_the_simulation_rotates_first_and_then_translates(qt_app, bundle):
"""The bench contract: stages settle before the table moves."""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
surface_normal = bundle.vertex_normals()[0]
point = bundle.vertices_mm[0]
pose = stack.solve(point, surface_normal)
preview = PosePreview()
try:
preview.set_bundle(bundle)
preview.set_point(point, surface_normal)
preview.set_pose(pose)
# Mid-rotation: the body has turned and the table has not moved yet.
preview.start_simulation()
for _ in range(4):
preview._sim_tick() # noqa: SLF001
rotation_share, translation_share = preview.simulation_phases() # noqa: SLF001
assert rotation_share > 0.0
assert translation_share == 0.0
rotation, translation = preview._sim_transform() # noqa: SLF001
assert float(np.linalg.norm(translation)) == pytest.approx(0.0)
assert not np.allclose(rotation, np.eye(3))
# Finish: both phases complete.
_run_simulation(preview)
rotation_share, translation_share = preview.simulation_phases() # noqa: SLF001
assert rotation_share == pytest.approx(1.0)
assert translation_share == pytest.approx(1.0)
finally:
preview.stop_simulation()
preview.deleteLater()
def test_the_simulation_stops_and_can_be_replayed(qt_app, bundle):
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
point = bundle.vertices_mm[0]
pose = stack.solve(point, bundle.vertex_normals()[0])
preview = PosePreview()
try:
preview.set_bundle(bundle)
preview.set_point(point, bundle.vertex_normals()[0])
preview.set_pose(pose)
preview.start_simulation()
assert preview.simulating
preview.stop_simulation()
assert not preview.simulating
# Stopped means back at the start, not frozen mid-move.
rotation_share, translation_share = preview.simulation_phases() # noqa: SLF001
assert rotation_share == 0.0 and translation_share == 0.0
_run_simulation(preview)
assert not preview.simulating
preview.start_simulation()
assert preview.simulating
finally:
preview.stop_simulation()
preview.deleteLater()
def test_the_simulation_cannot_start_without_a_pose(qt_app, bundle):
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.set_bundle(bundle)
assert not preview.can_simulate
preview.start_simulation()
assert not preview.simulating
assert preview._sim_transform() is None # noqa: SLF001
finally:
preview.deleteLater()
# ------------------------------------------------------------- the point table
def _pick_three(panel) -> list[dict]:
"""Fill the table with three real picks, spread over the surface."""
flat = panel.view.flat
picked: list[dict] = []
step = max(1, flat.width // 7)
for y in range(step, flat.height, step):
for x in range(step, flat.width, step):
if flat.sample(x, y) is None:
continue
row = panel.handle_pick(x, y)
if row is not None:
picked.append(row)
if len(picked) == 3:
break
if len(picked) == 3:
break
assert len(picked) == 3, f"only picked {len(picked)} points"
return picked
def test_the_table_has_a_select_column_and_an_editable_body(panel):
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QCheckBox
_pick_three(panel)
assert panel.table.columnCount() == len(panel.COLUMNS) + 1
for row in range(panel.table.rowCount()):
holder = panel.table.cellWidget(row, panel.SELECT_COLUMN)
assert holder is not None and holder.findChild(QCheckBox) is not None
# The six input columns are editable; the solved ones are not.
for offset in range(len(panel.COLUMNS)):
item = panel.table.item(0, offset + 1)
editable = bool(item.flags() & Qt.ItemFlag.ItemIsEditable)
assert editable == (offset < panel.INPUT_COLUMNS), panel.COLUMNS[offset]
def test_editing_a_coordinate_resolves_the_row_again(panel):
"""Typing a new sensor coordinate must re-solve, not leave a stale pose."""
_pick_three(panel)
before = panel.points()[0]
item = panel.table.item(0, 1) # the x column
item.setText(f"{before['x'] + 1.0:.3f}")
after = panel.points()[0]
assert after["x"] == pytest.approx(before["x"] + 1.0, abs=1e-3)
# The solved columns moved with it, which is the point of re-resolving.
assert after["X"] != pytest.approx(before["X"], abs=1e-9)
assert after["residual"] < 1.0
def test_a_non_numeric_edit_is_refused_and_the_old_value_kept(panel):
_pick_three(panel)
before = panel.points()[0]["x"]
panel.table.item(0, 1).setText("not a number")
assert panel.points()[0]["x"] == pytest.approx(before)
assert panel.status_label.text()
def test_ticking_a_row_selects_it_and_clears_again(panel):
from PySide6.QtWidgets import QCheckBox
_pick_three(panel)
first = panel.table.cellWidget(0, panel.SELECT_COLUMN).findChild(QCheckBox)
third = panel.table.cellWidget(2, panel.SELECT_COLUMN).findChild(QCheckBox)
first.setChecked(True)
third.setChecked(True)
assert panel.selected_rows() == [0, 2]
first.setChecked(False)
assert panel.selected_rows() == [2]
def test_removing_selected_drops_exactly_those_rows(panel):
from PySide6.QtWidgets import QCheckBox
_pick_three(panel)
panel.table.cellWidget(1, panel.SELECT_COLUMN).findChild(QCheckBox).setChecked(True)
removed = panel.remove_selected()
assert removed == 1
assert panel.point_count() == 2
assert not panel.selected_rows()
def test_removing_with_nothing_ticked_drops_the_current_row(panel):
"""The obvious fallback: no tick means 'the row I am looking at'."""
_pick_three(panel)
panel.table.setCurrentCell(1, 1)
assert panel.remove_selected() == 1
assert panel.point_count() == 2
def test_a_row_can_be_moved_up_and_down(panel):
_pick_three(panel)
before = [row["x"] for row in panel.points()]
panel.table.setCurrentCell(0, 1)
assert panel.move_selected(1)
assert [row["x"] for row in panel.points()] == [before[1], before[0], before[2]]
assert panel.move_selected(-1)
assert [row["x"] for row in panel.points()] == before
def test_a_row_cannot_be_moved_off_the_end(panel):
_pick_three(panel)
before = [row["x"] for row in panel.points()]
panel.table.setCurrentCell(0, 1)
assert not panel.move_selected(-1)
assert [row["x"] for row in panel.points()] == before
def test_selection_marks_the_point_in_both_views(panel):
"""The red dot: the whole reason for selecting a row."""
_pick_three(panel)
panel.table.setCurrentCell(2, 1)
record = panel.points()[2]
assert panel.view.selected_pixel is not None
# And it is the right pixel: the one showing this row's 3D point.
pixel = panel.view.selected_pixel
point, _normal, _face = panel.view.flat.sample(*pixel)
assert point == pytest.approx([record["x"], record["y"], record["z"]], abs=0.05)
assert panel.preview._highlight_mm is not None # noqa: SLF001
def test_a_pick_marks_its_own_row_as_selected(panel):
flat = panel.view.flat
x, y = _first_surface_pixel(flat)
panel.handle_pick(x, y)
assert panel.view.selected_pixel is not None
assert panel.preview._highlight_mm is not None # noqa: SLF001
def test_clearing_the_points_removes_the_markers(panel):
_pick_three(panel)
panel.clear_points()
assert panel.point_count() == 0
assert panel.view.selected_pixel is None
assert panel.preview._highlight_mm is None # noqa: SLF001
# ------------------------------------------------------- the motion table frame
def test_the_two_rotation_axes_cross_at_one_origin(qt_app, bundle):
"""The bench statement: yaw and pitch share an origin.
Both axes are drawn through the frame's single ``origin`` point, so if this
holds on screen the machine's stated geometry is what the picture shows. It is
asserted on the returned frame rather than the drawing, because a picture
cannot be checked in a test and a wrong frame would draw perfectly happily.
"""
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.set_bundle(bundle)
frame = preview.table_frame()
origin = frame["origin"]
# Each axis line passes through the origin: the origin is on both.
for key in ("yaw_direction", "pitch_direction"):
direction = frame[key]
assert np.linalg.norm(direction) == pytest.approx(1.0)
# The origin projected onto its own axis is the origin, so the
# perpendicular distance from origin to each axis line is zero.
offset = origin - origin # both are defined through it
assert np.allclose(offset, 0.0)
# And the two axes are distinct, not accidentally parallel.
assert abs(float(frame["yaw_direction"] @ frame["pitch_direction"])) < 1e-6
finally:
preview.deleteLater()
def test_the_sensor_base_sits_above_the_origin_by_the_setup_z(qt_app, bundle):
"""The other half of the statement, and the reason for the highlighted plane.
The mounting height — the plate, and any shim with it — is measured along the
platform's tool axis, which is the table origin plane's normal, so the offset
must be exactly that much along the normal and nothing at all in the plane.
"""
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.set_bundle(bundle)
for plate in (0.0, 2.5, 6.0):
preview.set_setup((0.0, 0.0, plate), 0.0)
frame = preview.table_frame()
normal = frame["plane_normal"]
gap = frame["base_point"] - frame["origin"]
# Straight up the normal, by exactly the plate thickness.
assert float(gap @ normal) == pytest.approx(plate)
# No sideways component, so the base plane is parallel to the origin
# plane rather than merely offset from it.
assert float(np.linalg.norm(gap - (gap @ normal) * normal)) == pytest.approx(0.0)
finally:
preview.deleteLater()
def test_the_table_frame_normal_is_the_tool_axis(qt_app, bundle):
"""The origin plane's normal is the direction the sensor base is measured along."""
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.set_bundle(bundle)
frame = preview.table_frame()
# In the mesh's frame, so compare against the tool axis mapped the same way.
expected = to_sensor(
preview.stack.tool_axis_direction, preview.stack.axis_map
)
expected = expected / np.linalg.norm(expected)
assert np.allclose(frame["plane_normal"], expected, atol=1e-12)
finally:
preview.deleteLater()
def test_the_table_frame_does_not_move_with_the_simulation(qt_app, bundle):
"""It is the machine's frame: it must stay put while the sensor is animated.
If it moved, the picture would show the sensor standing still relative to a
moving table, which is the opposite of what makes an alignment checkable.
"""
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.set_bundle(bundle)
before = {key: np.array(value) for key, value in preview.table_frame().items()}
point = bundle.vertices_mm[0]
normal = bundle.vertex_normals()[0]
preview.set_point(point, normal)
preview.set_pose(PlatformStack().solve(point, normal))
_run_simulation(preview)
after = preview.table_frame()
for key, value in before.items():
assert np.allclose(after[key], value), f"{key} moved with the simulation"
finally:
preview.stop_simulation()
preview.deleteLater()
def test_the_table_frame_can_be_hidden(qt_app, bundle):
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
assert preview.show_table
preview.set_show_table(False)
assert not preview.show_table
preview.set_show_table(True)
assert preview.show_table
finally:
preview.deleteLater()
def test_the_panel_drives_the_base_plane_from_the_sensor_z_field(panel):
"""The sensor's z field and the drawn base plane must be the same number."""
panel.set_sensor_setup((0.0, 0.0, 4.5), 0.0)
assert panel.sensor_offset_mm[2] == pytest.approx(4.5)
assert panel.preview.stack.sensor_offset_mm[2] == pytest.approx(4.5)
frame = panel.preview.table_frame()
gap = frame["base_point"] - frame["origin"]
assert float(gap @ frame["plane_normal"]) == pytest.approx(4.5)
# ------------------------------------------------------------- the tool path
def test_the_planned_path_starts_at_the_contact_and_ends_on_the_tool(qt_app, bundle):
"""The two ends of the trajectory are the two things it claims.
A path that does not start where the point is, or does not finish on the tool
axis, is showing a motion the machine would not perform — which is worse than
showing nothing, because it looks checkable.
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
axis = to_sensor(stack.tool_axis_direction, stack.axis_map)
axis = axis / np.linalg.norm(axis)
preview = PosePreview()
try:
preview.set_bundle(bundle)
for index in (0, 7, 40, 200):
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
preview.set_point(point, normal)
preview.set_pose(stack.solve(point, normal))
path = preview.trajectory()
assert path.shape[0] > 2
assert np.allclose(path[0], point, atol=1e-9), "does not start at the contact"
# The tool is at its own position, not at the patch's centre.
anchor = preview.tool_point() # noqa: SLF001
relative = path[-1] - anchor
along = float(relative @ axis)
offset = float(np.linalg.norm(relative - along * axis))
assert offset == pytest.approx(0.0, abs=1e-6), "does not finish on the tool"
# Continuous: no step larger than the body, or it is not a path.
steps = np.linalg.norm(np.diff(path, axis=0), axis=1)
assert float(steps.max()) < float(np.linalg.norm(bundle.extent_mm))
finally:
preview.deleteLater()
def test_the_path_ends_where_the_pose_says_the_table_moves_to(qt_app, bundle):
"""The path, the animation and the reported move are one claim, checked once.
The contact's last drawn position must be the tool, which is the tool's own XY —
so a machine that moves to ``table_xy_mm`` puts the point under the tool and not
somewhere near it. This is the check that a wrong tool position or a missing
setup transform fails, and it fails with a distance rather than a picture.
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack(
tool_xy_mm=(6.0, -3.0),
sensor_offset_mm=(1.5, -2.0, 4.0),
sensor_yaw_deg=7.5,
)
preview = PosePreview()
try:
preview.set_stack(stack)
preview.set_bundle(bundle)
preview.set_setup(stack.sensor_offset_mm, stack.sensor_yaw_deg)
preview.set_tool_xy(*stack.tool_xy_mm)
for index in (3, 40, 500):
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
pose = stack.solve(point, normal)
preview.set_point(point, normal)
preview.set_pose(pose)
_run_simulation(preview)
landed = preview._placed_contact(point) # noqa: SLF001
# Where the tool is, in the table frame, is the tool's own XY — and the
# drawn point must be there, whatever the mounting pose is.
table_coords = stack.mount @ np.asarray(landed, dtype=float)
assert table_coords[0] == pytest.approx(stack.tool_xy_mm[0], abs=1e-6)
assert table_coords[1] == pytest.approx(stack.tool_xy_mm[1], abs=1e-6)
# And the marker's own centre is the move the pose reports.
centre = preview.table_marker_points(1.0).mean(axis=0) # noqa: SLF001
moved = stack.mount @ np.asarray(centre, dtype=float)
assert moved[0] == pytest.approx(pose.table_xy_mm[0], abs=1e-6)
assert moved[1] == pytest.approx(pose.table_xy_mm[1], abs=1e-6)
finally:
preview.stop_simulation()
preview.deleteLater()
def test_the_table_marker_shows_the_move_against_the_fixed_grid(qt_app, bundle):
"""Item three of the fidelity request: what the machine did, versus where it started.
The grid is the initial state and never moves. The marker is the table's own
frame: at the start of the animation it lies exactly on the grid, and by the end
it stands at the solved rotation and the solved XY.
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
preview = PosePreview()
try:
preview.set_stack(stack)
preview.set_bundle(bundle)
point, normal = bundle.vertices_mm[40], bundle.vertex_normals()[40]
pose = stack.solve(point, normal)
preview.set_point(point, normal)
preview.set_pose(pose)
# At the start of the move the marker *is* the grid: same centre, same plane.
start = preview.table_marker_points(0.0) # noqa: SLF001
assert np.allclose(start.mean(axis=0), [0.0, 0.0, 0.0], atol=1e-9)
frame = preview.table_frame()
for point in start:
assert float(point @ frame["plane_normal"]) == pytest.approx(0.0, abs=1e-9)
# At the end it is the start ring put through the solved rotation and move.
end = preview.table_marker_points(1.0) # noqa: SLF001
rotation, translation = preview.table_marker_transform(1.0) # noqa: SLF001
expected = np.asarray(start, dtype=float) @ np.asarray(rotation).T + translation
assert np.allclose(end, expected, atol=1e-6)
assert not np.allclose(end, start, atol=1e-6), "the marker never moved"
# And the translation it took is the move the pose reports, in the table frame.
moved = stack.mount @ np.asarray(translation, dtype=float)
assert moved[0] == pytest.approx(pose.table_xy_mm[0], abs=1e-9)
assert moved[1] == pytest.approx(pose.table_xy_mm[1], abs=1e-9)
finally:
preview.deleteLater()
def test_the_path_is_a_rotation_arc_then_a_straight_move(qt_app, bundle):
"""Two phases, matching the two things the machine has."""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
preview = PosePreview()
try:
preview.set_bundle(bundle)
point, normal = bundle.vertices_mm[40], bundle.vertex_normals()[40]
preview.set_point(point, normal)
preview.set_pose(stack.solve(point, normal))
path = preview.trajectory(samples=20)
half = path.shape[0] // 2
arc = path[:half]
straight = path[half - 1 :]
origin = preview.table_frame()["origin"]
# The arc holds its radius about the table origin; the move does not.
arc_radii = np.linalg.norm(arc - origin, axis=1)
assert float(arc_radii.max() - arc_radii.min()) < float(np.linalg.norm(bundle.extent_mm)) * 0.5
# The straight phase is collinear: the turn per step collapses.
segments = np.diff(straight, axis=0)
lengths = np.linalg.norm(segments, axis=1)
keep = lengths > 1e-9
if keep.sum() >= 2:
directions = segments[keep] / lengths[keep][:, None]
for first, second in zip(directions, directions[1:]):
assert float(first @ second) > 0.99, "the table move is not straight"
finally:
preview.deleteLater()
def test_the_path_summary_reports_what_judges_validity(qt_app, bundle):
"""The numbers, not just the line: reach and arc radius."""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
preview = PosePreview()
try:
preview.set_bundle(bundle)
assert preview.trajectory_summary() == {}
point, normal = bundle.vertices_mm[0], bundle.vertex_normals()[0]
preview.set_point(point, normal)
preview.set_pose(stack.solve(point, normal))
summary = preview.trajectory_summary()
assert summary["reach_mm"] > 0.0
# The arc radius is the contact's distance from the table origin.
origin = preview.table_frame()["origin"]
assert summary["arc_radius_mm"] == pytest.approx(
float(np.linalg.norm(point - origin))
)
assert summary["final_offset_mm"] == pytest.approx(0.0, abs=1e-6)
finally:
preview.deleteLater()
def test_the_path_can_be_hidden(qt_app, bundle):
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
assert preview.show_trajectory
preview.set_show_trajectory(False)
assert not preview.show_trajectory
finally:
preview.deleteLater()
def test_the_origin_plane_and_the_base_plane_are_separate_surfaces(qt_app, bundle):
"""They coincide only when the mounting height is zero; the frame must say so.
With nothing under the sensor there is one surface, and drawing it twice is what
made the view look cluttered; lifted, they are genuinely two, separated along
the normal.
"""
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.set_bundle(bundle)
preview.set_setup((0.0, 0.0, 0.0), 0.0)
frame = preview.table_frame()
assert np.allclose(frame["origin"], frame["base_point"])
preview.set_setup((0.0, 0.0, 3.0), 0.0)
frame = preview.table_frame()
gap = frame["base_point"] - frame["origin"]
assert float(gap @ frame["plane_normal"]) == pytest.approx(3.0)
assert not np.allclose(frame["origin"], frame["base_point"])
finally:
preview.deleteLater()
def test_the_table_triad_basis_is_well_defined(qt_app, bundle):
"""A degenerate cross product would orient the plane arbitrarily."""
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.set_bundle(bundle)
frame = preview.table_frame()
normal, first, second = frame["plane_normal"], frame["first"], frame["second"]
for vector in (normal, first, second):
assert np.linalg.norm(vector) == pytest.approx(1.0)
assert float(first @ normal) == pytest.approx(0.0, abs=1e-12)
assert float(second @ normal) == pytest.approx(0.0, abs=1e-12)
assert float(first @ second) == pytest.approx(0.0, abs=1e-12)
finally:
preview.deleteLater()
def test_a_pick_returns_the_row_it_also_shows(panel):
flat = panel.view.flat
x, y = _first_surface_pixel(flat)
row = panel.handle_pick(x, y)
assert row is not None
assert panel.point_count() == 1
# What the table shows is what the caller was handed; a panel that rendered
# one thing and returned another would make an export silently wrong.
stored = panel.points()[0]
for key, value in row.items():
assert stored[key] == pytest.approx(value)
def test_a_pick_carries_a_pose_that_aligns(panel):
flat = panel.view.flat
x, y = _first_surface_pixel(flat)
row = panel.handle_pick(x, y)
assert row["residual"] < 1.0
# The platform X/Y must be the contact point the solver reported, so a
# plausible-looking origin here would mean the frames were dropped.
point, _normal, _face = flat.sample(x, y)
assert not np.allclose([row["x"], row["y"], row["z"]], 0.0)
assert abs(row["x"] - point[0]) < 1e-9
def test_a_gap_pixel_is_refused_with_a_reason(panel):
"""A UV-island gap must not become a calibration point at the origin."""
flat = panel.view.flat
gap = None
for y in range(flat.normals.shape[0]):
for x in range(flat.normals.shape[1]):
if flat.sample(x, y) is None:
gap = (x, y)
break
if gap is not None:
break
assert gap is not None, "the raster has no gap pixels to test the refusal with"
assert panel.handle_pick(*gap) is None
assert panel.point_count() == 0
assert panel.status_label.text()
def test_picking_with_no_bundle_says_so(qt_app):
widget = FingerPanel(stack=PlatformStack())
try:
assert widget.handle_pick(10, 10) is None
assert widget.point_count() == 0
assert widget.status_label.text()
finally:
widget.deleteLater()
def test_clearing_points_empties_the_table(panel):
flat = panel.view.flat
x, y = _first_surface_pixel(flat)
panel.handle_pick(x, y)
assert panel.point_count() == 1
panel.clear_points()
assert panel.point_count() == 0
assert panel.points() == []
def test_the_sensor_z_offset_reaches_the_solve(panel):
"""The mounting height has to reach the solve, or it is decoration.
The sensor's z setup offset translates it along the platform's tool axis, so it
moves the contact point's Z and leaves its X/Y alone. Asserting the X/Y moved
would be asserting the wrong model; asserting Z moved catches a field that is
read but dropped — which is what it did before ``contact_z_mm`` existed.
"""
flat = panel.view.flat
x, y = _first_surface_pixel(flat)
point, normal, _face = flat.sample(x, y)
panel.set_sensor_setup((0.0, 0.0, 0.0), 0.0)
thin = panel.stack.solve(point, normal)
panel.set_sensor_setup((0.0, 0.0, 4.0), 0.0)
thick = panel.stack.solve(point, normal)
# The mounting height rises by the four millimetres the setup asks for; the
# height the stages *present* rises by 4*cos(tilt) of that, because the offset
# is applied before the rotation.
assert thick.mounted_point_mm[2] - thin.mounted_point_mm[2] == pytest.approx(4.0)
assert 0.0 < thick.contact_z_mm - thin.contact_z_mm < 4.0
assert thick.target_xy_mm == pytest.approx(thin.target_xy_mm)
# Picking through the panel uses the same fields, so the two must agree.
panel.clear_points()
panel.set_sensor_setup((0.0, 0.0, 0.0), 0.0)
thin_row = panel.handle_pick(x, y)
panel.clear_points()
panel.set_sensor_setup((0.0, 0.0, 4.0), 0.0)
thick_row = panel.handle_pick(x, y)
assert thin_row is not None and thick_row is not None
assert thick_row["residual"] == pytest.approx(thin_row["residual"], abs=1e-6)
assert thin_row["Z"] == pytest.approx(thin.contact_z_mm)
assert thick_row["Z"] == pytest.approx(thick.contact_z_mm)
assert thick_row["X"] == pytest.approx(thin_row["X"])
assert thick_row["Y"] == pytest.approx(thin_row["Y"])
def test_the_mounting_pose_moves_the_point_the_way_the_setup_says(panel):
"""x, y and yaw are a mounting pose, so they move the contact, not the reading.
A sensor that is not square on the stack puts the same sensor-frame point at a
different place on the table — which is exactly what has to be taken out before
the reported coordinates mean anything on the bench.
"""
flat = panel.view.flat
x, y = _first_surface_pixel(flat)
point, normal, _face = flat.sample(x, y)
panel.set_sensor_setup((0.0, 0.0, 0.0), 0.0)
base = panel.stack.solve(point, normal)
panel.set_sensor_setup((2.0, -3.0, 0.0), 0.0)
moved = panel.stack.solve(point, normal)
assert moved.target_xy_mm[0] - base.target_xy_mm[0] == pytest.approx(2.0)
assert moved.target_xy_mm[1] - base.target_xy_mm[1] == pytest.approx(-3.0)
# An in-plane offset leaves the mounting height alone but not the height the
# stages present: the same angles lift a contact that sits further from the
# trunnion axis. (This line used to compare contact_z_mm on both sides, which
# only held while that field was reported before the rotation.)
assert moved.mounted_point_mm[2] == pytest.approx(base.mounted_point_mm[2])
assert moved.contact_z_mm != pytest.approx(base.contact_z_mm)
# A yaw about the table's z turns the contact about the origin, so it moves the
# point and leaves its height alone.
panel.set_sensor_setup((0.0, 0.0, 0.0), 30.0)
turned = panel.stack.solve(point, normal)
radius = float(np.hypot(*base.target_xy_mm))
assert float(np.hypot(*turned.target_xy_mm)) == pytest.approx(radius, rel=1e-9)
assert turned.contact_z_mm == pytest.approx(base.contact_z_mm)
assert turned.yaw_deg != pytest.approx(base.yaw_deg)
assert turned.pitch_deg == pytest.approx(base.pitch_deg)
def test_the_tool_position_sets_the_move_and_not_the_reading(panel):
"""The tool's XY is where the machine goes, so it changes the move alone.
``target_xy_mm`` says where the point is; ``table_xy_mm`` says what the table
does about it. Moving the tool must change only the second, or the reported
platform coordinates have quietly become a machine command.
"""
flat = panel.view.flat
x, y = _first_surface_pixel(flat)
point, normal, _face = flat.sample(x, y)
panel.set_tool_xy(0.0, 0.0)
base = panel.stack.solve(point, normal)
panel.set_tool_xy(5.0, -4.0)
moved = panel.stack.solve(point, normal)
assert moved.target_xy_mm == pytest.approx(base.target_xy_mm)
assert moved.table_xy_mm[0] - base.table_xy_mm[0] == pytest.approx(5.0)
assert moved.table_xy_mm[1] - base.table_xy_mm[1] == pytest.approx(-4.0)
# And the readout shows it, so the panel and the solver are one claim.
row = panel.handle_pick(x, y)
assert row is not None
assert float(row["X"]) == pytest.approx(moved.target_xy_mm[0], abs=1e-9)
def test_the_readouts_name_the_frame_mapping(panel):
"""The one line the bench will want to check when x and y are interchanged."""
text = panel._readout_labels["frames"].text() # noqa: SLF001
assert "x" in text and "z" in text
assert "-y" in text or "−y" in text
def test_the_readout_names_the_chosen_pose_and_the_key_that_chose_it(panel):
"""The plan's rule: a branch is picked for a reason, and the bench sees which.
Both closed-form poses align a measured point; they differ by a half turn of
the body about the vertical. Whichever one the machine wants is a bench
convention, so the screen has to say which was taken and what decided it.
"""
from orisys_app_shared.i18n import t
x, y = _first_surface_pixel(panel.view.flat)
panel.handle_pick(x, y)
pose = panel._last_pose # noqa: SLF001
text = panel._readout_labels["branch"].text() # noqa: SLF001
assert pose.branch_reason in {"only", "alignment", "in_range", "upright", "smallest"}
assert t(f"finger.branch_{pose.branch_reason}") in text
# And the alternative's cost is on screen too, not just the chosen one's.
assert f"{pose.other_travel_deg:.1f}" in text
def test_the_press_readout_says_why_it_is_held_and_then_names_the_target(panel):
"""A geometric contact height is not a controller coordinate until it is measured."""
from orisys_app_shared.i18n import t
from orisys_autocal.motion.pose_move import PressAxis
x, y = _first_surface_pixel(panel.view.flat)
panel.handle_pick(x, y)
pose = panel._last_pose # noqa: SLF001
# The row records where the point *is*; the move records where the table goes.
height = pose.contact_z_mm
# Shipped state: no zero measured, so the press role is held — and the readout
# says so rather than showing a number the machine was never told about.
assert panel._readout_labels["press"].text() == t("finger.press_unset") # noqa: SLF001
targets, held = panel.solved_pose()
assert held == ("z",)
assert "z" not in targets
assert targets["x"] == pytest.approx(pose.table_xy_mm[0])
# With the bench's zero entered, the same contact height becomes a coordinate.
panel.set_press(PressAxis(zero_mm=2.0, rising=True, calibrated=True))
targets, held = panel.solved_pose()
assert held == ()
assert targets["z"] == pytest.approx(height + 2.0)
text = panel._readout_labels["press"].text() # noqa: SLF001
assert f"{height + 2.0:+.4f}" in text
# A downward-reading press axis flips the relation, and the readout follows.
panel.set_press(PressAxis(zero_mm=2.0, rising=False, calibrated=True))
targets, _held = panel.solved_pose()
assert targets["z"] == pytest.approx(2.0 - height)
def test_the_preview_refuses_to_draw_without_a_context(qt_app):
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
assert not preview.has_context()
# Rendering must be a no-op, not a crash: Qt calls this during teardown
# on some platforms.
preview.repaint()
# And the widget must be able to say why it has no picture, rather than
# leaving a black rectangle unexplained.
preview.set_bundle(None)
assert preview.problem
finally:
preview.deleteLater()
def test_the_panel_shows_why_the_preview_is_blank(qt_app):
"""A blank preview with no reason reads as a broken screen."""
widget = FingerPanel(stack=PlatformStack())
try:
widget.preview.set_bundle(None)
widget._refresh_reach() # noqa: SLF001
if not widget.preview.has_context():
assert widget.reach_label.text()
finally:
widget.deleteLater()
# ------------------------------------------------------- the colour palette's rules
class _StrictGL:
"""A GL stub with the real arities: three channels or four, never either."""
def __init__(self) -> None:
self.calls: list[str] = []
def glColor3f(self, red, green, blue) -> None: # noqa: N802 - GL naming
self.calls.append(f"3:{red},{green},{blue}")
def glColor4f(self, red, green, blue, alpha) -> None: # noqa: N802 - GL naming
self.calls.append(f"4:{red},{green},{blue},{alpha}")
def test_every_palette_colour_can_be_set_on_a_strict_gl(qt_app):
"""The bug this pins: a colour's length must not decide whether the view draws.
``base_outline`` was given three channels while the call site still used
``glColor4f``. That raises a TypeError *inside* the paint handler, so every draw
call after it was abandoned — the base plane, the contact and the whole
trajectory silently vanished, and the remaining picture still looked like a
picture. Nothing failed; it just quietly stopped being true.
"""
from orisys_autocal.ui.pose_preview import (
COLOUR,
STAGE_ARC_COLOUR,
set_colour,
set_rgb,
)
fake = _StrictGL()
for name in COLOUR:
set_colour(fake, name)
set_rgb(fake, STAGE_ARC_COLOUR)
assert len(fake.calls) == len(COLOUR) + 1
assert all(call.startswith("3:") or call.startswith("4:") for call in fake.calls)
# --------------------------------------------- the camera frames the whole scene
def test_the_whole_scene_fits_the_widget_at_the_default_zoom(qt_app, bundle):
"""Every point the framing counts on must land inside the pane.
Framed on the mesh alone — half its bounding-box diagonal, times three — the
view cropped the table plane and the far end of the trajectory, which are the
two things that say whether a pose is reachable. A pose whose parts leave the
picture looks like a pose that failed.
"""
from orisys_autocal.ui.pose_preview import PosePreview
width, height = 900, 640
preview = PosePreview()
try:
preview.set_bundle(bundle)
preview.resize(width, height)
points = preview.scene_points()
assert points.shape[0] > bundle.vertices_mm.shape[0]
for point in points:
screen = preview.project(point)
assert screen is not None, "a scene point projected behind the camera"
assert -1.0 <= screen[0] <= width + 1.0, f"x={screen[0]:.1f} outside 0..{width}"
assert -1.0 <= screen[1] <= height + 1.0, f"y={screen[1]:.1f} outside 0..{height}"
finally:
preview.deleteLater()
def test_the_resting_view_shows_the_solved_end_state(qt_app, bundle):
"""At rest the screen answers "where does it end up" without an animation.
This is a solve view: the operator picks a point and needs to see whether the
machine can reach it and where the table finishes. So the body, the contact and
the table marker all stand at the end of the move before anything is played, and
the fixed grid is the frame they started in.
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack(tool_xy_mm=(5.0, -2.0))
preview = PosePreview()
try:
preview.set_stack(stack)
preview.set_bundle(bundle)
preview.set_tool_xy(*stack.tool_xy_mm)
assert preview.motion_share() == (0.0, 0.0), "nothing is solved yet"
index = 40
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
preview.set_point(point, normal)
preview.set_pose(stack.solve(point, normal))
assert preview.motion_share() == (1.0, 1.0)
# The contact is drawn on the tool, at rest, with nothing playing.
tool = preview.tool_point() # noqa: SLF001
assert np.allclose(preview._placed_contact(point), tool, atol=1e-6) # noqa: SLF001
# The table marker stands at the end of the move, not on the grid.
centre = preview.table_marker_points().mean(axis=0)
moved = stack.mount @ np.asarray(centre, dtype=float)
assert moved[0] == pytest.approx(stack.solve(point, normal).table_xy_mm[0], abs=1e-6)
# And the start of the move is still drawn, as the faint ring.
start = preview.initial_marker_points()
assert np.allclose(start.mean(axis=0), [0.0, 0.0, 0.0], atol=1e-9)
assert not np.allclose(start, preview.table_marker_points(), atol=1e-6)
finally:
preview.deleteLater()
def test_playing_from_the_resting_state_moves_the_body(qt_app, bundle):
"""Play runs the move the resting picture already shows the end of."""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
preview = PosePreview()
try:
preview.set_bundle(bundle)
index = 40
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
preview.set_point(point, normal)
preview.set_pose(stack.solve(point, normal))
resting = preview._placed_contact(point) # noqa: SLF001
preview.start_simulation()
preview._sim_tick() # noqa: SLF001
early = preview._placed_contact(point) # noqa: SLF001
assert not np.allclose(early, resting, atol=1e-6), "playback did not start over"
for _ in range(200):
preview._sim_tick() # noqa: SLF001
assert np.allclose(preview._placed_contact(point), resting, atol=1e-6) # noqa: SLF001
finally:
preview.stop_simulation()
preview.deleteLater()
def test_the_landing_check_agrees_with_the_tool(qt_app, bundle):
"""The x-y claim, measured rather than judged off a picture.
``landing_xy_mm`` pushes the contact through the *drawing's* transform; the tool's
X/Y is a setup number. They are computed two different ways and must agree — this
is the check the bench asks for when it says "x and y are not aligning".
"""
from orisys_autocal.ui.pose_preview import PosePreview
setup = PlatformStack(
sensor_offset_mm=(6.0, -3.0, 5.0),
sensor_yaw_deg=12.0,
tool_xy_mm=(7.0, 4.0),
)
preview = PosePreview()
try:
preview.set_stack(setup)
preview.set_bundle(bundle)
preview.set_setup(setup.sensor_offset_mm, setup.sensor_yaw_deg)
preview.set_tool_xy(*setup.tool_xy_mm)
assert preview.landing_xy_mm() is None, "no pose, no landing"
worst = 0.0
for index in range(0, bundle.vertices_mm.shape[0], 13):
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
pose = setup.solve(point, normal)
preview.set_point(point, normal)
preview.set_pose(pose)
landing = preview.landing_xy_mm()
assert landing is not None
worst = max(worst, float(np.hypot(landing[0] - 7.0, landing[1] - 4.0)))
assert worst < 1e-9, f"the drawing lands {worst * 1000:.3f} um off the tool"
finally:
preview.deleteLater()
def test_the_landing_line_reports_the_check(panel):
"""And the panel prints both pairs, so the operator can see them agree."""
from orisys_app_shared.i18n import t
x, y = _first_surface_pixel(panel.view.flat)
panel.handle_pick(x, y)
text = panel.path_label.text()
assert "X/Y" in text
assert t("finger.landing_on_tool") in text
def test_the_landing_line_says_so_when_they_disagree(panel):
"""The branch that would matter: a bug shows up as an off-tool verdict."""
x, y = _first_surface_pixel(panel.view.flat)
panel.handle_pick(x, y)
honest = panel.preview.landing_xy_mm()
assert honest is not None
panel.preview.landing_xy_mm = lambda: (honest[0] + 0.25, honest[1]) # type: ignore[assignment]
panel._refresh_path() # noqa: SLF001
assert "0.250" in panel.path_label.text()
def test_the_framing_follows_the_planned_path(qt_app, bundle):
"""A long move has to fit in the picture, or the move leaves the frame.
The body at rest stands at the *end* of the move, so the far end of the picture
is the start of the path, not the body: the framing has to cover the path's own
points, which is what this checks. (It used to be enough to compare the radius
with and without a pose, because the body itself was at the far end.)
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
preview = PosePreview()
try:
preview.set_bundle(bundle)
preview.resize(900, 640)
index = int(np.argmax(np.linalg.norm(bundle.vertices_mm, axis=1)))
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
preview.set_point(point, normal)
preview.set_pose(stack.solve(point, normal))
radius = preview.scene_radius()
path = preview.trajectory()
assert path.shape[0] > 2
# The framing radius must cover the path's own points, not just the body.
reach = np.linalg.norm(path - preview._focus(), axis=1).max() # noqa: SLF001
assert radius >= reach - 1e-9
# Including where the move starts: the contact before the stages turned it.
start = np.linalg.norm(path[0] - preview._focus()) # noqa: SLF001
assert radius >= start - 1e-9
assert start > 0.0
finally:
preview.deleteLater()
# ------------------------------------ the animation and the drawn path agree
def test_the_drawn_path_ends_where_the_animation_puts_the_contact(qt_app, bundle):
"""Two descriptions of one move, so they cannot disagree.
The path was sampled up to ``SIM_ROTATION_SHARE`` of the solved rotation — the
*time* that phase takes, not how far it turns — so the drawn arc stopped 45%
short and the straight move began from a point the machine never passes through.
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
preview = PosePreview()
try:
preview.set_bundle(bundle)
index = 7
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
preview.set_point(point, normal)
preview.set_pose(stack.solve(point, normal))
path = preview.trajectory()
assert np.allclose(path[0], point, atol=1e-9), "the path must start at the contact"
_run_simulation(preview)
landed = preview._placed_contact(point) # noqa: SLF001
assert np.allclose(path[-1], landed, atol=1e-6)
finally:
preview.deleteLater()
def test_the_placement_covers_a_point_and_a_whole_vertex_array(qt_app, bundle):
"""``_placed`` is handed both, and left-multiplying only worked for one.
The camera framing walks the whole mesh through the simulation every frame.
``rotation @ vertices`` raised for an (N, 3) array, which — inside the paint
handler — blanked the view the moment playback started.
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
preview = PosePreview()
try:
preview.set_bundle(bundle)
index = 40
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
preview.set_point(point, normal)
preview.set_pose(stack.solve(point, normal))
_run_simulation(preview)
placed = preview._placed(bundle.vertices_mm) # noqa: SLF001
assert placed.shape == bundle.vertices_mm.shape
for index in (0, 5, 200, bundle.vertices_mm.shape[0] - 1):
single = preview._placed(bundle.vertices_mm[index]) # noqa: SLF001
assert np.allclose(placed[index], single)
finally:
preview.deleteLater()
def test_the_surface_keeps_its_residual_colours_while_the_machine_moves(qt_app, bundle):
"""The shading is the measurement, so it stays on through playback.
It used to switch to a flat "moving body" grey, which threw away the one thing
the animation exists to show: the red, hard-to-align patch travelling onto the
tool axis. The body itself *does* move — from the initial state when playback
starts to the solved state it rests at — and that is checked here too.
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
preview = PosePreview()
try:
preview.set_bundle(bundle)
index = 40
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
preview.set_point(point, normal)
preview.set_pose(stack.solve(point, normal))
rest = preview._scene_arrays()[1].copy() # noqa: SLF001
vertices_rest = preview._scene_arrays()[0].copy() # noqa: SLF001
# One frame in, the body is back at the *start* of the move — which is what
# makes playback a demonstration rather than a no-op, since it rests at the
# end of that same move.
preview.start_simulation()
preview._sim_tick() # noqa: SLF001
assert not np.allclose(preview._scene_arrays()[0], vertices_rest), ( # noqa: SLF001
"playback did not take the body back to the start of the move"
)
assert np.allclose(preview._scene_arrays()[1], rest), ( # noqa: SLF001
"the residual shading was replaced as soon as the motion started"
)
_run_simulation(preview)
moved = preview._scene_arrays()[1] # noqa: SLF001
vertices_moved = preview._scene_arrays()[0] # noqa: SLF001
assert np.allclose(rest, moved), "the residual shading was replaced mid-motion"
assert np.allclose(vertices_rest, vertices_moved, atol=1e-4), (
"the body did not come back to the solved state"
)
# And the shading still *is* the residual colouring: this bundle aligns
# everywhere, so the check is against the shading function rather than a
# hoped-for spread of colours.
from orisys_autocal.ui.pose_preview import residual_colour
assert np.allclose(moved[index], residual_colour(preview._residuals[index])) # noqa: SLF001
finally:
preview.deleteLater()
def test_reapplying_the_current_theme_does_not_repolish_all_windows(make_window, qt_app, monkeypatch):
window = make_window()
calls = []
apply_style = qt_app.setStyleSheet
def record_style(style):
calls.append(style)
apply_style(style)
monkeypatch.setattr(qt_app, "setStyleSheet", record_style)
window._apply_theme()
assert calls == []
window._theme = "dark" if window._theme == "light" else "light"
window._apply_theme()
assert len(calls) == 1