added mesh export preview

This commit is contained in:
ykcheungab
2026-09-20 13:46:58 +08:00
parent 39caecbfda
commit f9662df08c
16 changed files with 2025 additions and 11 deletions
+509
View File
@@ -0,0 +1,509 @@
"""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,)