Files
orisys-app/shared/tests/test_scene3d_assets.py
T
ykcheungab d0dcac743f 阶段 2:资产合同 —— 旧 NPZ 导入、坐标/单位、绑定、包容器与工具 - What: shared/scene3d 新增四个模块与两个工具的真实现。digest.py:规范化内容摘要(C 连续小端字节 + 排序 JSON;DIGEST_SCHEMA 版本化),geometry_id(只看 part 角色/顶点/三角形,故意不含法线材质与 LOD 名——改颜色不得让标定失效)、binding_id、field_domain_id(形状、flip_v、source_scale、原点/索引约定、映射内容)、asset_hash(条目摘要 + 去自引用字段的 manifest)。legacy.py:LegacyFingerImporter 按"先单位、后刚体"两步转换(meters_per_source_unit 必填且不得猜、validate_rigid_transform 拒绝含缩放/镜像/非仿射的 4x4);split_at_uv_seams 按 (源顶点, 量化 UV) 拆点,接缝处不平均 UV;vertex_normals 面积加权并给无面顶点有限回退;uv_to_pixel 采用实测约定 x=u*(W-1)、y=(1-v)*(H-1)(flip_v 时);build_binding 生成四点双线性采样表(域内裁剪、零权 tap 丢弃并重分配、未用项 -1/0);uv_correspondence_residual 用 Procrustes 残差把"UV 是否对得上 world_xyz"变成可断言的事实;estimate_meters_per_source_unit 只报告不应用。glb.py:glTF 2.0 子集读取(GLB 头/chunk 校验、accessor/bufferView/stride、节点 TRS 与矩阵烘焙进顶点、法线用逆转置再归一化、缺法线则计算),明确拒绝 Draco/meshopt/量化扩展、稀疏 accessor、外部 buffer、骨骼、形态目标、非 TRIANGLES、越界索引、非有限坐标与超预算三角形;with_housing 把外壳作为独立 part 加入且不动感知几何 id。package.py:.oryscene(zip:envelope.json + manifest.json + sensing/housing *.npy,无 pickle 无代码执行),写入原子(先写 .partial 再替换)、读取逐一校验 magic/envelope 版本/payload 状态/条目路径/manifest 自哈希/逐条目 sha256/dtype/shape/**从包内数组重算 geometry_id 与 binding_id**,加密封套明确报"本版本只开明文包"。tools/scene_assets/validate_scene.py 与 compile_scene.py 真实现(含 --json 报告与退出码:0 通过、2 检查失败、1 参数/文件问题;未给单位时停在 needs_units 并打印实测候选尺度)。测试:shared/tests/test_scene3d_assets.py 33 项(真实 1032 导出与 1005 对比资产的输入样例、接缝/法线/绑定/单位/变换/摘要的失败样例、GLB 往返与拒绝样例)、test_scene3d_package.py 24 项(往返、外壳条目、以及篡改条目/篡改 manifest 哈希/自洽但错误的几何/非有限坐标/多余条目/缺条目/路径穿越/dtype 与 shape 不符/未来版本/加密封套/key provider 的拒绝)。docs/architecture/studio-3d-stages.md 阶段 2 记为完成并附验收结果。 - Why: 阶段 2 要回答的是"这份几何到底是什么、单位是什么、它和哪套 UV 域配套、以及这份包有没有被动过",这些一旦错了会以"三维看起来有点怪"的形式出现且事后无法追溯。因此把 UV 约定做成可测量的事实(真实资产残差 0.0064 vs 错误翻转 0.59)、把单位做成必填参数而不是默认值、把 identity 做成内容摘要并**在读取时重算**,同时用真实资产当输入样例、用篡改后的包当失败样例。 - Verification: 真实资产导入:1032 顶点/1974 三角形/域 300×280/尺寸 15.81×15.0×7.5 mm,validate_assets 通过;UV 残差 0.0064(错翻转 0.59);两套资产混用被拒(geometry_mismatch);缩放/镜像/非仿射变换被拒;compile→validate 端到端 ok(包内条目哈希与 manifest 自哈希全部通过);shared 全量 244 passed;studio 118 passed;ui_check 554 项 0 失败;.repro/verify_assets.py 22 项 0 失败。过程中修掉 5 个真实缺陷:np.load 需要真实文件对象(手写内存 stub 抛空消息异常)、包可以"自洽却错误"(改数组+改哈希后无人重算几何 id,现已重算并比对)、binding 行数不匹配被误报为域错误、np.ptp 平铺比较无意义(换算/不变性断言改为按轴)、以及合成 PoC 场景自己用字符串当 geometry_id(所有人都会抄它,等于绕过检查)。 - Not in this change: 签名与 AES-GCM(阶段 5);LOD 简化;calibration.py 侧的资产哈希登记;三维视图仍未被应用导入(阶段 4 接线)。
2026-09-14 15:08:29 +08:00

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
)