otko/tests/gui/test_canvas_grid_snap.py

259 lines
9.4 KiB
Python
Raw Normal View History

2026-09-08 02:12:15 -04:00
"""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,
2026-09-08 02:12:15 -04:00
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:
2026-09-08 02:12:15 -04:00
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
2026-09-08 02:12:15 -04:00
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
2026-09-08 02:12:15 -04:00
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(Project()) # default: Global system with no grid lines
2026-09-08 02:12:15 -04:00
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
2026-09-08 02:12:15 -04:00
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
2026-09-08 02:12:15 -04:00
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]),
),
2026-09-08 02:12:15 -04:00
),
],
)
)
2026-09-08 02:12:15 -04:00
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
2026-09-08 02:12:15 -04:00
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]),
),
2026-09-08 02:12:15 -04:00
),
],
)
)
2026-09-08 02:12:15 -04:00
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)
2026-09-08 02:12:15 -04:00
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
2026-09-08 02:12:15 -04:00
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
2026-09-08 02:12:15 -04:00
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)
2026-09-08 02:12:15 -04:00
d2 = _pt_to_segment_d2(p, a, b)
# Closest point is (10, 50), distance ≈ sqrt(40² + 150²) ≈ 155
assert d2[0] > 150.0**2
2026-09-08 02:12:15 -04:00
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
2026-09-08 02:12:15 -04:00
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