import os import unittest import zipfile from pathlib import Path from xml.etree import ElementTree as ET import build123d import trimesh 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.step_scene import LoadedStepScene, OccurrenceNode, _shape_hash from tests.python.support.tmp_root import temporary_directory from cadpy.threemf import export_scene_3mf, export_shape_3mf NS = {"m": "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"} 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 _translate_transform(x: float, y: float, z: float) -> tuple[float, ...]: return ( 1.0, 0.0, 0.0, x, 0.0, 1.0, 0.0, y, 0.0, 0.0, 1.0, z, 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_model(path: Path) -> ET.Element: with zipfile.ZipFile(path) as package: return ET.fromstring(package.read("3D/3dmodel.model")) class ThreeMfExportTests(unittest.TestCase): def test_export_shape_3mf_writes_loadable_package(self) -> None: with temporary_directory(prefix="cad-3mf-test-") as temp_dir: output_path = Path(temp_dir) / "box.3mf" shape = build123d.Box(10, 20, 30).wrapped BRepMesh_IncrementalMesh(shape, 0.1, True, 0.1, True).Perform() export_shape_3mf(shape, output_path) self.assertTrue(output_path.exists()) self.assertTrue(zipfile.is_zipfile(output_path)) with zipfile.ZipFile(output_path) as package: self.assertIn("[Content_Types].xml", package.namelist()) self.assertIn("_rels/.rels", package.namelist()) self.assertIn("3D/3dmodel.model", package.namelist()) self.assertNotIn("mesh.stl", package.namelist()) model_xml = package.read("3D/3dmodel.model") root = ET.fromstring(model_xml) self.assertEqual("millimeter", root.attrib["unit"]) bases = root.findall("./m:resources/m:basematerials/m:base", NS) self.assertTrue(bases) self.assertIn("#B6C4CEFF", [base.attrib.get("displaycolor") for base in bases]) vertices = root.findall(".//m:vertex", NS) triangles = root.findall(".//m:triangle", NS) self.assertTrue(vertices) self.assertTrue(triangles) self.assertLess(len(vertices), len(triangles) * 3) loaded = trimesh.load(output_path, file_type="3mf") geometry = getattr(loaded, "geometry", None) self.assertTrue(len(geometry) if geometry is not None else len(loaded.faces)) def test_scene_export_emits_component_objects_and_transforms(self) -> None: with temporary_directory(prefix="cad-3mf-test-") as temp_dir: output_path = Path(temp_dir) / "assembly.3mf" shape = _meshed_box() prototype_key = _shape_hash(shape) scene = LoadedStepScene( step_path=Path("assembly.step"), roots=[ OccurrenceNode( path=(1,), name="root", source_name="root", transform=_identity_transform(), local_transform=_identity_transform(), prototype_key=None, children=[ OccurrenceNode( path=(1, 1), name="left", source_name="block", transform=_identity_transform(), local_transform=_identity_transform(), prototype_key=prototype_key, ), OccurrenceNode( path=(1, 2), name="right", source_name="block", transform=_translate_transform(25, 0, 0), local_transform=_translate_transform(25, 0, 0), prototype_key=prototype_key, ), ], ) ], prototype_shapes={prototype_key: shape}, prototype_names={prototype_key: "block"}, ) export_scene_3mf(scene, output_path) root = _read_model(output_path) objects = root.findall("./m:resources/m:object", NS) mesh_objects = [obj for obj in objects if obj.find("m:mesh", NS) is not None] component_objects = [obj for obj in objects if obj.find("m:components", NS) is not None] components = root.findall(".//m:component", NS) transforms = [component.attrib.get("transform") for component in components if component.attrib.get("transform")] self.assertEqual(1, len(mesh_objects)) self.assertEqual(1, len(component_objects)) self.assertEqual(2, len(components)) self.assertTrue(any("25" in transform.split() for transform in transforms)) def test_uniform_color_uses_object_level_material(self) -> None: with temporary_directory(prefix="cad-3mf-test-") as temp_dir: output_path = Path(temp_dir) / "red.3mf" shape = _meshed_box() scene, prototype_key = _single_leaf_scene( shape, prototype_colors={_shape_hash(shape): (1.0, 0.0, 0.0, 1.0)}, ) export_scene_3mf(scene, output_path) root = _read_model(output_path) bases = root.findall("./m:resources/m:basematerials/m:base", NS) mesh_object = next(obj for obj in root.findall("./m:resources/m:object", NS) if obj.find("m:mesh", NS) is not None) triangles = root.findall(".//m:triangle", NS) self.assertEqual(prototype_key, _shape_hash(shape)) self.assertIn("#FF0000FF", [base.attrib.get("displaycolor") for base in bases]) self.assertNotIn("#B6C4CEFF", [base.attrib.get("displaycolor") for base in bases]) self.assertEqual("1", mesh_object.attrib.get("pid")) self.assertIsNotNone(mesh_object.attrib.get("pindex")) self.assertFalse(any("p1" in triangle.attrib for triangle in triangles)) def test_display_colors_are_srgb_encoded_from_scene_colors(self) -> None: with temporary_directory(prefix="cad-3mf-test-") as temp_dir: output_path = Path(temp_dir) / "rail.3mf" shape = _meshed_box() scene, _prototype_key = _single_leaf_scene(shape) export_scene_3mf(scene, output_path, occurrence_colors={"o1": (0.22, 0.28, 0.34, 1.0)}) root = _read_model(output_path) bases = root.findall("./m:resources/m:basematerials/m:base", NS) self.assertIn("#81909EFF", [base.attrib.get("displaycolor") for base in bases]) def test_occurrence_color_overrides_default_material(self) -> None: with temporary_directory(prefix="cad-3mf-test-") as temp_dir: output_path = Path(temp_dir) / "dark.3mf" shape = _meshed_box() scene, _prototype_key = _single_leaf_scene(shape) export_scene_3mf(scene, output_path, occurrence_colors={"o1": (0.168627, 0.184314, 0.2, 1.0)}) root = _read_model(output_path) bases = root.findall("./m:resources/m:basematerials/m:base", NS) mesh_object = next(obj for obj in root.findall("./m:resources/m:object", NS) if obj.find("m:mesh", NS) is not None) dark_index = next( str(index) for index, base in enumerate(bases) if base.attrib.get("displaycolor") == "#72777CFF" ) self.assertEqual(dark_index, mesh_object.attrib.get("pindex")) def test_parent_occurrence_color_applies_to_descendant_meshes(self) -> None: with temporary_directory(prefix="cad-3mf-test-") as temp_dir: output_path = Path(temp_dir) / "child.3mf" shape = _meshed_box() prototype_key = _shape_hash(shape) scene = LoadedStepScene( step_path=Path("assembly.step"), roots=[ OccurrenceNode( path=(1,), name="colored-parent", source_name="colored-parent", transform=_identity_transform(), local_transform=_identity_transform(), prototype_key=None, children=[ OccurrenceNode( path=(1, 1), name="child", source_name="child", transform=_identity_transform(), local_transform=_identity_transform(), prototype_key=prototype_key, ) ], ) ], prototype_shapes={prototype_key: shape}, ) export_scene_3mf(scene, output_path, occurrence_colors={"o1": (0.168627, 0.184314, 0.2, 1.0)}) root = _read_model(output_path) bases = root.findall("./m:resources/m:basematerials/m:base", NS) mesh_object = next(obj for obj in root.findall("./m:resources/m:object", NS) if obj.find("m:mesh", NS) is not None) dark_index = next( str(index) for index, base in enumerate(bases) if base.attrib.get("displaycolor") == "#72777CFF" ) self.assertEqual(dark_index, mesh_object.attrib.get("pindex")) def test_mixed_face_colors_use_triangle_material_indices(self) -> None: with temporary_directory(prefix="cad-3mf-test-") as temp_dir: output_path = Path(temp_dir) / "mixed.3mf" shape = _meshed_box() face_hashes: list[int] = [] explorer = TopExp_Explorer(shape, TopAbs_FACE) while explorer.More(): 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_hash: ( (1.0, 0.0, 0.0, 1.0) if index % 2 == 0 else (0.0, 0.0, 1.0, 1.0) ) for index, face_hash in enumerate(face_hashes) } }, ) export_scene_3mf(scene, output_path) root = _read_model(output_path) bases = root.findall("./m:resources/m:basematerials/m:base", NS) triangles = root.findall(".//m:triangle", NS) triangle_materials = {triangle.attrib.get("p1") for triangle in triangles if "p1" in triangle.attrib} mesh_object = next(obj for obj in root.findall("./m:resources/m:object", NS) if obj.find("m:mesh", NS) is not None) self.assertEqual(prototype_key, _shape_hash(shape)) self.assertIn("#FF0000FF", [base.attrib.get("displaycolor") for base in bases]) self.assertIn("#0000FFFF", [base.attrib.get("displaycolor") for base in bases]) self.assertNotIn("#B6C4CEFF", [base.attrib.get("displaycolor") for base in bases]) self.assertIsNone(mesh_object.attrib.get("pindex")) self.assertGreaterEqual(len(triangle_materials), 2) @unittest.skipUnless(os.environ.get("CAD_3MF_HEAVY_TESTS") == "1", "set CAD_3MF_HEAVY_TESTS=1 to run fixture exports") class ThreeMfFixtureExportTests(unittest.TestCase): def test_sample_assembly_exports_native_components(self) -> None: with temporary_directory(prefix="cad-3mf-fixture-") as temp_dir: output_path = Path(temp_dir) / "sample_assembly.3mf" shape_a = _meshed_box() shape_b = _meshed_box(6, 8, 10) key_a = _shape_hash(shape_a) key_b = _shape_hash(shape_b) scene = LoadedStepScene( step_path=Path("sample_assembly.step"), roots=[ OccurrenceNode( path=(1,), name="sample_assembly", source_name="sample_assembly", transform=_identity_transform(), local_transform=_identity_transform(), prototype_key=None, children=[ OccurrenceNode( path=(1, 1), name="sample_module", source_name="sample_module", transform=_identity_transform(), local_transform=_identity_transform(), prototype_key=None, children=[ OccurrenceNode( path=(1, 1, 1), name="sample_component_a", source_name="sample_component_a", transform=_identity_transform(), local_transform=_identity_transform(), prototype_key=key_a, ), OccurrenceNode( path=(1, 1, 2), name="sample_component_b", source_name="sample_component_b", transform=_translate_transform(25, 0, 0), local_transform=_translate_transform(25, 0, 0), prototype_key=key_b, ), ], ), OccurrenceNode( path=(1, 2), name="sample_component_c", source_name="sample_component_c", transform=_translate_transform(0, 25, 0), local_transform=_translate_transform(0, 25, 0), prototype_key=key_a, ), ], ) ], prototype_shapes={key_a: shape_a, key_b: shape_b}, prototype_names={key_a: "sample_component_a", key_b: "sample_component_b"}, ) export_scene_3mf(scene, output_path) root = _read_model(output_path) objects = root.findall("./m:resources/m:object", NS) components = root.findall(".//m:component", NS) transforms = [component.attrib.get("transform") for component in components if component.attrib.get("transform")] self.assertGreater(sum(1 for obj in objects if obj.find("m:mesh", NS) is not None), 1) self.assertGreater(sum(1 for obj in objects if obj.find("m:components", NS) is not None), 1) self.assertGreater(len(transforms), 0) loaded = trimesh.load(output_path, file_type="3mf") self.assertGreater(len(getattr(loaded, "geometry", {}) or []), 1) def test_sample_components_export_material_assignments(self) -> None: with temporary_directory(prefix="cad-3mf-fixture-") as temp_dir: output_path = Path(temp_dir) / "sample_components.3mf" shape_a = _meshed_box() shape_b = _meshed_box(6, 8, 10) key_a = _shape_hash(shape_a) key_b = _shape_hash(shape_b) scene = LoadedStepScene( step_path=Path("sample_components.step"), roots=[ OccurrenceNode( path=(1,), name="sample_component_a", source_name="sample_component_a", transform=_identity_transform(), local_transform=_identity_transform(), prototype_key=key_a, ), OccurrenceNode( path=(2,), name="sample_component_b", source_name="sample_component_b", transform=_translate_transform(25, 0, 0), local_transform=_translate_transform(25, 0, 0), prototype_key=key_b, ), ], prototype_shapes={key_a: shape_a, key_b: shape_b}, prototype_names={key_a: "sample_component_a", key_b: "sample_component_b"}, prototype_colors={key_a: (1.0, 0.0, 0.0, 1.0), key_b: (0.0, 0.0, 1.0, 1.0)}, ) export_scene_3mf(scene, output_path) root = _read_model(output_path) bases = root.findall("./m:resources/m:basematerials/m:base", NS) objects = root.findall("./m:resources/m:object", NS) triangles = root.findall(".//m:triangle", NS) material_colors = {base.attrib.get("displaycolor") for base in bases} object_materials = {(obj.attrib.get("pid"), obj.attrib.get("pindex")) for obj in objects if obj.attrib.get("pindex")} triangle_materials = {triangle.attrib.get("p1") for triangle in triangles if "p1" in triangle.attrib} self.assertIn("#FF0000FF", material_colors) self.assertIn("#0000FFFF", material_colors) self.assertTrue(object_materials or triangle_materials) loaded = trimesh.load(output_path, file_type="3mf") self.assertGreater(len(getattr(loaded, "geometry", {}) or []), 1) if __name__ == "__main__": unittest.main()