"""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 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 ( ExpectedGeometry, Material, MeshPart, SceneAssets, ThreeDError, validate_assets, freeze, ) 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, estimate_meters_per_source_unit, load_legacy_domain, 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: return LegacyFingerImporter().convert( RUN_DIR, RUN_MAP, 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,) @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() # ------------------------------------------------------------------- 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("= 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 )