"""Tests for the export preview: geometry, camera, appearance and the GL smoke. The GL test is skipped when no real platform GL context can be created, copying Studio's ``test_scene3d_gl_smoke.py``: the offscreen/minimal plugin cannot create a platform GL context at all, so an offscreen run cannot stand in for a GPU. """ from __future__ import annotations import os from pathlib import Path import pytest np = pytest.importorskip("numpy") from orisys_sensor_design.manifest import ( DEFAULT_HOUSING_COLLECTIONS, RevisionPaths, build_export_job, export_housing_collections, repo_root, ) from orisys_sensor_design.preview import appearance from orisys_sensor_design.preview.camera import ( OrbitCamera, frustum_segments, pose_is_rigid, ) from orisys_sensor_design.preview.geometry import ( DEFAULT_CREASE_ANGLE_DEG, SMOOTH_CREASE_ANGLE_DEG, PreviewGeometryError, compute_normals, load_housing, load_parts, load_sensing, split_vertices, ) REPO = repo_root() STUDIO_BACKEND = ( REPO.parent / "orisys-app" / "apps" / "studio" / "src" / "orisys_studio" / "scene3d" / "gl_backend.py" ) XYNOVA_EXPORT = REPO / "build" / "export" / "finger-xynova" / "r01" / "compute" def _write_npz(path: Path, **arrays) -> Path: path.parent.mkdir(parents=True, exist_ok=True) np.savez_compressed(path, **arrays) return path def _housing_npz(path: Path) -> Path: vertices = np.array( [[0, 0, 0], [1, 0, 0], [0, 1, 0], [5, 5, 5], [6, 5, 5], [5, 6, 5]], dtype=np.float32, ) faces = np.array([[0, 1, 2], [3, 4, 5]], dtype=np.int32) return _write_npz( path, vertices=vertices, faces=faces, part_index=np.array([0, 1], dtype=np.int32), part_names=np.array(["frame_L", "nail"]), ) # ------------------------------------------------------------- geometry def test_compute_normals_of_a_flat_quad_points_along_plus_z() -> None: positions = np.array( [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=np.float32 ) triangles = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int32) normals = compute_normals(positions, triangles) assert normals.shape == positions.shape assert np.allclose(normals, np.array([0, 0, 1], dtype=np.float32), atol=1e-6) def test_compute_normals_falls_back_on_degenerate_faces() -> None: positions = np.zeros((3, 3), dtype=np.float32) triangles = np.array([[0, 1, 2]], dtype=np.int32) normals = compute_normals(positions, triangles) assert np.allclose(normals, np.array([0, 0, 1], dtype=np.float32)) def test_load_sensing_reads_the_payload_mesh(tmp_path: Path) -> None: _write_npz( tmp_path / "mesh.npz", vertices=np.zeros((3, 3), dtype=np.float32), faces=np.array([[0, 1, 2]], dtype=np.int32), ) part = load_sensing(tmp_path) assert part.role == "sensing" assert part.name == "sensing_surface" assert part.triangle_count == 1 def test_load_sensing_rejects_a_missing_file(tmp_path: Path) -> None: with pytest.raises(PreviewGeometryError, match="missing geometry file"): load_sensing(tmp_path) def test_load_housing_splits_parts_by_index(tmp_path: Path) -> None: _housing_npz(tmp_path / "housing.npz") parts = load_housing(tmp_path) assert [part.name for part in parts] == ["frame_L", "nail"] assert all(part.role == "housing" for part in parts) assert parts[0].vertex_count == 3 assert parts[1].triangle_count == 1 def test_load_housing_is_empty_when_absent(tmp_path: Path) -> None: assert load_housing(tmp_path) == [] def test_load_housing_is_empty_when_it_has_no_faces(tmp_path: Path) -> None: _write_npz( tmp_path / "housing.npz", vertices=np.zeros((0, 3), dtype=np.float32), faces=np.zeros((0, 3), dtype=np.int32), part_index=np.zeros((0,), dtype=np.int32), part_names=np.array([""], dtype=np.str_)[:0], ) assert load_housing(tmp_path) == [] def test_load_housing_rejects_a_part_index_length_mismatch(tmp_path: Path) -> None: _write_npz( tmp_path / "housing.npz", vertices=np.zeros((3, 3), dtype=np.float32), faces=np.array([[0, 1, 2]], dtype=np.int32), part_index=np.array([0, 1], dtype=np.int32), part_names=np.array(["a", "b"]), ) with pytest.raises(PreviewGeometryError, match="part_index"): load_housing(tmp_path) def test_load_parts_can_skip_housing(tmp_path: Path) -> None: _write_npz( tmp_path / "mesh.npz", vertices=np.zeros((3, 3), dtype=np.float32), faces=np.array([[0, 1, 2]], dtype=np.int32), ) _housing_npz(tmp_path / "housing.npz") assert len(load_parts(tmp_path)) == 3 assert len(load_parts(tmp_path, include_housing=False)) == 1 # --------------------------------------------------------------- camera def test_fit_frames_the_bounds_and_scales_with_the_frame() -> None: camera = OrbitCamera() low = np.array([-1.0, -1.0, -1.0]) high = np.array([1.0, 1.0, 1.0]) camera.fit(low, high, 1.0) assert np.allclose(camera.target, 0.0) radius = np.linalg.norm(high - low) * 0.5 assert camera.fitted_radius == pytest.approx(radius) # The distance must exceed the radius, and stay within its own ratios. assert radius * 0.05 <= camera.distance <= radius * 50.0 assert camera.distance > radius def test_fit_is_unit_agnostic_for_a_scaled_export() -> None: """The exported sensor frame is not metres, so framing must not assume it.""" camera = OrbitCamera() camera.fit(np.array([-574.0, -576.0, -299.0]), np.array([574.0, 556.0, 1691.0]), 1.6) assert camera.distance > 1000.0 # would be clamped to 5 m by Studio's limits def test_zoom_is_clamped_relative_to_the_fitted_radius() -> None: camera = OrbitCamera() camera.fit(np.array([-1.0, -1.0, -1.0]), np.array([1.0, 1.0, 1.0]), 1.0) for _ in range(200): camera.zoom(1.0) assert camera.distance >= camera.fitted_radius * 0.05 for _ in range(400): camera.zoom(-1.0) assert camera.distance <= camera.fitted_radius * 50.0 def test_orbit_clamps_pitch() -> None: camera = OrbitCamera() camera.orbit(0.0, -100000.0) assert abs(camera.pitch) <= 1.5534 + 1e-3 def test_view_and_projection_are_well_formed() -> None: camera = OrbitCamera() camera.fit(np.array([-1.0, -1.0, -1.0]), np.array([1.0, 1.0, 1.0]), 1.0) view = camera.view() projection = camera.projection() assert view.shape == (4, 4) and projection.shape == (4, 4) # An orthonormal view: rotation rows are unit length. assert np.allclose(np.linalg.norm(view[:3, :3], axis=1), 1.0, atol=1e-9) # The target maps onto the camera's -Z axis. eye = camera.eye() target_in_camera = view @ np.array([*camera.target, 1.0]) assert target_in_camera[2] == pytest.approx(-camera.distance, rel=1e-6) assert eye.shape == (3,) def test_pose_is_rigid_detects_a_scaled_pose() -> None: rigid = np.eye(4) assert pose_is_rigid(rigid) is True scaled = np.diag([0.003937, 0.003937, 0.003937, 1.0]) assert pose_is_rigid(scaled) is False assert pose_is_rigid(np.zeros((3, 3))) is False def test_frustum_segments_apex_is_the_camera_position() -> None: K = np.array([[100.0, 0.0, 50.0], [0.0, 100.0, 50.0], [0.0, 0.0, 1.0]]) world_to_cam = np.eye(4) world_to_cam[2, 3] = 2.0 # camera sits 2 units in front of the origin segments = frustum_segments(K, world_to_cam, image_size=(100, 100)) assert segments.shape == (8, 2, 3) apex = segments[0, 0] assert np.allclose(apex, [0.0, 0.0, -2.0], atol=1e-9) def test_frustum_segments_rejects_a_bad_shape() -> None: with pytest.raises(ValueError, match="world_to_cam"): frustum_segments(np.eye(3), np.eye(3)) with pytest.raises(ValueError, match="K must be"): frustum_segments(np.eye(2), np.eye(4)) # ------------------------------------------------------------- appearance def test_shader_sources_are_the_core_profile() -> None: vertex, fragment = appearance.shader_source("diffuse") assert vertex.startswith(appearance.SHADER_HEADER) assert fragment.startswith(appearance.SHADER_HEADER) assert "u_light_direction" in fragment line_vertex, line_fragment = appearance.shader_source("line") assert "u_color" in line_fragment with pytest.raises(ValueError, match="unknown shader"): appearance.shader_source("nope") def test_base_color_matches_studio_for_housing() -> None: assert appearance.base_color("housing") == appearance.HOUSING_COLOR assert appearance.base_color("anything") == appearance.PART_FALLBACK_COLOR @pytest.mark.skipif( not STUDIO_BACKEND.is_file(), reason="orisys-app checkout is not present" ) def test_appearance_mirror_matches_studio() -> None: """Drift guard: the mirror must not silently diverge from Studio's backend.""" source = STUDIO_BACKEND.read_text(encoding="utf-8") assert f"glClearColor({appearance.CLEAR_COLOR[0]}, {appearance.CLEAR_COLOR[1]}, " in source assert "(0.3, -0.5, 0.8)" in source assert "0.35 + 0.65 * lambert" in source assert "0.32, 0.33, 0.35, 1.0" in source assert "0.45, 0.45, 0.47, 1.0" in source for fragment_line in ( "vec3 n = normalize(v_normal);", "if (!gl_FrontFacing) {", "n = -n;", ): assert fragment_line in source, fragment_line assert fragment_line in appearance.DIFFUSE_FRAGMENT_BODY, fragment_line # ------------------------------------------------- export contract guards def test_export_job_requests_housing_from_the_assembly_collection() -> None: paths = RevisionPaths.from_revision_dir(REPO / "sensors" / "finger-xynova" / "r01") job = build_export_job(paths, Path("out"), "compute") assert job["schema"] == "orisys-sensor-design/export-job-v2" assert job["housing_collections"] == export_housing_collections(paths) assert job["housing_collections"] == ["ASSEMBLY"] def test_housing_collections_default_when_design_yaml_is_silent(tmp_path: Path) -> None: revision = tmp_path / "sensors" / "finger" / "r99" (revision / "profiles").mkdir(parents=True) (revision / "design.yaml").write_text("sensor: finger\nrevision: r99\n", encoding="utf-8") paths = RevisionPaths.from_revision_dir(revision) assert export_housing_collections(paths) == list(DEFAULT_HOUSING_COLLECTIONS) @pytest.mark.skipif( not (XYNOVA_EXPORT / "housing.npz").is_file(), reason="run orisys-design export first", ) def test_exported_housing_keeps_mesh_npz_sensing_only() -> None: """The SDK unroll reads mesh.npz vertices as the sensing surface.""" with np.load(XYNOVA_EXPORT / "mesh.npz", allow_pickle=False) as mesh: assert set(mesh.files) == {"vertices", "faces", "uv_data", "loop_to_vertex"} assert mesh["vertices"].shape[0] == 2508 # SMOOTH reads the artifact as stored: 21426 is what housing.npz contains, and # crease splitting only ever enlarges the preview's in-memory copy. parts = load_housing(XYNOVA_EXPORT, crease_angle_deg=SMOOTH_CREASE_ANGLE_DEG) assert [part.name for part in parts] == ["frame_L", "nail", "frame_R"] assert sum(part.vertex_count for part in parts) == 21426 # ------------------------------------------------------------- GL smoke pytestmark_gl = pytest.mark.skipif( os.environ.get("QT_QPA_PLATFORM", "").lower() in ("offscreen", "minimal"), reason="the offscreen/minimal plugin cannot create a platform GL context", ) @pytest.mark.skipif( os.environ.get("QT_QPA_PLATFORM", "").lower() in ("offscreen", "minimal"), reason="the offscreen/minimal plugin cannot create a platform GL context", ) @pytest.mark.skipif( not (XYNOVA_EXPORT / "mesh.npz").is_file(), reason="export not built" ) def test_preview_draws_the_mesh_and_the_housing_toggle_changes_it() -> None: pytest.importorskip("PyQt5") from PyQt5.QtWidgets import QApplication from orisys_sensor_design.preview.gl_view import ( PreviewGLWidget, configure_surface_format, ) from orisys_sensor_design.preview.window import prepare state = prepare(XYNOVA_EXPORT) configure_surface_format() app = QApplication.instance() or QApplication([]) widget = PreviewGLWidget( state["parts"], state["camera"], frustum=state["frustum"] ) widget.show_frustum = False widget.resize(480, 320) widget.show() app.processEvents() background = np.array([15, 18, 23]) def drawn() -> int: widget.repaint() app.processEvents() frame = widget.grab_rgb() return int( np.count_nonzero(np.any(frame[:, :, :3] != background, axis=2)) ) widget.show_housing = True with_housing = drawn() widget.show_housing = False without_housing = drawn() assert widget.gl_ok, widget.gl_error assert with_housing > 0, "nothing was drawn at all" assert 0 < without_housing < with_housing, ( "hiding the housing did not reduce what is on screen " f"({without_housing} vs {with_housing})" ) # --------------------------------------------------------- crease normals def _cube() -> tuple[np.ndarray, np.ndarray]: positions = np.array( [ [0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1], ], dtype=np.float32, ) triangles = np.array( [ [0, 1, 2], [0, 2, 3], [4, 6, 5], [4, 7, 6], [0, 4, 5], [0, 5, 1], [1, 5, 6], [1, 6, 2], [2, 6, 7], [2, 7, 3], [3, 7, 4], [3, 4, 0], ], dtype=np.int32, ) return positions, triangles def _two_faces(angle_deg: float) -> tuple[np.ndarray, np.ndarray]: """Two triangles sharing an edge, tilted from each other by ``angle_deg``.""" radians = np.radians(angle_deg) positions = np.array( [ [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0 + np.cos(radians), 0.0, np.sin(radians)], ], dtype=np.float32, ) triangles = np.array([[0, 1, 2], [1, 3, 2]], dtype=np.int32) return positions, triangles def test_split_vertices_makes_a_cube_corner_three_clusters() -> None: positions, triangles = _cube() split_positions, normals, _ = split_vertices(positions, triangles, 35.0) assert len(split_positions) == 24 # 8 corners x 3 axis-aligned faces axes = {tuple(np.round(row, 6)) for row in normals} assert axes == { (1.0, 0.0, 0.0), (-1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, -1.0, 0.0), (0.0, 0.0, 1.0), (0.0, 0.0, -1.0), } def test_split_vertices_smooth_sentinel_keeps_every_vertex() -> None: positions, triangles = _cube() split_positions, normals, split_triangles = split_vertices( positions, triangles, SMOOTH_CREASE_ANGLE_DEG ) assert len(split_positions) == 8 assert np.allclose(split_triangles, triangles) assert np.allclose(normals, compute_normals(positions, triangles)) def test_split_vertices_respects_the_threshold() -> None: shallow_positions, shallow_triangles = _two_faces(20.0) _, _, shallow = split_vertices(shallow_positions, shallow_triangles, 35.0) # The shared edge vertices keep one normal each, so the quad stays 4 vertices. assert len(shallow) == 2 assert len(set(map(tuple, shallow.reshape(-1, 3)))) <= 4 steep_positions, steep_triangles = _two_faces(40.0) steep_positions_out, _, _ = split_vertices( steep_positions, steep_triangles, 35.0 ) assert len(steep_positions_out) > len(steep_positions) def test_split_vertices_leaves_a_flat_quad_flat() -> None: positions = np.array( [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=np.float32 ) triangles = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int32) split_positions, normals, _ = split_vertices(positions, triangles, 35.0) assert len(split_positions) == 4 assert np.allclose(normals, np.array([0, 0, 1], dtype=np.float32), atol=1e-6) def test_split_vertices_handles_an_empty_mesh() -> None: positions = np.zeros((0, 3), dtype=np.float32) triangles = np.zeros((0, 3), dtype=np.int32) split_positions, normals, split_triangles = split_vertices( positions, triangles, 35.0 ) assert len(split_positions) == 0 and len(split_triangles) == 0 @pytest.mark.skipif( not (XYNOVA_EXPORT / "mesh.npz").is_file(), reason="export not built" ) def test_default_crease_leaves_the_smooth_sensing_surface_untouched() -> None: """The sensing surface has 0% sharp edges, so it must not be re-vertexed.""" smooth = load_sensing(XYNOVA_EXPORT, crease_angle_deg=SMOOTH_CREASE_ANGLE_DEG) creased = load_sensing(XYNOVA_EXPORT, crease_angle_deg=DEFAULT_CREASE_ANGLE_DEG) assert creased.vertex_count == smooth.vertex_count assert np.allclose(creased.normals, smooth.normals, atol=1e-6) @pytest.mark.skipif( not (XYNOVA_EXPORT / "housing.npz").is_file(), reason="export not built" ) def test_default_crease_splits_the_sharp_housing_edges() -> None: smooth = load_housing(XYNOVA_EXPORT, crease_angle_deg=SMOOTH_CREASE_ANGLE_DEG) creased = load_housing(XYNOVA_EXPORT, crease_angle_deg=DEFAULT_CREASE_ANGLE_DEG) assert [p.name for p in smooth] == [p.name for p in creased] for before, after in zip(smooth, creased): assert after.triangle_count == before.triangle_count assert after.vertex_count > before.vertex_count, before.name # ------------------------------------------------------------ depth range def test_projection_brackets_the_fitted_model() -> None: camera = OrbitCamera() low = np.array([-574.0, -576.0, -299.0]) high = np.array([574.0, 556.0, 1691.0]) camera.fit(low, high, 1.6) view = camera.view() projection = camera.projection() eye = camera.eye() for corner in ( np.array([x, y, z, 1.0]) for x in (low[0], high[0]) for y in (low[1], high[1]) for z in (low[2], high[2]) ): clip = projection @ view @ corner ndc_z = clip[2] / clip[3] assert -1.0 < ndc_z < 1.0, ndc_z # The whole depth buffer is spent on the model, not a 1% slice of it. depth = np.linalg.norm(view[:3, :3] @ (high - low)) assert depth > 0 and eye.shape == (3,)