"""Tests for the canvas-side pixel-space grid snap. We verify the snap LOGIC by stubbing the world→screen projection so the tests don't need a live VTK renderer. The real canvas composes these pieces during a click. """ from __future__ import annotations import numpy as np import pytest pytest.importorskip("PySide6") from otko.core import ( # noqa: E402 CoordinateGridSystem, CoordinateSystem, GridSystem, Project, make_grid_lines, ) # ────────────────────── logic helpers (no VTK) ────────────────────── def _nearest_snap( cx: float, cy: float, world_pts: np.ndarray, screen_pts: np.ndarray, tol_px: float, ) -> tuple[float, float, float] | None: """Mimics ModelCanvas._nearest_grid_intersection_px with pre-projected data.""" if len(world_pts) == 0: return None d2 = (screen_pts[:, 0] - cx) ** 2 + (screen_pts[:, 1] - cy) ** 2 idx = int(np.argmin(d2)) if d2[idx] <= tol_px**2: return tuple(float(v) for v in world_pts[idx]) # type: ignore[return-value] return None def test_snap_commits_when_click_is_within_tolerance() -> None: world = np.array([[0, 0, 0], [3, 0, 0], [3, 4, 0]], dtype=float) # Project as if they mapped to these screen pixels. screen = np.array([[100, 100], [300, 100], [300, 250]], dtype=float) # Click 10 pixels away from intersection #1 (at 300, 100). snapped = _nearest_snap(306, 108, world, screen, tol_px=15.0) assert snapped == (3.0, 0.0, 0.0) def test_snap_rejects_when_click_is_beyond_tolerance() -> None: world = np.array([[0, 0, 0], [3, 0, 0]], dtype=float) screen = np.array([[100, 100], [300, 100]], dtype=float) # Click dead centre between the two pixels (200, 100) — 100 px away # from each, well beyond tol=15 px. snapped = _nearest_snap(200, 100, world, screen, tol_px=15.0) assert snapped is None def test_snap_rejects_on_empty_grid() -> None: assert _nearest_snap(100, 100, np.empty((0, 3)), np.empty((0, 2)), 15.0) is None # ────────────────── project ↔ intersections plumbing ───────────────── def test_grid_intersections_world_includes_all_visible_systems(qtbot) -> None: # type: ignore[no-untyped-def] """ModelCanvas._grid_intersections_world combines every visible system's intersections (transformed by that system's origin/rotation).""" from otko.views.canvas3d.model_canvas import ModelCanvas canvas = ModelCanvas() qtbot.addWidget(canvas) p = Project( coord_systems=[ CoordinateGridSystem( name="Global", grid=GridSystem( x_grid_lines=make_grid_lines("X", [0.0, 3.0]), y_grid_lines=make_grid_lines("Y", [0.0]), z_grid_lines=make_grid_lines("Z", [0.0]), ), ), CoordinateGridSystem( name="Floor2", coord=CoordinateSystem(origin=(0, 0, 3)), grid=GridSystem( x_grid_lines=make_grid_lines("X", [0.0, 3.0]), y_grid_lines=make_grid_lines("Y", [0.0]), z_grid_lines=make_grid_lines("Z", [0.0]), ), ), ], ) canvas.show_project(p) pts = canvas._grid_intersections_world() assert pts is not None # 2 X × 1 Y × 1 Z = 2 from each system; 4 total. assert pts.shape == (4, 3) # Floor2 intersections are at z=3. zs = sorted(set(float(z) for z in pts[:, 2])) assert zs == [0.0, 3.0] def test_grid_intersections_world_returns_none_without_grid(qtbot) -> None: # type: ignore[no-untyped-def] from otko.views.canvas3d.model_canvas import ModelCanvas canvas = ModelCanvas() qtbot.addWidget(canvas) canvas.show_project(Project()) # default: Global system with no grid lines assert canvas._grid_intersections_world() is None def test_hide_all_suppresses_intersections(qtbot) -> None: # type: ignore[no-untyped-def] """A system with ``hide_all=True`` must not contribute snap targets.""" from otko.views.canvas3d.model_canvas import ModelCanvas canvas = ModelCanvas() qtbot.addWidget(canvas) p = Project( coord_systems=[ CoordinateGridSystem( name="Global", grid=GridSystem( x_grid_lines=make_grid_lines("X", [0.0, 3.0]), y_grid_lines=make_grid_lines("Y", [0.0]), z_grid_lines=make_grid_lines("Z", [0.0]), hide_all=True, ), ), ], ) canvas.show_project(p) assert canvas._grid_intersections_world() is None # ──────────────────────── hover snap + radius sizing ────────────────────── def test_hover_snap_marker_round_trips(qtbot) -> None: # type: ignore[no-untyped-def] """set_hover_snap(pt) creates an actor; passing None removes it.""" from otko.views.canvas3d.model_canvas import ModelCanvas canvas = ModelCanvas() qtbot.addWidget(canvas) canvas.show_project( Project( coord_systems=[ CoordinateGridSystem( name="Global", grid=GridSystem( x_grid_lines=make_grid_lines("X", [0.0, 3.0]), y_grid_lines=make_grid_lines("Y", [0.0, 4.0]), z_grid_lines=make_grid_lines("Z", [0.0]), ), ), ], ) ) r = canvas._renderer assert r._hover_actor is None r.set_hover_snap((3.0, 0.0, 0.0)) assert r._hover_actor is not None r.set_hover_snap(None) assert r._hover_actor is None def test_snap_preview_flag_clears_marker(qtbot) -> None: # type: ignore[no-untyped-def] from otko.views.canvas3d.model_canvas import ModelCanvas canvas = ModelCanvas() qtbot.addWidget(canvas) canvas.show_project( Project( coord_systems=[ CoordinateGridSystem( name="Global", grid=GridSystem( x_grid_lines=make_grid_lines("X", [0.0]), y_grid_lines=make_grid_lines("Y", [0.0]), z_grid_lines=make_grid_lines("Z", [0.0]), ), ), ], ) ) canvas.set_snap_preview_enabled(True) canvas._renderer.set_hover_snap((0.0, 0.0, 0.0)) assert canvas._renderer._hover_actor is not None canvas.set_snap_preview_enabled(False) assert canvas._renderer._hover_actor is None # ──────────────────────── frame picking (point-to-segment) ──────────────── def _pt_to_segment_d2(p: np.ndarray, a: np.ndarray, b: np.ndarray) -> np.ndarray: """Vectorised point-to-segment squared distance, used to verify logic.""" ab = b - a ab_sq = (ab**2).sum(axis=1) ab_sq = np.where(ab_sq == 0, 1.0, ab_sq) pa = p - a t = (pa * ab).sum(axis=1) / ab_sq t = np.clip(t, 0.0, 1.0) closest = a + t[:, None] * ab return ((p - closest) ** 2).sum(axis=1) def test_point_to_segment_midpoint_hit() -> None: """A click at the midpoint of a long frame must match — the old midpoint- only test already passed; sanity check before end-hit tests.""" p = np.array([50.0, 50.0]) a = np.array([[0.0, 50.0]]) b = np.array([[100.0, 50.0]]) d2 = _pt_to_segment_d2(p, a, b) assert d2[0] == pytest.approx(0.0) def test_point_to_segment_endpoint_hit() -> None: """A click near an endpoint must also hit — the bug fix this test guards.""" p = np.array([1.0, 51.0]) # 1 px off node-a on a horizontal frame a = np.array([[0.0, 50.0]]) b = np.array([[100.0, 50.0]]) d2 = _pt_to_segment_d2(p, a, b) assert d2[0] <= 2.0**2 # well inside a 5-pixel test tolerance def test_point_to_segment_orthogonal_miss() -> None: """Clicking far from a short frame must produce a large distance.""" p = np.array([50.0, 200.0]) a = np.array([[0.0, 50.0]]) b = np.array([[10.0, 50.0]]) # short frame at (0..10, 50) d2 = _pt_to_segment_d2(p, a, b) # Closest point is (10, 50), distance ≈ sqrt(40² + 150²) ≈ 155 assert d2[0] > 150.0**2 def test_single_node_radius_uses_grid_extent(qtbot) -> None: # type: ignore[no-untyped-def] """With a single node placed, the sphere radius must scale with the grid bounds so the node remains visible (regression guard).""" from otko.core import Node from otko.views.canvas3d.model_canvas import ModelCanvas canvas = ModelCanvas() qtbot.addWidget(canvas) p = Project( nodes=[Node(id=1, coords=(0.0, 0.0, 0.0))], coord_systems=[ CoordinateGridSystem( name="Global", grid=GridSystem( x_grid_lines=make_grid_lines("X", [0.0, 10.0]), y_grid_lines=make_grid_lines("Y", [0.0, 10.0]), z_grid_lines=make_grid_lines("Z", [0.0]), ), ), ], ) canvas.show_project(p) r = canvas._renderer._scene_node_radius() # Grid diagonal = sqrt(10² + 10²) ≈ 14.14. Radius ≈ 0.008 · 14.14 ≈ 0.113. # Without the grid fix we'd get the 0.05 floor. Assert it's *above* the floor. assert r > 0.05