510 lines
18 KiB
Python
510 lines
18 KiB
Python
"""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,)
|