Files
orisys-app/shared/tests/test_scene3d_assets.py
2026-09-27 19:20:43 +08:00

940 lines
38 KiB
Python

"""Stage 2 acceptance: asset contracts, coordinates, units, identity.
Two kinds of test live here. The ones that need the real Blender export are skipped
when the SDK assets are not on this machine; the ones that build their own data run
everywhere. That split is deliberate — the real assets are the *input sample* the
stage owes, and a synthetic one can only prove the code agrees with itself.
"""
from __future__ import annotations
import json
import shutil
import struct
import zipfile
from pathlib import Path
import numpy as np
import pytest
from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import (
Material,
MeshPart,
SceneAssets,
ThreeDError,
freeze,
validate_assets,
)
from orisys_app_shared.scene3d.digest import (
asset_hash,
binding_id,
content_digest,
field_domain_id,
geometry_id,
)
from orisys_app_shared.scene3d.glb import (
HousingLimits,
load_housing_glb,
read_glb,
with_housing,
)
from orisys_app_shared.scene3d.legacy import (
LegacyFingerImporter,
build_binding,
crease_aware_mesh,
estimate_meters_per_source_unit,
load_legacy_domain,
normals_disagree_with_faces,
split_at_crease_edges,
split_at_uv_seams,
uv_correspondence_residual,
validate_rigid_transform,
vertex_normals,
)
#: The shipped export, when this checkout has it.
_ASSETS = Path(r"D:\orisys_ws\orisys-sdk\sensors\finger\assets")
RUN_DIR = _ASSETS / "30fps-0703"
RUN_MAP = RUN_DIR / "unroll_map_uv_s0.4.npz"
VIZ_DIR = _ASSETS / "visualization"
VIZ_MAP = VIZ_DIR / "flatten_remap.npz"
requires_run = pytest.mark.skipif(
not (RUN_DIR / "mesh.npz").is_file() or not RUN_MAP.is_file(),
reason="the shipped 30fps-0703 export is not present",
)
requires_viz = pytest.mark.skipif(
not (VIZ_DIR / "mesh.npz").is_file() or not VIZ_MAP.is_file(),
reason="the shipped visualization export is not present",
)
def _identity() -> np.ndarray:
return np.eye(4)
def _import_run(**kwargs) -> SceneAssets:
export_dir = kwargs.pop("export_dir", RUN_DIR)
map_path = kwargs.pop("map_path", RUN_MAP)
return LegacyFingerImporter().convert(
export_dir,
map_path,
transform_sensor_from_source=kwargs.pop("transform", _identity()),
meters_per_source_unit=kwargs.pop("units", 1e-3),
**kwargs,
)
# --------------------------------------------------------- input sample (real)
@requires_run
def test_the_real_export_imports_and_validates():
assets = _import_run()
part = assets.sensing_part()
assert part.positions_sensor_m.shape == (1032, 3)
assert part.triangles.shape == (1974, 3)
assert assets.shape_hw == (300, 280)
assert validate_assets(assets) == ()
# 15.8 source units at 1 mm per unit is a 15.8 mm sensing surface.
extent = np.ptp(np.asarray(part.positions_sensor_m), axis=0)
assert np.allclose(np.sort(extent), [0.0075, 0.015, 0.01581], atol=1e-5), extent
assert assets.source_vertex_of_render is not None
assert assets.source_vertex_of_render.shape == (1032,)
def _write_housing(path: Path) -> Path:
"""A tiny tetrahedron standing in for a mounting frame."""
vertices = np.array(
[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 10.0]],
dtype=np.float32,
)
faces = np.array([[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]], dtype=np.int32)
np.savez(path, vertices=vertices, faces=faces)
return path
@requires_run
def test_a_housing_becomes_its_own_part_without_touching_the_geometry_ids(tmp_path: Path):
"""The frame is display-only: it must not enter the identities the device verifies."""
plain = _import_run()
housing_path = _write_housing(tmp_path / "housing.npz")
with_housing = _import_run(housing_path=housing_path)
assert [part.part_id for part in plain.parts] == ["sensing_surface"]
assert [(part.part_id, part.role) for part in with_housing.parts] == [
("sensing_surface", "sensing"),
("housing", "housing"),
]
assert with_housing.geometry_id == plain.geometry_id, (
"adding a housing changed the sensing geometry id, which the device verifies"
)
assert with_housing.source_geometry_id == plain.source_geometry_id
assert with_housing.asset_hash != plain.asset_hash, (
"the package content changed, so the asset hash has to say so"
)
assert validate_assets(with_housing) == ()
# Units and the rigid transform are applied to the housing exactly as to the surface.
frame = with_housing.part("housing")
assert frame is not None
assert np.allclose(np.ptp(np.asarray(frame.positions_sensor_m), axis=0), 0.01, atol=1e-6)
@requires_run
def test_a_housing_in_the_export_directory_is_used_automatically(tmp_path: Path, monkeypatch):
"""``<legacy-dir>/housing.npz`` is picked up, so a re-export needs no new flag."""
export = tmp_path / "export"
export.mkdir()
shutil.copy(RUN_DIR / "mesh.npz", export / "mesh.npz")
assert len(_import_run(export_dir=export).parts) == 1
_write_housing(export / "housing.npz")
assert [part.role for part in _import_run(export_dir=export).parts] == [
"sensing",
"housing",
]
@requires_run
def test_a_requested_housing_that_is_missing_is_refused(tmp_path: Path):
"""A missing frame must not look like a package that simply has none."""
with pytest.raises(ThreeDError) as caught:
_import_run(housing_path=tmp_path / "not-there.npz")
assert caught.value.diagnostic.code == "geometry_mismatch"
@requires_run
def test_a_housing_with_an_out_of_range_triangle_is_refused(tmp_path: Path):
path = tmp_path / "housing.npz"
np.savez(
path,
vertices=np.zeros((3, 3), dtype=np.float32),
faces=np.array([[0, 1, 9]], dtype=np.int32),
)
with pytest.raises(ThreeDError) as caught:
_import_run(housing_path=path)
assert caught.value.diagnostic.code == "geometry_mismatch"
@requires_run
def test_the_uv_convention_is_measured_against_the_asset():
"""The one fact the importer must not guess: which pixel a UV addresses."""
domain = load_legacy_domain(RUN_MAP)
with np.load(RUN_DIR / "mesh.npz", allow_pickle=False) as handle:
positions, uvs, _, _ = split_at_uv_seams(
handle["vertices"], handle["faces"], handle["uv_data"], handle["loop_to_vertex"]
)
residual, samples = uv_correspondence_residual(positions, uvs, domain)
assert domain.flip_v is True
assert samples > 500
assert residual < 0.01, f"the UV convention no longer matches world_xyz ({residual})"
# ...and the wrong flip really is wrong: the check discriminates.
flipped = type(domain)(
shape_hw=domain.shape_hw,
flip_v=False,
source_scale=domain.source_scale,
valid_mask=domain.valid_mask,
domain_id=domain.domain_id,
mapping_digest=domain.mapping_digest,
world_xyz=domain.world_xyz,
)
wrong, _ = uv_correspondence_residual(positions, uvs, flipped)
assert wrong > 0.2, f"a wrong v flip scored {wrong}: the check is not discriminating"
@requires_run
@requires_viz
def test_the_two_asset_directories_are_different_scenes():
"""1032/1974 on a 300x280 domain versus 1005/1921 on a 1024x1024 domain."""
run = _import_run()
viz = LegacyFingerImporter().convert(
VIZ_DIR, VIZ_MAP, transform_sensor_from_source=_identity(), meters_per_source_unit=1e-3
)
assert run.sensing_part().positions_sensor_m.shape[0] == 1032
assert viz.sensing_part().positions_sensor_m.shape[0] == 1005
assert run.geometry_id != viz.geometry_id
assert run.field_domain_id != viz.field_domain_id
assert run.shape_hw != viz.shape_hw
@requires_run
@requires_viz
def test_mixing_the_two_meshes_is_refused():
run = _import_run()
viz = LegacyFingerImporter().convert(
VIZ_DIR, VIZ_MAP, transform_sensor_from_source=_identity(), meters_per_source_unit=1e-3
)
mixed = SceneAssets(
asset_id="mixed",
asset_version="0",
asset_hash="x",
geometry_id=run.geometry_id,
parts=viz.parts, # 1005 vertices
binding=run.binding, # 1032 rows
camera_presets=run.camera_presets,
bounds_sensor_m=viz.bounds_sensor_m,
field_domain_id=run.field_domain_id,
shape_hw=run.shape_hw,
)
with pytest.raises(ThreeDError) as excinfo:
validate_assets(mixed)
assert excinfo.value.diagnostic.code == "geometry_mismatch"
assert "different mesh" in excinfo.value.diagnostic.message
# ------------------------------------------------------------- seam splitting
def test_a_uv_seam_splits_a_vertex_without_averaging_its_uv():
"""One geometry vertex with two UVs becomes two render vertices; a shared one stays."""
vertices = np.array(
[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]]
)
faces = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int64)
# Vertex 0 is used with (0,0) in the first triangle and (0.5,0.5) in the second
# (a seam); vertex 2 keeps one UV across both (shared, not duplicated).
loop_to_vertex = np.array([0, 1, 2, 0, 2, 3], dtype=np.int64)
uv_data = np.array(
[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [0.5, 0.5], [0.0, 1.0], [0.2, 0.2]]
)
positions, uvs, triangles, source = split_at_uv_seams(
vertices, faces, uv_data, loop_to_vertex
)
assert source.tolist() == [0, 1, 2, 0, 3], source.tolist()
assert len(positions) == 5, "the seam vertex was not duplicated exactly once"
seam = [index for index, item in enumerate(source.tolist()) if item == 0]
assert len(seam) == 2 and not np.allclose(uvs[seam[0]], uvs[seam[1]]), (
"the two sides of the seam must keep their own UVs, never an average"
)
shared = [index for index, item in enumerate(source.tolist()) if item == 2]
assert len(shared) == 1, "a vertex with one UV must not be duplicated"
assert triangles.shape == (2, 3)
assert set(triangles[0].tolist()) & set(triangles[1].tolist()) == {shared[0]}, (
"the two triangles must share the unsplit vertex"
)
def test_identical_uvs_do_not_duplicate_a_vertex():
vertices = np.zeros((3, 3))
faces = np.array([[0, 1, 2]])
loop = np.array([0, 1, 2])
uv = np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]])
positions, uvs, _, source = split_at_uv_seams(vertices, faces, uv, loop)
assert len(positions) == 3 and source.tolist() == [0, 1, 2]
def test_a_mesh_whose_corner_lists_do_not_match_is_refused():
vertices = np.zeros((3, 3))
faces = np.array([[0, 1, 2]])
with pytest.raises(ThreeDError) as excinfo:
split_at_uv_seams(vertices, faces, np.zeros((2, 2)), np.array([0, 1, 2]))
assert "per-corner UVs" in excinfo.value.diagnostic.message
def test_an_out_of_range_face_index_is_refused():
vertices = np.zeros((3, 3))
faces = np.array([[0, 1, 9]])
uv = np.zeros((3, 2))
loop = np.array([0, 1, 2])
with pytest.raises(ThreeDError) as excinfo:
split_at_uv_seams(vertices, faces, uv, loop)
assert "outside the vertex array" in excinfo.value.diagnostic.message
def test_vertex_normals_are_unit_and_area_weighted():
# Two triangles sharing an edge, the second twice as large.
positions = np.array(
[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 2.0, 0.0]]
)
triangles = np.array([[0, 1, 2], [0, 2, 3]])
normals = vertex_normals(positions, triangles)
assert np.allclose(np.linalg.norm(normals, axis=1), 1.0)
assert np.allclose(normals[:, 2], 1.0), "a flat mesh must have +Z normals"
# A vertex used by no triangle gets a finite fallback rather than NaN.
lonely = vertex_normals(np.vstack([positions, [[5.0, 5.0, 5.0]]]), triangles)
assert np.isfinite(lonely[4]).all()
def _box(half: float = 1.0) -> tuple[np.ndarray, np.ndarray]:
"""A closed cube: every corner is a 90° crease, every edge a smooth pair."""
positions = np.array(
[
[-half, -half, -half], [half, -half, -half], [half, half, -half],
[-half, half, -half], [-half, -half, half], [half, -half, half],
[half, half, half], [-half, half, half],
],
dtype=np.float64,
)
triangles = np.array(
[
[0, 2, 1], [0, 3, 2], # -z
[4, 5, 6], [4, 6, 7], # +z
[0, 1, 5], [0, 5, 4], # -y
[2, 3, 7], [2, 7, 6], # +y
[1, 2, 6], [1, 6, 5], # +x
[3, 0, 4], [3, 4, 7], # -x
],
dtype=np.int64,
)
return positions, triangles
def test_crease_aware_normals_agree_with_every_face_of_a_box():
"""The defect this fixes: an averaged corner normal agrees with *no* face."""
positions, triangles = _box()
averaged = vertex_normals(positions, triangles)
crease = crease_aware_mesh(positions, triangles)
split_positions, split_triangles, split_normals = crease
assert len(split_triangles) == len(triangles), "splitting must not change the faces"
assert len(split_positions) == 24, "each of the 8 corners needs 3 normals"
def worst(normals, faces, verts):
a, b, c = verts[faces[:, 0]], verts[faces[:, 1]], verts[faces[:, 2]]
face = np.cross(b - a, c - a)
unit = face / np.linalg.norm(face, axis=1, keepdims=True)
incident = [[] for _ in range(len(verts))]
for index, triangle in enumerate(faces):
for k in range(3):
incident[int(triangle[k])].append(index)
return max(
float(np.degrees(np.arccos(np.clip(unit[incident[v]] @ normals[v], -1, 1))).min())
for v in range(len(verts))
if incident[v]
)
assert worst(averaged, triangles, positions) > 30.0, (
"the fixture must actually exhibit the defect"
)
assert worst(split_normals, split_triangles, split_positions) < 1e-6, (
"a split corner normal must be exactly a face normal on a cube"
)
def test_crease_splitting_keeps_the_smooth_parts_welded():
"""A cylinder's curved side must survive whole; only its rim split."""
ring = 24
angles = np.linspace(0.0, 2.0 * np.pi, ring, endpoint=False)
positions = np.vstack(
[
np.column_stack([np.cos(angles), np.sin(angles), np.zeros(ring)]),
np.column_stack([np.cos(angles), np.sin(angles), np.ones(ring)]),
]
)
triangles = []
for i in range(ring):
j = (i + 1) % ring
triangles.append([i, j, ring + i])
triangles.append([j, ring + j, ring + i])
triangles = np.asarray(triangles, dtype=np.int64)
split_positions, split_triangles, _ = split_at_crease_edges(positions, triangles)
# The side is a smooth ring (3.7° per step), so nothing on it may be duplicated;
# the two rims are boundaries, so the split only separates the *caps'* edges.
assert len(split_triangles) == len(triangles)
assert len(split_positions) == len(positions), (
"a smoothly curved side must stay welded"
)
def test_normals_disagree_with_faces_detects_the_defect():
positions, triangles = _box()
assert normals_disagree_with_faces(
positions, triangles, vertex_normals(positions, triangles)
)
_, _, crease = crease_aware_mesh(positions, triangles)
split_positions, split_triangles, _ = split_at_crease_edges(positions, triangles)
assert not normals_disagree_with_faces(split_positions, split_triangles, crease)
# A flat sheet has no crease at all.
flat = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
assert not normals_disagree_with_faces(
flat, np.array([[0, 1, 2]]), np.array([[0.0, 0.0, 1.0]] * 3)
)
@requires_run
def test_the_shipped_housing_has_no_stale_shading():
"""The real housing: 134 of 2172 edges are creases, so plain averaging bands."""
import io
package = (
Path(__file__).resolve().parents[2]
/ "apps" / "studio" / "config" / "sensors" / "finger" / "scene"
/ "finger-display-1.0.0.oryscene"
)
if not package.is_file():
pytest.skip("the compiled finger package is not present")
with zipfile.ZipFile(package) as archive:
positions = np.load(io.BytesIO(archive.read("housing/positions.npy"))).astype(np.float64)
triangles = np.load(io.BytesIO(archive.read("housing/triangles.npy"))).astype(np.int64)
normals = np.load(io.BytesIO(archive.read("housing/normals.npy"))).astype(np.float64)
# Every stored normal agrees with an incident face, which is only possible
# because the source mesh's 726 vertices became 861 (one per side of a crease).
assert not normals_disagree_with_faces(positions, triangles, normals), (
"the shipped housing still carries normals averaged across its 90° corners"
)
# ...and the split is not a blanket unweld: the smooth vertices stay shared.
unique_positions = len(np.unique(positions, axis=0))
assert unique_positions == 726, (
f"{unique_positions} unique positions: the smooth surface was unwelded too"
)
assert len(positions) == 861, (
f"{len(positions)} vertices: the creases were not split into their own normals"
)
# The source mesh this was compiled from is the shape that used to be shipped,
# and plain averaging on it is exactly the defect.
source = Path(
r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute\housing.npz"
)
if source.is_file():
with np.load(source, allow_pickle=False) as handle:
source_positions = np.asarray(handle["vertices"], dtype=np.float64)
source_triangles = np.asarray(handle["faces"], dtype=np.int64)
assert len(source_positions) == 726, len(source_positions)
assert normals_disagree_with_faces(
source_positions,
source_triangles,
vertex_normals(source_positions, source_triangles),
), "the detector does not fire on the geometry that actually banded"
# ------------------------------------------------------------------- binding
def _domain(shape_hw=(4, 5), flip_v=False, world=None):
valid = np.ones(shape_hw, dtype=bool)
return type(
"D",
(),
{
"shape_hw": shape_hw,
"flip_v": flip_v,
"source_scale": None,
"valid_mask": freeze(valid, dtype=np.bool_),
"domain_id": field_domain_id(shape_hw=shape_hw, flip_v=flip_v),
"mapping_digest": "",
"world_xyz": world,
},
)()
def test_binding_taps_the_four_neighbours_with_weights_summing_to_one():
domain = _domain()
# Between pixels: x = 0.3*4 = 1.2, y = 0.25*3 = 0.75, so all four neighbours
# carry weight. (A UV that lands exactly on a pixel centre correctly uses one
# tap only, so the test has to sit between them.)
uvs = np.array([[0.3, 0.25]])
binding = build_binding(uvs, domain, part_id="sensing_surface", geometry_id_value="g")
weights = np.asarray(binding.sample_weights)[0]
indices = np.asarray(binding.sample_indices)[0]
assert weights.sum() == pytest.approx(1.0)
assert len(set(indices.tolist())) == 4, indices
# y*W+x with W=5: (0,1) -> 1, (0,2) -> 2, (1,1) -> 6, (1,2) -> 7
assert sorted(indices.tolist()) == [1, 2, 6, 7], indices.tolist()
# The nearer pixel of each pair carries the larger weight.
by_index = dict(zip(indices.tolist(), weights.tolist()))
assert by_index[1] > by_index[2] and by_index[6] > by_index[7]
def test_a_uv_exactly_on_a_pixel_uses_only_that_pixel():
"""Zero-weight taps are dropped, so a pixel-exact UV uses one tap, not four."""
# 3x5: u = 1/4 -> x = 1 exactly; v = 1/2 -> y = 1 exactly.
domain = _domain(shape_hw=(3, 5))
binding = build_binding(
np.array([[0.25, 0.5]]), domain, part_id="p", geometry_id_value="g"
)
indices = np.asarray(binding.sample_indices)[0]
weights = np.asarray(binding.sample_weights)[0]
assert int((indices >= 0).sum()) == 1, indices
assert int(indices[0]) == 1 * 5 + 1, indices
assert weights[0] == pytest.approx(1.0)
def test_a_half_pixel_uv_uses_the_two_taps_on_that_column():
"""Between rows, on a pixel: exactly two taps, and their weights still sum to 1."""
domain = _domain(shape_hw=(3, 5))
binding = build_binding(
np.array([[0.25, 0.25]]), domain, part_id="p", geometry_id_value="g"
)
indices = np.asarray(binding.sample_indices)[0]
weights = np.asarray(binding.sample_weights)[0]
used = indices[indices >= 0].tolist()
assert used == [1, 6], indices
assert [index % 5 for index in used] == [1, 1], "both taps must be in one column"
assert weights.sum() == pytest.approx(1.0)
def test_a_vertex_on_the_border_drops_its_outside_taps():
"""No tap may point outside the map, and the weights still sum to one."""
domain = _domain()
uvs = np.array([[0.0, 0.0], [1.0, 1.0]])
binding = build_binding(uvs, domain, part_id="sensing_surface", geometry_id_value="g")
indices = np.asarray(binding.sample_indices)
weights = np.asarray(binding.sample_weights)
assert np.allclose(weights.sum(axis=1), 1.0, atol=1e-6)
assert int(indices[indices >= 0].max()) < domain.shape_hw[0] * domain.shape_hw[1]
# A corner uses exactly one tap (the corner pixel itself), the rest are unused.
assert int((indices[0] >= 0).sum()) == 1, indices[0]
assert int((indices[0] < 0).sum()) == 3
assert weights[0, 1:].sum() == 0.0
assert int(weights[indices < 0].sum()) == 0
assert int(indices[0, 0]) == 0
def test_a_flipped_domain_mirrors_the_row_it_samples():
domain = _domain(shape_hw=(4, 5), flip_v=False)
flipped = _domain(shape_hw=(4, 5), flip_v=True)
uv = np.array([[0.54, 0.0]]) # between pixels, so the row is unambiguous
plain = np.asarray(
build_binding(uv, domain, part_id="p", geometry_id_value="g").sample_indices
)
mirrored = np.asarray(
build_binding(uv, flipped, part_id="p", geometry_id_value="g").sample_indices
)
plain_rows = sorted({int(index) // 5 for index in plain[plain >= 0].tolist()})
mirrored_rows = sorted({int(index) // 5 for index in mirrored[mirrored >= 0].tolist()})
assert plain_rows == [0], plain
assert mirrored_rows == [3], mirrored
@requires_run
def test_the_real_binding_is_well_formed():
assets = _import_run()
indices = np.asarray(assets.binding.sample_indices)
weights = np.asarray(assets.binding.sample_weights)
domain_size = assets.shape_hw[0] * assets.shape_hw[1]
assert indices.shape == (1032, 4)
assert indices.max() < domain_size
assert np.allclose(weights.sum(axis=1), 1.0, atol=1e-5)
assert (weights >= 0).all()
assert int((indices < 0).sum()) == int((weights == 0).sum())
# --------------------------------------------------------------------- units
def test_a_scaling_transform_is_refused():
scaled = np.diag([2.0, 2.0, 2.0, 1.0])
with pytest.raises(ThreeDError) as excinfo:
validate_rigid_transform(scaled)
assert "not a rigid rotation" in excinfo.value.diagnostic.message
def test_a_mirror_is_refused():
mirror = np.diag([-1.0, 1.0, 1.0, 1.0])
with pytest.raises(ThreeDError) as excinfo:
validate_rigid_transform(mirror)
assert "determinant" in excinfo.value.diagnostic.message
def test_a_bad_transform_shape_or_non_finite_values_are_refused():
with pytest.raises(ThreeDError):
validate_rigid_transform(np.eye(3))
broken = np.eye(4)
broken[0, 3] = np.nan
with pytest.raises(ThreeDError):
validate_rigid_transform(broken)
# A bottom row that is not the affine one: it would shear on every multiply.
affine_broken = np.array(
[[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 1, 1]], dtype=float
)
with pytest.raises(ThreeDError) as excinfo:
validate_rigid_transform(affine_broken)
assert "affine" in excinfo.value.diagnostic.message
def test_units_must_be_stated_and_positive(tmp_path):
"""The importer never guesses: no default, and a zero is an error."""
mesh = tmp_path / "mesh.npz"
np.savez(
mesh,
vertices=np.zeros((3, 3), dtype=np.float32),
faces=np.array([[0, 1, 2]], dtype=np.int32),
uv_data=np.zeros((3, 2), dtype=np.float32),
loop_to_vertex=np.array([0, 1, 2], dtype=np.int32),
)
mapping = tmp_path / "map.npz"
np.savez(mapping, valid_mask=np.ones((2, 2), dtype=bool))
for units in (0.0, -1.0, float("nan")):
with pytest.raises(ThreeDError) as excinfo:
LegacyFingerImporter().convert(
tmp_path, mapping, transform_sensor_from_source=_identity(),
meters_per_source_unit=units,
)
assert "does not guess" in excinfo.value.diagnostic.message
@requires_run
def test_units_and_the_transform_do_two_separate_jobs():
"""Millimetres become metres, then a rigid transform moves them."""
plain = _import_run()
shifted = _import_run(transform=np.array(
[[1, 0, 0, 0.001], [0, 1, 0, -0.002], [0, 0, 1, 0.003], [0, 0, 0, 1]], dtype=float
))
delta = np.asarray(shifted.sensing_part().positions_sensor_m) - np.asarray(
plain.sensing_part().positions_sensor_m
)
assert np.allclose(delta, [0.001, -0.002, 0.003], atol=1e-6)
assert np.allclose(
np.ptp(np.asarray(plain.sensing_part().positions_sensor_m), axis=0),
np.ptp(np.asarray(shifted.sensing_part().positions_sensor_m), axis=0),
atol=1e-6,
), "a rigid transform must not change any extent"
millimetres = _import_run(units=1e-3)
metres = _import_run(units=1.0)
assert np.allclose(
np.asarray(millimetres.sensing_part().positions_sensor_m) * 1000.0,
np.asarray(metres.sensing_part().positions_sensor_m),
atol=1e-6,
)
@requires_run
def test_the_measured_scale_is_reported_not_applied():
with np.load(RUN_DIR / "mesh.npz", allow_pickle=False) as handle:
vertices = handle["vertices"]
suggested = estimate_meters_per_source_unit(vertices)
assert suggested == pytest.approx(1.0 / 15.81, rel=0.01)
assert estimate_meters_per_source_unit(vertices, 0.0158) == pytest.approx(1e-3, rel=0.01)
# ------------------------------------------------------------------ identity
def test_digests_are_deterministic_and_sensitive():
assets = synthetic.poc_scene()
assert geometry_id(assets.parts) == geometry_id(assets.parts)
assert content_digest({"a": np.arange(4)}) == content_digest({"a": np.arange(4)})
assert content_digest({"a": np.arange(4)}) != content_digest({"a": np.arange(5)})
# dtype and shape are part of the identity: the same numbers stored
# differently are different bytes.
assert content_digest({"a": np.arange(4, dtype=np.int32)}) != content_digest(
{"a": np.arange(4, dtype=np.int64)}
)
def test_geometry_identity_ignores_display_choices():
"""A colour or a recomputed normal must not invalidate a calibration."""
assets = synthetic.poc_scene()
plate = assets.sensing_part()
recoloured = MeshPart(
part_id=plate.part_id,
role=plate.role,
positions_sensor_m=plate.positions_sensor_m,
normals_sensor=freeze(
-np.asarray(plate.normals_sensor), dtype=np.float32
), # a different normal algorithm
triangles=plate.triangles,
material=Material((1.0, 0.0, 0.0, 1.0), "diffuse", False),
lod_id=plate.lod_id,
)
assert geometry_id([recoloured]) == geometry_id([plate])
moved = MeshPart(
part_id=plate.part_id,
role=plate.role,
positions_sensor_m=freeze(
np.asarray(plate.positions_sensor_m) + 0.001, dtype=np.float32
),
normals_sensor=plate.normals_sensor,
triangles=plate.triangles,
material=plate.material,
lod_id=plate.lod_id,
)
assert geometry_id([moved]) != geometry_id([plate]), "moving the mesh must change it"
def test_domain_identity_covers_flip_scale_and_mapping():
base = field_domain_id(shape_hw=(300, 280), flip_v=True, source_scale=0.4, mapping_digest="a")
assert base == field_domain_id(
shape_hw=(300, 280), flip_v=True, source_scale=0.4, mapping_digest="a"
)
assert base != field_domain_id(
shape_hw=(300, 280), flip_v=False, source_scale=0.4, mapping_digest="a"
)
assert base != field_domain_id(
shape_hw=(300, 280), flip_v=True, source_scale=0.5, mapping_digest="a"
)
assert base != field_domain_id(
shape_hw=(300, 280), flip_v=True, source_scale=0.4, mapping_digest="b"
)
assert base != field_domain_id(
shape_hw=(280, 300), flip_v=True, source_scale=0.4, mapping_digest="a"
)
with pytest.raises(ThreeDError):
field_domain_id(shape_hw=(0, 280), flip_v=True)
def test_a_lod_change_needs_a_new_binding():
assets = synthetic.poc_scene()
moved = SceneAssets(
**{
**{field: getattr(assets, field) for field in (
"asset_id", "asset_version", "asset_hash", "geometry_id", "parts",
"camera_presets", "bounds_sensor_m", "field_domain_id", "shape_hw",
)},
"binding": assets.binding,
}
)
assert binding_id(moved.binding) == binding_id(assets.binding)
assert asset_hash({"a": "1"}, {"x": 1}) != asset_hash({"a": "1"}, {"x": 2})
# ------------------------------------------------------------------- the GLB
def _glb_bytes(positions, normals=None, indices=None, *, mode=4, extra=None,
node_matrix=None) -> bytes:
"""A minimal, valid glTF 2.0 binary with one node, one mesh, one primitive."""
positions = np.asarray(positions, dtype=np.float32)
chunks = [positions.tobytes()]
attributes = {"POSITION": 0}
accessors = [
{
"bufferView": 0,
"componentType": 5126,
"count": int(len(positions)),
"type": "VEC3",
}
]
views = [{"buffer": 0, "byteOffset": 0, "byteLength": len(chunks[0])}]
offset = len(chunks[0])
if normals is not None:
normals = np.asarray(normals, dtype=np.float32)
chunks.append(normals.tobytes())
accessors.append(
{
"bufferView": 1,
"componentType": 5126,
"count": int(len(normals)),
"type": "VEC3",
}
)
views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(chunks[-1])})
offset += len(chunks[-1])
attributes["NORMAL"] = 1
primitive: dict = {"attributes": attributes, "mode": int(mode)}
if indices is not None:
indices = np.asarray(indices, dtype=np.uint32)
chunks.append(indices.tobytes())
accessors.append(
{
"bufferView": len(views),
"componentType": 5125,
"count": int(len(indices)),
"type": "SCALAR",
}
)
views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(chunks[-1])})
primitive["indices"] = len(accessors) - 1
document = {
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0]}],
"nodes": [{"mesh": 0, "name": "housing"}],
"meshes": [{"primitives": [primitive]}],
"accessors": accessors,
"bufferViews": views,
"buffers": [{"byteLength": sum(len(chunk) for chunk in chunks)}],
}
if node_matrix is not None:
document["nodes"][0]["matrix"] = list(np.asarray(node_matrix, dtype=float).T.reshape(-1))
if extra:
document.update(extra)
binary = b"".join(chunks)
json_chunk = json.dumps(document).encode("utf-8")
json_chunk += b" " * ((4 - len(json_chunk) % 4) % 4)
binary += b"\0" * ((4 - len(binary) % 4) % 4)
total = 12 + 8 + len(json_chunk) + 8 + len(binary)
header = struct.pack("<III", 0x46546C67, 2, total)
return (
header
+ struct.pack("<II", len(json_chunk), 0x4E4F534A)
+ json_chunk
+ struct.pack("<II", len(binary), 0x004E4942)
+ binary
)
def test_a_housing_glb_is_read_with_its_node_transform_baked(tmp_path):
positions = [[0, 0, 0], [1, 0, 0], [0, 1, 0]]
path = tmp_path / "housing.glb"
matrix = np.eye(4)
matrix[:3, 3] = [0.01, 0.02, 0.03]
path.write_bytes(_glb_bytes(positions, indices=[0, 1, 2], node_matrix=matrix))
part = load_housing_glb(path)
assert part.role == "housing" and part.part_id == "housing"
assert part.triangles.shape == (1, 3)
assert np.allclose(
np.asarray(part.positions_sensor_m), np.asarray(positions) + [0.01, 0.02, 0.03]
)
assert np.allclose(np.linalg.norm(np.asarray(part.normals_sensor), axis=1), 1.0)
def test_a_housing_glb_without_normals_gets_computed_ones(tmp_path):
path = tmp_path / "housing.glb"
path.write_bytes(_glb_bytes([[0, 0, 0], [1, 0, 0], [0, 1, 0]], indices=[0, 1, 2]))
part = load_housing_glb(path)
assert np.allclose(np.asarray(part.normals_sensor)[:, 2], 1.0)
def test_a_glb_with_a_scale_in_its_node_is_baked_not_refused(tmp_path):
"""A model transform is data, not a mistake: bake it into the vertices."""
path = tmp_path / "scaled.glb"
matrix = np.diag([2.0, 2.0, 2.0, 1.0])
path.write_bytes(_glb_bytes([[0, 0, 0], [1, 0, 0], [0, 1, 0]], indices=[0, 1, 2],
node_matrix=matrix))
part = load_housing_glb(path)
assert np.allclose(np.asarray(part.positions_sensor_m)[1], [2.0, 0.0, 0.0])
def test_the_housing_glb_path_refuses_what_it_cannot_do(tmp_path):
cases = {
"draco": _glb_bytes([[0, 0, 0], [1, 0, 0], [0, 1, 0]],
extra={"extensionsUsed": ["KHR_draco_mesh_compression"]}),
"morph targets": _glb_bytes(
[[0, 0, 0], [1, 0, 0], [0, 1, 0]],
extra={"meshes": [{"primitives": [
{"attributes": {"POSITION": 0}, "mode": 4}
], "weights": [0.5]}]},
),
"not triangles": _glb_bytes([[0, 0, 0], [1, 0, 0], [0, 1, 0]], mode=1),
"nan position": _glb_bytes([[0, 0, 0], [np.nan, 0, 0], [0, 1, 0]]),
"bad index": _glb_bytes([[0, 0, 0], [1, 0, 0], [0, 1, 0]], indices=[0, 1, 9]),
}
for label, payload in cases.items():
path = tmp_path / f"{label.replace(' ', '_')}.glb"
path.write_bytes(payload)
with pytest.raises(ThreeDError) as excinfo:
load_housing_glb(path)
assert excinfo.value.diagnostic.code in ("asset_rejected", "geometry_mismatch"), label
def test_a_broken_glb_header_is_refused(tmp_path):
path = tmp_path / "broken.glb"
path.write_bytes(b"not a glb at all")
with pytest.raises(ThreeDError):
read_glb(path)
good = _glb_bytes([[0, 0, 0], [1, 0, 0], [0, 1, 0]])
path.write_bytes(good[:8] + struct.pack("<I", len(good) + 16) + good[12:])
with pytest.raises(ThreeDError) as excinfo:
read_glb(path)
assert "header declares" in excinfo.value.diagnostic.message
def test_the_housing_triangle_budget_is_enforced(tmp_path):
path = tmp_path / "big.glb"
positions = []
for index in range(30):
positions.extend([[float(index), 0.0, 0.0], [float(index) + 1, 0.0, 0.0], [0.0, 1.0, 0.0]])
indices = list(range(len(positions)))
path.write_bytes(_glb_bytes(positions, indices=indices))
with pytest.raises(ThreeDError) as excinfo:
load_housing_glb(path, limits=HousingLimits(max_triangles=5))
assert excinfo.value.diagnostic.code == "gpu_budget_exceeded"
@requires_run
def test_adding_a_housing_keeps_the_sensing_identity(tmp_path):
"""The housing is a second part; the sensing geometry id must not move."""
assets = _import_run() # the imported scene has the sensing part only
before = assets.geometry_id
path = tmp_path / "housing.glb"
path.write_bytes(_glb_bytes([[0, 0, 0], [1, 0, 0], [0, 1, 0]], indices=[0, 1, 2]))
housing = load_housing_glb(path)
combined = with_housing(assets, housing)
assert len(combined.parts) == 2
assert combined.sensing_part().positions_sensor_m is assets.sensing_part().positions_sensor_m
assert combined.geometry_id == before, (
"the sensing geometry id must not change when a housing is added"
)
with pytest.raises(ThreeDError) as excinfo:
with_housing(combined, housing) # only one housing
assert "already has a housing" in excinfo.value.diagnostic.message
# ...and the POC scene, which ships with one, refuses a second the same way.
with pytest.raises(ThreeDError):
with_housing(synthetic.poc_scene(), housing)
@requires_run
def test_the_housing_can_be_placed_with_the_surface_in_one_frame():
"""Three known non-collinear points: surface and housing agree on the axes."""
assets = _import_run()
bounds = np.asarray(assets.bounds_sensor_m)
# A housing box built *around* the imported surface, in the same metres.
low, high = bounds[0] - 0.001, bounds[1] + 0.001
corners = np.array(
[
[low[0], low[1], low[2]], [high[0], low[1], low[2]], [low[0], high[1], low[2]],
[high[0], high[1], low[2]], [low[0], low[1], high[2]], [high[0], low[1], high[2]],
[low[0], high[1], high[2]], [high[0], high[1], high[2]],
]
)
part = MeshPart(
part_id="housing",
role="housing",
positions_sensor_m=freeze(corners, dtype=np.float32),
normals_sensor=freeze(np.tile([0.0, 0.0, 1.0], (8, 1)), dtype=np.float32),
triangles=freeze(np.zeros((0, 3), dtype=np.uint32), dtype=np.uint32),
material=Material((0.3, 0.3, 0.3, 1.0), "diffuse", True),
)
scene = validate_assets(with_housing(assets, part)) is not None
assert scene
surface = np.asarray(assets.sensing_part().positions_sensor_m)
housed = np.asarray(part.positions_sensor_m)
# The three axes line up: every surface vertex is inside the housing box, and
# the box's extent exceeds the surface's by exactly the 1 mm margin.
assert (surface.min(axis=0) >= housed.min(axis=0)).all()
assert (surface.max(axis=0) <= housed.max(axis=0)).all()
assert np.allclose(
np.ptp(housed, axis=0) - np.ptp(surface, axis=0), 0.002, atol=1e-6
)