721 lines
28 KiB
Python
721 lines
28 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 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("<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
|
|
)
|