import json import struct import unittest from array import array from pathlib import Path import build123d from OCP.BRepMesh import BRepMesh_IncrementalMesh from OCP.TopAbs import TopAbs_FACE from OCP.TopExp import TopExp_Explorer from OCP.TopoDS import TopoDS from cadpy.glb import ( export_assembly_glb_from_scene, export_native_glb_from_scene, export_part_glb_from_scene, read_step_display_edge_manifest_from_glb, read_step_topology_bundle_from_glb, read_step_topology_manifest_from_glb, ) from cadpy.glb_topology import STEP_TOPOLOGY_SCHEMA_VERSION, glb_surface_edge_class_has_nonzero_values from cadpy.step_scene import ( LoadedStepScene, OccurrenceNode, SelectorBundle, SelectorOptions, SelectorProfile, _shape_hash, extract_selectors_from_scene, load_step_scene, ) from tests.python.support.tmp_root import temporary_directory def _identity_transform() -> tuple[float, ...]: return ( 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, ) def _meshed_box(width: float = 10, depth: float = 20, height: float = 30) -> object: shape = build123d.Box(width, depth, height).wrapped BRepMesh_IncrementalMesh(shape, 0.1, True, 0.1, True).Perform() return shape def _single_leaf_scene(shape: object, **kwargs) -> tuple[LoadedStepScene, int]: prototype_key = _shape_hash(shape) scene = LoadedStepScene( step_path=Path("fixture.step"), roots=[ OccurrenceNode( path=(1,), name="fixture", source_name="fixture", transform=_identity_transform(), local_transform=_identity_transform(), prototype_key=prototype_key, ) ], prototype_shapes={prototype_key: shape}, prototype_names={prototype_key: "fixture"}, **kwargs, ) return scene, prototype_key def _read_glb_json(path: Path) -> dict[str, object]: payload = path.read_bytes() magic, version, _length = struct.unpack_from(" None: with temporary_directory(prefix="cad-glb-test-") as temp_dir: step_path = Path(temp_dir) / "fixture.step" scene, _prototype_key = _single_leaf_scene(_meshed_box()) bundle = SelectorBundle( manifest={ "schemaVersion": 1, "profile": "artifact", "stepPath": "fixture.step", "stepHash": "step-hash-123", "tables": {}, "occurrences": [], "shapes": [], "faces": [], "edges": [], }, buffers={"edgeIds": array("I", [7, 11]), "surfaceHalfEdges": array("I")}, ) glb_path = export_part_glb_from_scene( step_path, scene, linear_deflection=0.1, angular_deflection=0.1, selector_bundle=bundle, ) gltf = _read_glb_json(glb_path) self.assertIn("STEP_topology", gltf.get("extensionsUsed", [])) self.assertIn("STEP_topology", gltf.get("extensions", {})) extension = gltf.get("extensions", {}).get("STEP_topology", {}) self.assertIn("indexView", extension) self.assertIn("edgeView", extension) self.assertIn("selectorView", extension) index = read_step_topology_manifest_from_glb(glb_path) self.assertIsNotNone(index) assert index is not None self.assertEqual("index", index.get("profile")) self.assertEqual("part", index.get("entryKind")) self.assertNotIn("cadRef", index) self.assertNotIn("cadPath", index) surface_edges = read_step_display_edge_manifest_from_glb(glb_path) self.assertIsNotNone(surface_edges) assert surface_edges is not None self.assertEqual("surface-edges", surface_edges.get("profile")) self.assertEqual(STEP_TOPOLOGY_SCHEMA_VERSION, surface_edges.get("schemaVersion")) self.assertEqual("surfaceHalfEdges", surface_edges.get("halfEdgesView")) self.assertIn("surfaceHalfEdges", surface_edges.get("buffers", {}).get("views", {})) self.assertNotIn("faces", surface_edges) self.assertNotIn("relations", surface_edges) embedded = read_step_topology_bundle_from_glb(glb_path) self.assertIsNotNone(embedded) assert embedded is not None self.assertEqual(STEP_TOPOLOGY_SCHEMA_VERSION, embedded.manifest["schemaVersion"]) self.assertNotIn("cadRef", embedded.manifest) self.assertNotIn("cadPath", embedded.manifest) self.assertEqual([7, 11], list(embedded.buffers["edgeIds"])) def test_step_glb_surface_edge_class_attribute_is_populated_from_selector_topology(self) -> None: with temporary_directory(prefix="cad-glb-surface-edges-") as temp_dir: step_path = Path(temp_dir) / "cylinder.step" build123d.export_step(build123d.Cylinder(5, 10), step_path) scene = load_step_scene(step_path) bundle = extract_selectors_from_scene( scene, profile=SelectorProfile.ARTIFACT, options=SelectorOptions(linear_deflection=0.2, angular_deflection=0.2), ) glb_path = export_part_glb_from_scene( step_path, scene, linear_deflection=0.2, angular_deflection=0.2, selector_bundle=bundle, ) self.assertTrue(glb_surface_edge_class_has_nonzero_values(glb_path)) def test_legacy_topology_json_and_bin_bundle_is_read_when_glb_lacks_extension(self) -> None: with temporary_directory(prefix="cad-glb-test-") as temp_dir: artifact_dir = Path(temp_dir) / ".fixture.step" artifact_dir.mkdir() glb_path = artifact_dir / "model.glb" glb_path.write_bytes(b"legacy visual glb without embedded topology") edge_ids = array("I", [19, 23]) edge_bytes = edge_ids.tobytes() (artifact_dir / "topology.bin").write_bytes(edge_bytes) (artifact_dir / "topology.json").write_text( json.dumps( { "schemaVersion": 1, "profile": "artifact", "cadRef": "fixtures/legacy", "buffers": { "uri": "topology.bin", "littleEndian": True, "views": { "edgeIds": { "dtype": "uint32", "byteOffset": 0, "byteLength": len(edge_bytes), "count": 2, "itemSize": 4, } }, }, } ), encoding="utf-8", ) index = read_step_topology_manifest_from_glb(glb_path) self.assertIsNotNone(index) assert index is not None self.assertEqual("fixtures/legacy", index.get("cadRef")) bundle = read_step_topology_bundle_from_glb(glb_path) self.assertIsNotNone(bundle) assert bundle is not None self.assertEqual("fixtures/legacy", bundle.manifest.get("cadRef")) self.assertEqual([19, 23], list(bundle.buffers["edgeIds"])) def test_assembly_glb_preserves_face_colors_as_material_primitives(self) -> None: with temporary_directory(prefix="cad-glb-test-") as temp_dir: step_path = Path(temp_dir) / "fixture.step" shape = _meshed_box() face_hashes: list[int] = [] explorer = TopExp_Explorer(shape, TopAbs_FACE) while explorer.More() and len(face_hashes) < 2: face_hashes.append(_shape_hash(TopoDS.Face_s(explorer.Current()))) explorer.Next() scene, prototype_key = _single_leaf_scene( shape, prototype_face_colors={ _shape_hash(shape): { face_hashes[0]: (1.0, 0.0, 0.0, 1.0), face_hashes[1]: (0.0, 0.0, 1.0, 1.0), } }, ) glb_path = export_assembly_glb_from_scene( step_path, scene, linear_deflection=0.1, angular_deflection=0.1, ) gltf = _read_glb_json(glb_path) colors = [ material.get("pbrMetallicRoughness", {}).get("baseColorFactor") for material in gltf.get("materials", []) ] primitives = gltf.get("meshes", [])[0].get("primitives", []) primitive_materials = {primitive.get("material") for primitive in primitives} primitive_attributes = [primitive.get("attributes", {}) for primitive in primitives] self.assertEqual(prototype_key, _shape_hash(shape)) self.assertIn([1.0, 0.0, 0.0, 1.0], colors) self.assertIn([0.0, 0.0, 1.0, 1.0], colors) self.assertGreaterEqual(len(primitive_materials), 2) self.assertTrue(all("NORMAL" in attributes for attributes in primitive_attributes)) def test_assembly_glb_applies_occurrence_color_to_descendant_meshes(self) -> None: with temporary_directory(prefix="cad-glb-test-") as temp_dir: step_path = Path(temp_dir) / "fixture.step" shape = _meshed_box() prototype_key = _shape_hash(shape) scene = LoadedStepScene( step_path=step_path, roots=[ OccurrenceNode( path=(1,), name="assembly", source_name="assembly", transform=_identity_transform(), prototype_key=None, local_transform=_identity_transform(), children=( OccurrenceNode( path=(1, 1), name="part", source_name="part", transform=_identity_transform(), local_transform=_identity_transform(), prototype_key=prototype_key, ), ), ) ], prototype_shapes={prototype_key: shape}, prototype_names={prototype_key: "fixture"}, ) glb_path = export_assembly_glb_from_scene( step_path, scene, linear_deflection=0.1, angular_deflection=0.1, occurrence_colors={"o1": (0.1, 0.2, 0.3, 1.0)}, ) gltf = _read_glb_json(glb_path) colors = [ material.get("pbrMetallicRoughness", {}).get("baseColorFactor") for material in gltf.get("materials", []) ] self.assertIn([0.1, 0.2, 0.3, 1.0], colors) def test_part_glb_uses_scene_material_colors(self) -> None: with temporary_directory(prefix="cad-glb-test-") as temp_dir: step_path = Path(temp_dir) / "fixture.step" shape = _meshed_box() scene, prototype_key = _single_leaf_scene( shape, prototype_colors={_shape_hash(shape): (0.168627, 0.184314, 0.2, 1.0)}, ) glb_path = export_part_glb_from_scene( step_path, scene, linear_deflection=0.1, angular_deflection=0.1, ) gltf = _read_glb_json(glb_path) colors = [ material.get("pbrMetallicRoughness", {}).get("baseColorFactor") for material in gltf.get("materials", []) ] self.assertEqual(prototype_key, _shape_hash(shape)) self.assertIn([0.168627, 0.184314, 0.2, 1.0], colors) def test_native_glb_is_y_up_without_step_topology_or_cad_extras(self) -> None: with temporary_directory(prefix="cad-glb-test-") as temp_dir: step_path = Path(temp_dir) / "fixture.step" shape = _meshed_box(width=10, depth=20, height=30) scene, _prototype_key = _single_leaf_scene( shape, prototype_colors={_shape_hash(shape): (0.168627, 0.184314, 0.2, 1.0)}, ) glb_path = export_native_glb_from_scene( step_path, scene, target_path=Path(temp_dir) / "fixture.glb", linear_deflection=0.1, angular_deflection=0.1, ) gltf = _read_glb_json(glb_path) self.assertNotIn("extensionsUsed", gltf) self.assertNotIn("extensions", gltf) self.assertTrue(all("extras" not in node for node in gltf.get("nodes", []))) position_accessor = gltf["accessors"][0] self.assertEqual([-0.005, -0.015, -0.01], [round(value, 6) for value in position_accessor["min"]]) self.assertEqual([0.005, 0.015, 0.01], [round(value, 6) for value in position_accessor["max"]]) colors = [ material.get("pbrMetallicRoughness", {}).get("baseColorFactor") for material in gltf.get("materials", []) ] self.assertIn([0.168627, 0.184314, 0.2, 1.0], colors) def test_native_glb_converts_assembly_transforms_to_y_up(self) -> None: with temporary_directory(prefix="cad-glb-test-") as temp_dir: step_path = Path(temp_dir) / "fixture.step" shape = _meshed_box() prototype_key = _shape_hash(shape) translated = ( 1.0, 0.0, 0.0, 10.0, 0.0, 1.0, 0.0, 20.0, 0.0, 0.0, 1.0, 30.0, 0.0, 0.0, 0.0, 1.0, ) scene = LoadedStepScene( step_path=step_path, roots=[ OccurrenceNode( path=(1,), name="assembly", source_name="assembly", transform=_identity_transform(), local_transform=_identity_transform(), prototype_key=None, children=( OccurrenceNode( path=(1, 1), name="part", source_name="part", transform=translated, local_transform=translated, prototype_key=prototype_key, ), ), ) ], prototype_shapes={prototype_key: shape}, prototype_names={prototype_key: "fixture"}, ) glb_path = export_native_glb_from_scene( step_path, scene, target_path=Path(temp_dir) / "assembly.glb", linear_deflection=0.1, angular_deflection=0.1, ) gltf = _read_glb_json(glb_path) child = next(node for node in gltf["nodes"] if node.get("mesh") == 0) self.assertEqual("part", child.get("name")) self.assertEqual([0.01, 0.03, -0.02], [round(value, 6) for value in child["matrix"][12:15]]) if __name__ == "__main__": unittest.main()