阶段 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 接线)。
This commit is contained in:
@@ -0,0 +1,720 @@
|
||||
"""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
|
||||
)
|
||||
Reference in New Issue
Block a user