"""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