otko/tests/gui/test_canvas_grid_snap.py
smillmorel 61b850bf46
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / type (pull_request) Has been cancelled
CI / test-headless (macos-latest, 3.10) (pull_request) Has been cancelled
CI / test-headless (macos-latest, 3.11) (pull_request) Has been cancelled
CI / test-headless (macos-latest, 3.12) (pull_request) Has been cancelled
CI / test-headless (ubuntu-latest, 3.10) (pull_request) Has been cancelled
CI / test-headless (ubuntu-latest, 3.11) (pull_request) Has been cancelled
CI / test-headless (ubuntu-latest, 3.12) (pull_request) Has been cancelled
CI / test-headless (windows-latest, 3.10) (pull_request) Has been cancelled
CI / test-headless (windows-latest, 3.11) (pull_request) Has been cancelled
CI / test-headless (windows-latest, 3.12) (pull_request) Has been cancelled
CI / test-gui (pull_request) Has been cancelled
CI / test-integration (macos-latest) (pull_request) Has been cancelled
CI / test-integration (ubuntu-latest) (pull_request) Has been cancelled
CI / test-integration (windows-latest) (pull_request) Has been cancelled
style: resolve the remaining ruff findings
Auto-fixes (UP037, UP038, F401, I001, SIM105, RUF100) plus hand-fixes:
drop the now-unused `Union` imports, annotate mutable class attributes
with ClassVar, raise ValidationError instead of blind Exception in the
zero-length test, delete two dead locals, and bind the per-iteration
grid-builder closures to their loop variables (B023).
2026-09-16 13:20:36 -04:00

259 lines
9.4 KiB
Python
Raw Permalink 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.

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