first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,420 @@
import json
import shutil
import struct
import unittest
from pathlib import Path
from unittest import mock
import build123d
from cadpy import assembly_export as assembly_export_module
from cadpy.assembly_export import build_assembly_compound, export_assembly_step_scene
from cadpy.assembly_spec import AssemblyInstanceSpec, AssemblyNodeSpec, AssemblySpec
from cadpy.glb_topology import STEP_TOPOLOGY_SCHEMA_VERSION
from cadpy.glb_mesh_payload import DEFAULT_MATERIAL, scene_glb_mesh_payload
from cadpy.render import part_glb_path
from cadpy.step_scene import (
SelectorProfile,
_apply_transform_point,
_bbox_from_shape,
extract_selectors_from_scene,
mesh_step_scene,
scene_leaf_occurrences,
scene_occurrence_shape,
_shape_hash,
)
from tests.python.support.cad_test_roots import IsolatedCadRoots
IDENTITY_TRANSFORM = (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)
TRANSLATED_TRANSFORM = (1.0, 0.0, 0.0, 4.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 _pad4(payload: bytes, *, byte: bytes = b"\0") -> bytes:
padding = (4 - (len(payload) % 4)) % 4
return payload + (byte * padding)
def _write_topology_glb(path: Path, manifest: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
manifest_payload = json.dumps({"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION, "profile": "index", **manifest}, separators=(",", ":")).encode("utf-8")
display_manifest_payload = json.dumps(
{
"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION,
"profile": "surface-edges",
"stepHash": "",
"halfEdgesView": "surfaceHalfEdges",
"buffers": {"views": {"surfaceHalfEdges": {"dtype": "uint32", "bufferView": 1, "byteOffset": 0, "byteLength": 0, "count": 0, "itemSize": 4}}},
},
separators=(",", ":"),
).encode("utf-8")
display_offset = len(_pad4(manifest_payload))
binary = _pad4(manifest_payload) + _pad4(display_manifest_payload)
gltf = {
"asset": {"version": "2.0"},
"buffers": [{"byteLength": len(binary)}],
"bufferViews": [
{"buffer": 0, "byteOffset": 0, "byteLength": len(manifest_payload)},
{"buffer": 0, "byteOffset": display_offset, "byteLength": len(display_manifest_payload)},
],
"extensionsUsed": ["STEP_topology"],
"extensions": {
"STEP_topology": {
"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION,
"indexView": 0,
"edgeView": 1,
"encoding": "utf-8",
}
},
}
json_chunk = _pad4(json.dumps(gltf, separators=(",", ":")).encode("utf-8"), byte=b" ")
path.write_bytes(
b"glTF"
+ struct.pack("<II", 2, 12 + 8 + len(json_chunk) + 8 + len(binary))
+ struct.pack("<I4s", len(json_chunk), b"JSON")
+ json_chunk
+ struct.pack("<I4s", len(binary), b"BIN\0")
+ binary
)
class AssemblyExportTests(unittest.TestCase):
def setUp(self) -> None:
self._isolated_roots = IsolatedCadRoots(self, prefix="assembly-export-")
self.cad_root = self._isolated_roots.cad_root
def tearDown(self) -> None:
shutil.rmtree(self._isolated_roots.root, ignore_errors=True)
def _write_part(self) -> Path:
step_path = self.cad_root / "STEP" / "leaf.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
build123d.export_step(build123d.Box(1, 1, 1), step_path)
return step_path
def _write_colored_part(self) -> Path:
step_path = self.cad_root / "STEP" / "leaf.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
box = build123d.Box(1, 1, 1)
box.color = build123d.Color(1, 0, 0, 1)
build123d.export_step(box, step_path)
return step_path
def _write_native_assembly_part(self, *, write_topology: bool = False) -> Path:
step_path = self.cad_root / "STEP" / "native_module.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
left = build123d.Box(1, 1, 1)
left.label = "left"
right = build123d.Box(1, 1, 1).moved(build123d.Location((10, 0, 0)))
right.label = "right"
module = build123d.Compound(children=[left, right], label="native_module")
build123d.export_step(module, step_path)
if write_topology:
_write_topology_glb(part_glb_path(step_path), {"assembly": {"root": {"children": [{}]}}})
return step_path
def _assembly_spec(self, *instances: AssemblyInstanceSpec) -> AssemblySpec:
assembly_path = self.cad_root / "STEP" / "assembly.py"
assembly_path.parent.mkdir(parents=True, exist_ok=True)
return AssemblySpec(
assembly_path=assembly_path,
instances=instances,
)
def _leaf_instance(
self,
*,
instance_id: str = "leaf",
transform: tuple[float, ...] = IDENTITY_TRANSFORM,
use_source_colors: bool = True,
) -> AssemblyInstanceSpec:
return AssemblyInstanceSpec(
instance_id=instance_id,
source_path=(self.cad_root / "STEP" / "leaf.step").resolve(),
path="leaf.step",
name=instance_id,
transform=transform,
use_source_colors=use_source_colors,
)
def _transformed_payload_bbox(self, scene, node) -> dict[str, list[float]]:
payload = scene_glb_mesh_payload(
scene,
node.prototype_key,
default_color=DEFAULT_MATERIAL,
suppress_face_colors=True,
)
points = []
for x in (payload.minimum[0] * 1000.0, payload.maximum[0] * 1000.0):
for y in (payload.minimum[1] * 1000.0, payload.maximum[1] * 1000.0):
for z in (payload.minimum[2] * 1000.0, payload.maximum[2] * 1000.0):
points.append(_apply_transform_point(node.transform, [x, y, z]))
return {
"min": [min(point[index] for point in points) for index in range(3)],
"max": [max(point[index] for point in points) for index in range(3)],
}
def _assert_bbox_close(self, actual: dict[str, list[float]], expected: dict[str, list[float]]) -> None:
for key in ("min", "max"):
for actual_value, expected_value in zip(actual[key], expected[key], strict=True):
self.assertAlmostEqual(actual_value, expected_value, places=3)
def test_imported_part_does_not_read_persistent_source_color(self) -> None:
self._write_part()
assembly_spec = self._assembly_spec(self._leaf_instance())
assembly = build_assembly_compound(assembly_spec, label="assembly")
self.assertIsNone(assembly.children[0].color)
def test_instance_can_suppress_embedded_source_color(self) -> None:
self._write_colored_part()
assembly_spec = self._assembly_spec(self._leaf_instance(use_source_colors=False))
assembly = build_assembly_compound(assembly_spec, label="assembly")
self.assertIsNone(assembly.children[0].color)
def test_repeated_part_instances_keep_distinct_occurrence_names(self) -> None:
self._write_part()
assembly_spec = self._assembly_spec(
self._leaf_instance(instance_id="leaf_a"),
self._leaf_instance(instance_id="leaf_b", transform=TRANSLATED_TRANSFORM),
)
assembly_path = assembly_spec.assembly_path
output_path = assembly_path.with_suffix(".step")
scene = export_assembly_step_scene(assembly_spec, output_path)
bundle = extract_selectors_from_scene(
scene,
cad_ref="assemblies/assembly",
profile=SelectorProfile.SUMMARY,
)
columns = bundle.manifest["tables"]["occurrenceColumns"]
source_names = {
dict(zip(columns, row))["sourceName"]
for row in bundle.manifest["occurrences"]
}
self.assertIn("leaf_a", source_names)
self.assertIn("leaf_b", source_names)
self.assertEqual(output_path.resolve(), scene.step_path)
self.assertIsNotNone(scene.doc)
def test_recursive_children_keep_subassembly_labels(self) -> None:
self._write_part()
assembly_spec = AssemblySpec(
assembly_path=self.cad_root / "STEP" / "assembly.py",
instances=(),
children=(
AssemblyNodeSpec(
instance_id="module",
name="module",
transform=IDENTITY_TRANSFORM,
children=(
AssemblyNodeSpec(
instance_id="leaf",
name="leaf",
source_path=(self.cad_root / "STEP" / "leaf.step").resolve(),
path="leaf.step",
transform=IDENTITY_TRANSFORM,
),
),
),
),
)
assembly = build_assembly_compound(assembly_spec, label="assembly")
self.assertEqual("module", assembly.children[0].label)
self.assertEqual("module__leaf", assembly.children[0].children[0].label)
def test_direct_export_recursive_children_keep_occurrence_names(self) -> None:
self._write_part()
assembly_spec = AssemblySpec(
assembly_path=self.cad_root / "STEP" / "assembly.py",
instances=(),
children=(
AssemblyNodeSpec(
instance_id="module",
name="module",
transform=IDENTITY_TRANSFORM,
children=(
AssemblyNodeSpec(
instance_id="leaf",
name="leaf",
source_path=(self.cad_root / "STEP" / "leaf.step").resolve(),
path="leaf.step",
transform=IDENTITY_TRANSFORM,
),
),
),
),
)
scene = export_assembly_step_scene(assembly_spec, assembly_spec.assembly_path.with_suffix(".step"))
bundle = extract_selectors_from_scene(
scene,
cad_ref="assemblies/assembly",
profile=SelectorProfile.SUMMARY,
)
columns = bundle.manifest["tables"]["occurrenceColumns"]
source_names = {
dict(zip(columns, row))["sourceName"]
for row in bundle.manifest["occurrences"]
}
self.assertIn("module", source_names)
self.assertIn("module__leaf", source_names)
def test_direct_export_preserves_imported_step_assembly_child_locations(self) -> None:
native_step = self._write_native_assembly_part()
assembly_spec = self._assembly_spec(
AssemblyInstanceSpec(
instance_id="native",
source_path=native_step.resolve(),
path="native_module.step",
name="native",
transform=IDENTITY_TRANSFORM,
),
)
scene = export_assembly_step_scene(assembly_spec, assembly_spec.assembly_path.with_suffix(".step"))
native_root = scene.roots[0].children[0].children[0]
child_x_offsets = sorted(round(float(child.local_transform[3]), 3) for child in native_root.children)
self.assertEqual([0.0, 10.0], child_x_offsets)
def test_imported_step_assembly_glb_payload_does_not_double_apply_child_location(self) -> None:
native_step = self._write_native_assembly_part()
assembly_spec = self._assembly_spec(
AssemblyInstanceSpec(
instance_id="native",
source_path=native_step.resolve(),
path="native_module.step",
name="native",
transform=IDENTITY_TRANSFORM,
),
)
scene = export_assembly_step_scene(assembly_spec, assembly_spec.assembly_path.with_suffix(".step"))
mesh_step_scene(scene, linear_deflection=0.006, angular_deflection=0.6, relative=True)
leaf_bboxes = [
(node, _bbox_from_shape(scene_occurrence_shape(scene, node)))
for node in scene_leaf_occurrences(scene)
if node.prototype_key is not None
]
self.assertEqual(2, len(leaf_bboxes))
right_box_leaf, expected_bbox = max(
leaf_bboxes,
key=lambda item: item[1]["max"][0],
)
payload_bbox = self._transformed_payload_bbox(scene, right_box_leaf)
self.assertGreater(expected_bbox["min"][0], 9.0)
self.assertLess(expected_bbox["max"][0], 11.0)
self._assert_bbox_close(payload_bbox, {"min": expected_bbox["min"], "max": expected_bbox["max"]})
def test_cached_compound_copy_preserves_parent_transform(self) -> None:
leaf = build123d.Box(1, 1, 1)
leaf.label = "leaf"
compound = build123d.Compound(children=[leaf], label="module")
moved = compound.moved(build123d.Location((10, 0, 0)))
copied = assembly_export_module._copy_cached_shape_tree(moved)
self.assertAlmostEqual(10.0, copied.center().X, places=6)
def test_catalog_index_is_reused_for_repeated_instances(self) -> None:
self._write_part()
assembly_spec = self._assembly_spec(
self._leaf_instance(instance_id="leaf_a"),
self._leaf_instance(instance_id="leaf_b", transform=TRANSLATED_TRANSFORM),
)
with mock.patch.object(
assembly_export_module,
"iter_cad_sources",
wraps=assembly_export_module.iter_cad_sources,
) as iter_cad_sources:
assembly = build_assembly_compound(assembly_spec, label="assembly")
self.assertEqual(1, iter_cad_sources.call_count)
self.assertEqual(["leaf_a", "leaf_b"], [child.label for child in assembly.children])
def test_direct_export_propagates_leaf_shape_label_to_step_product(self) -> None:
"""Regression: leaf-Part definitions in the assembly STEP must carry
the shape label, not the generic 'SOLID' OCCT fallback. Without
``_set_label_name`` on the leaf branch of ``_shape_definition_for_tree``,
a CAD UI such as Fusion shows every single-Part component as ``solid``.
"""
leaf_label = "leaf_box_product"
step_path = self.cad_root / "STEP" / f"{leaf_label}.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
box = build123d.Box(1, 1, 1)
box.label = leaf_label
build123d.export_step(box, step_path)
assembly_spec = self._assembly_spec(
AssemblyInstanceSpec(
instance_id="leaf",
source_path=step_path.resolve(),
path=f"{leaf_label}.step",
name="leaf",
transform=IDENTITY_TRANSFORM,
),
)
output_path = assembly_spec.assembly_path.with_suffix(".step")
export_assembly_step_scene(assembly_spec, output_path)
contents = output_path.read_bytes()
self.assertIn(f"PRODUCT('{leaf_label}'".encode("ascii"), contents)
self.assertNotIn(b"PRODUCT('SOLID'", contents)
def test_direct_writer_reuses_quantity_colors_for_tuple_rgba(self) -> None:
assembly_spec = self._assembly_spec()
writer = assembly_export_module._DirectXcafAssemblyWriter(assembly_spec, label="assembly")
captured_colors: list[object] = []
class FakeColorTool:
def SetColor(self, label, wrapped, color_type):
captured_colors.append(wrapped)
writer.color_tool = FakeColorTool()
writer._set_label_color(object(), (0.1, 0.2, 0.3, 1.0))
writer._set_label_color(object(), (0.1, 0.2, 0.3, 1.0))
self.assertEqual(2, len(captured_colors))
self.assertIs(captured_colors[0], captured_colors[1])
def test_direct_writer_applies_step_scene_face_colors_to_prototype_shapes(self) -> None:
assembly_spec = self._assembly_spec()
writer = assembly_export_module._DirectXcafAssemblyWriter(assembly_spec, label="assembly")
captured_colors: list[object] = []
class FakeColorTool:
def SetColor(self, label, wrapped, color_type):
captured_colors.append(wrapped)
writer.color_tool = FakeColorTool()
shape = build123d.Box(1, 1, 1).wrapped
shape_label = writer.shape_tool.AddShape(shape, False)
face_hashes = []
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face_hashes.append(_shape_hash(TopoDS.Face_s(explorer.Current())))
explorer.Next()
writer._set_shape_face_colors(shape_label, shape, {face_hashes[0]: (0.0, 0.0, 1.0, 1.0)})
self.assertEqual(1, len(captured_colors))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,173 @@
import shutil
import unittest
from pathlib import Path
from cadpy import assembly_flatten
from cadpy import assembly_spec
from cadpy.render import part_glb_path
from tests.python.support.cad_test_roots import IsolatedCadRoots
def _translation(x: float, y: float, z: float) -> list[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,
]
IDENTITY_TRANSFORM = _translation(0.0, 0.0, 0.0)
class AssemblyFlattenTests(unittest.TestCase):
def setUp(self) -> None:
self._isolated_roots = IsolatedCadRoots(self, prefix="assembly_flatten-")
tempdir = self._isolated_roots.temporary_cad_directory(prefix="tmp-assembly_flatten-")
self._tempdir = tempdir
self.temp_root = Path(tempdir.name)
self.relative_dir = self.temp_root.relative_to(assembly_spec.CAD_ROOT).as_posix()
self.render_paths: list[Path] = []
def tearDown(self) -> None:
for render_path in self.render_paths:
render_path.unlink(missing_ok=True)
shutil.rmtree(self.temp_root, ignore_errors=True)
self._tempdir.cleanup()
def _cad_ref(self, name: str) -> str:
return f"{self.relative_dir}/{name}"
def _write_part(self, name: str) -> None:
step_path = self.temp_root / f"{name}.step"
step_path.write_text("ISO-10303-21; END-ISO-10303-21;\n")
glb_path = part_glb_path(step_path)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_bytes(b"glb")
self.render_paths.append(glb_path)
def _write_assembly(self, name: str, *, instances: list[dict[str, object]]) -> None:
(self.temp_root / f"{name}.py").write_text(
"\n".join(
[
"def gen_step():",
f" return {{'instances': {instances!r}}}",
"",
]
)
)
def test_flatten_source_path_resolves_python_assembly_parts(self) -> None:
self._write_part("part-a")
self._write_part("part-b")
self._write_assembly(
"root-assembly",
instances=[
{
"path": "part-a.step",
"name": "left-leaf",
"transform": _translation(1.0, 2.0, 3.0),
},
{
"path": "part-b.step",
"name": "right-leaf",
"transform": _translation(0.0, 5.0, 0.0),
},
],
)
resolved = assembly_flatten.flatten_source_path(self.temp_root / "root-assembly.py")
self.assertEqual(2, len(resolved))
self.assertEqual(
[
("left-leaf",),
("right-leaf",),
],
[part.instance_path for part in resolved],
)
self.assertEqual(
[
self._cad_ref("part-a"),
self._cad_ref("part-b"),
],
[part.cad_ref for part in resolved],
)
self.assertEqual("left-leaf", resolved[0].name)
self.assertEqual("right-leaf", resolved[1].name)
self.assertEqual(
tuple(_translation(1.0, 2.0, 3.0)),
resolved[0].transform,
)
self.assertEqual(
tuple(_translation(0.0, 5.0, 0.0)),
resolved[1].transform,
)
def test_flatten_source_path_resolves_paths_relative_to_assembly_source(self) -> None:
step_dir = self.temp_root / "parts"
step_dir.mkdir(parents=True)
step_path = step_dir / "part.step"
step_path.write_text("ISO-10303-21; END-ISO-10303-21;\n")
glb_path = part_glb_path(step_path)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_bytes(b"glb")
self.render_paths.append(glb_path)
(step_dir / "sample_assembly.py").write_text(
"\n".join(
[
"def gen_step():",
" return {'instances': [",
" {'path': 'part.step', 'name': 'leaf', 'transform': "
f"{IDENTITY_TRANSFORM!r}}}",
" ]}",
"",
]
),
encoding="utf-8",
)
resolved = assembly_flatten.flatten_source_path(step_dir / "sample_assembly.py")
self.assertEqual(1, len(resolved))
self.assertEqual(f"{self.relative_dir}/parts/part", resolved[0].cad_ref)
self.assertEqual(step_path.resolve(), resolved[0].step_path.resolve())
self.assertEqual(part_glb_path(step_path).resolve(), resolved[0].glb_path.resolve())
def test_flatten_source_path_rejects_duplicate_instance_names(self) -> None:
self._write_part("part")
self._write_assembly(
"assembly",
instances=[
{
"path": "part.step",
"name": "duplicate",
"transform": IDENTITY_TRANSFORM,
},
{
"path": "part.step",
"name": "duplicate",
"transform": IDENTITY_TRANSFORM,
},
],
)
with self.assertRaisesRegex(assembly_flatten.AssemblyResolutionError, "duplicates"):
assembly_flatten.flatten_source_path(self.temp_root / "assembly.py")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,120 @@
import shutil
import unittest
from cadpy.assembly_spec import (
IDENTITY_TRANSFORM,
AssemblySpecError,
assembly_spec_from_payload,
)
from tests.python.support.cad_test_roots import IsolatedCadRoots
class AssemblySpecTests(unittest.TestCase):
def setUp(self) -> None:
self._isolated_roots = IsolatedCadRoots(self, prefix="assembly-spec-")
self.cad_root = self._isolated_roots.cad_root
self.assembly_path = self.cad_root / "STEP" / "assembly.py"
self.assembly_path.parent.mkdir(parents=True, exist_ok=True)
self.leaf_step = self.assembly_path.parent / "leaf.step"
self.leaf_step.write_text("ISO-10303-21; END-ISO-10303-21;\n", encoding="utf-8")
def tearDown(self) -> None:
shutil.rmtree(self._isolated_roots.root, ignore_errors=True)
def test_flat_instances_are_normalized_to_root_children(self) -> None:
spec = assembly_spec_from_payload(
self.assembly_path,
{
"instances": [
{
"path": "leaf.step",
"name": "leaf",
"transform": list(IDENTITY_TRANSFORM),
}
]
},
)
self.assertEqual(1, len(spec.instances))
self.assertEqual(1, len(spec.children))
self.assertEqual("leaf", spec.children[0].instance_id)
self.assertEqual(self.leaf_step.resolve(), spec.children[0].source_path)
def test_nested_children_parse_to_leaf_instances(self) -> None:
spec = assembly_spec_from_payload(
self.assembly_path,
{
"children": [
{
"name": "module",
"transform": list(IDENTITY_TRANSFORM),
"children": [
{
"path": "leaf.step",
"name": "leaf",
"transform": list(IDENTITY_TRANSFORM),
}
],
}
]
},
)
self.assertEqual(1, len(spec.children))
self.assertEqual("module", spec.children[0].instance_id)
self.assertEqual(1, len(spec.children[0].children))
self.assertEqual(("leaf",), tuple(instance.instance_id for instance in spec.instances))
def test_rejects_duplicate_sibling_names(self) -> None:
with self.assertRaisesRegex(AssemblySpecError, "duplicates 'leaf'"):
assembly_spec_from_payload(
self.assembly_path,
{
"children": [
{
"path": "leaf.step",
"name": "leaf",
"transform": list(IDENTITY_TRANSFORM),
},
{
"path": "leaf.step",
"name": "leaf",
"transform": list(IDENTITY_TRANSFORM),
},
]
},
)
def test_rejects_empty_subassembly_children(self) -> None:
with self.assertRaisesRegex(AssemblySpecError, "children\\[1\\]\\.children must be a non-empty array"):
assembly_spec_from_payload(
self.assembly_path,
{
"children": [
{
"name": "module",
"transform": list(IDENTITY_TRANSFORM),
"children": [],
}
]
},
)
def test_rejects_absolute_paths(self) -> None:
with self.assertRaisesRegex(AssemblySpecError, "must be a relative STEP path"):
assembly_spec_from_payload(
self.assembly_path,
{
"children": [
{
"path": str(self.leaf_step.resolve()),
"name": "leaf",
"transform": list(IDENTITY_TRANSFORM),
}
]
},
)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,423 @@
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("<III", payload, 0)
if magic != 0x46546C67 or version != 2:
raise AssertionError("Not a GLB v2 file")
chunk_length, chunk_type = struct.unpack_from("<I4s", payload, 12)
if chunk_type != b"JSON":
raise AssertionError("First GLB chunk is not JSON")
return json.loads(payload[20:20 + chunk_length].decode("utf-8"))
class GlbExportTests(unittest.TestCase):
def test_part_glb_embeds_step_topology_extension(self) -> 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()
@@ -0,0 +1,62 @@
"""Selector-manifest entryKind stamping in the GLB STEP_topology writer."""
from __future__ import annotations
import json
import unittest
from cadpy.glb import _GlbBuilder
from cadpy.glb_topology import STEP_TOPOLOGY_EXTENSION
from cadpy.selector_types import SelectorBundle
def _decode_view(builder: _GlbBuilder, view_index: int) -> dict[str, object]:
views = builder.json["bufferViews"]
assert isinstance(views, list)
view = views[view_index]
start = int(view["byteOffset"])
length = int(view["byteLength"])
return json.loads(bytes(builder.binary[start : start + length]).decode("utf-8"))
def _bundle_manifest(*, assembly: bool) -> dict[str, object]:
manifest: dict[str, object] = {
"cadPath": "tmp/sample",
"stepPath": "tmp/sample.step",
"stepHash": "hash-123",
"sourceKind": "step",
"sourcePath": "tmp/sample.step",
"bbox": {"min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 1.0]},
"stats": {"occurrenceCount": 3, "leafOccurrenceCount": 2},
}
if assembly:
manifest["assembly"] = {"id": "o1"}
return manifest
class GlbSelectorManifestEntryKindTests(unittest.TestCase):
def _write_step_topology(self, manifest: dict[str, object]) -> tuple[dict[str, object], dict[str, object]]:
builder = _GlbBuilder()
builder.add_step_topology(SelectorBundle(manifest=manifest), include_selector_topology=True)
extensions = builder.json["extensions"]
assert isinstance(extensions, dict)
extension = extensions[STEP_TOPOLOGY_EXTENSION]
selector_manifest = _decode_view(builder, extension["selectorView"])
return extension, selector_manifest
def test_assembly_manifest_stamps_selector_entry_kind(self) -> None:
extension, selector_manifest = self._write_step_topology(_bundle_manifest(assembly=True))
self.assertEqual("assembly", extension["entryKind"])
self.assertEqual("assembly", selector_manifest["entryKind"])
self.assertNotIn("assembly", selector_manifest)
def test_part_manifest_stamps_selector_entry_kind(self) -> None:
extension, selector_manifest = self._write_step_topology(_bundle_manifest(assembly=False))
self.assertEqual("part", extension["entryKind"])
self.assertEqual("part", selector_manifest["entryKind"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,69 @@
import tempfile
import unittest
from pathlib import Path
from cadpy import generation, step_artifacts
from cadpy.step_targets import ResolvedStepTarget
class StepArtifactsTests(unittest.TestCase):
def test_existing_step_target_ignores_python_source_for_glb_regeneration(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
step_path = root / "part.step"
source_path = root / "part.py"
step_path.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
source_path.write_text("def gen_step():\n return None\n", encoding="utf-8")
target = ResolvedStepTarget(
cad_path="part",
kind="part",
source_path=source_path,
step_path=step_path,
)
self.assertIsNone(step_artifacts._python_source_for_target(target))
def test_missing_logical_step_can_still_use_python_source(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
step_path = root / "part.step"
source_path = root / "part.py"
source_path.write_text("def gen_step():\n return None\n", encoding="utf-8")
target = ResolvedStepTarget(
cad_path="part",
kind="part",
source_path=step_path,
step_path=step_path,
)
self.assertEqual(step_artifacts._python_source_for_target(target), source_path)
def test_existing_step_spec_can_reuse_python_backed_glb_when_step_hash_matches(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
step_path = root / "part.step"
step_path.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
spec = generation.EntrySpec(
source_ref="part.step",
cad_ref="part",
kind="part",
source_path=step_path,
display_name="part",
source="imported",
step_path=step_path,
)
self.assertFalse(generation._artifact_source_kind_matches_spec(spec, {"sourceKind": "python"}))
self.assertTrue(
generation._artifact_source_kind_matches_spec(
spec,
{
"sourceKind": "python",
"stepHash": generation.step_file_hash(step_path),
},
)
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,80 @@
import unittest
from pathlib import Path
from cadpy.step_metadata import (
TEXT_TO_CAD_GENERATOR,
inject_text_to_cad_step_metadata,
read_text_to_cad_step_metadata,
)
from tests.python.support.tmp_root import temporary_directory
MINIMAL_STEP = """ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('Open CASCADE Model'),'2;1');
ENDSEC;
DATA;
#1=PRODUCT_DEFINITION('design','',#2,#3);
#4=PRODUCT_DEFINITION_SHAPE('','',#1);
#5=SHAPE_REPRESENTATION('',(#6),#7);
#7=(GEOMETRIC_REPRESENTATION_CONTEXT(3) REPRESENTATION_CONTEXT('Context #1','3D'));
ENDSEC;
END-ISO-10303-21;
"""
class TextToCadStepMetadataTests(unittest.TestCase):
def test_injects_and_reads_text_to_cad_metadata(self) -> None:
with temporary_directory(prefix="cad-step-metadata-") as temp_dir:
step_path = Path(temp_dir) / "fixture.step"
step_path.write_text(MINIMAL_STEP, encoding="utf-8")
inject_text_to_cad_step_metadata(
step_path,
entry_kind="assembly",
source_hash="source-hash-123",
)
metadata = read_text_to_cad_step_metadata(step_path)
self.assertEqual(TEXT_TO_CAD_GENERATOR, metadata.get("generator"))
self.assertEqual("assembly", metadata.get("entryKind"))
self.assertEqual("source-hash-123", metadata.get("sourceHash"))
step_text = step_path.read_text(encoding="utf-8")
self.assertIn("PROPERTY_DEFINITION('cadpy metadata','cadpy:entryKind'", step_text)
def test_reads_tail_metadata_without_full_file_scan(self) -> None:
with temporary_directory(prefix="cad-step-metadata-tail-") as temp_dir:
step_path = Path(temp_dir) / "large-fixture.step"
metadata_block = "\n".join(
[
"#100=DESCRIPTIVE_REPRESENTATION_ITEM('cadpy:generator','cadpy');",
"#101=REPRESENTATION('cadpy:generator',(#100),#7);",
"#102=PROPERTY_DEFINITION('cadpy metadata','cadpy:generator',#1);",
"#103=PROPERTY_DEFINITION_REPRESENTATION(#102,#101);",
"#104=DESCRIPTIVE_REPRESENTATION_ITEM('cadpy:entryKind','assembly');",
"#105=REPRESENTATION('cadpy:entryKind',(#104),#7);",
"#106=PROPERTY_DEFINITION('cadpy metadata','cadpy:entryKind',#1);",
"#107=PROPERTY_DEFINITION_REPRESENTATION(#106,#105);",
"#112=DESCRIPTIVE_REPRESENTATION_ITEM('cadpy:sourceHash','source-hash-tail');",
"#113=REPRESENTATION('cadpy:sourceHash',(#112),#7);",
"#114=PROPERTY_DEFINITION('cadpy metadata','cadpy:sourceHash',#1);",
"#115=PROPERTY_DEFINITION_REPRESENTATION(#114,#113);",
]
)
step_path.write_text(
"ISO-10303-21;\nDATA;\n"
+ ("#9=PRODUCT('padding','padding','',(#7));\n" * 40000)
+ metadata_block
+ "\nENDSEC;\nEND-ISO-10303-21;\n",
encoding="utf-8",
)
metadata = read_text_to_cad_step_metadata(step_path)
self.assertEqual(TEXT_TO_CAD_GENERATOR, metadata.get("generator"))
self.assertEqual("assembly", metadata.get("entryKind"))
self.assertEqual("source-hash-tail", metadata.get("sourceHash"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,296 @@
import os
import unittest
from pathlib import Path
from unittest import mock
import build123d
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS
from cadpy import step_scene
from cadpy.step_scene import (
LoadedStepScene,
OccurrenceNode,
SelectorOptions,
SelectorProfile,
adaptive_mesh_resolution_for_scene,
extract_selectors_from_scene,
load_step_scene,
scene_occurrence_shape,
)
from cadpy.glb_topology import STEP_TOPOLOGY_SCHEMA_VERSION
from cadpy.metadata import DEFAULT_MESH_ANGULAR_TOLERANCE, DEFAULT_MESH_TOLERANCE
from tests.python.support.tmp_root import temporary_directory
class StepSceneSelectorArtifactTests(unittest.TestCase):
def test_load_step_scene_cached_reuses_brep_scene_cache(self) -> None:
with temporary_directory(prefix="cad-step-scene-cache-") as temp_dir:
temp_root = Path(temp_dir)
step_path = temp_root / "box.step"
cache_dir = temp_root / "cache"
build123d.export_step(build123d.Box(1, 1, 1), step_path)
with mock.patch.dict(os.environ, {"TEXT_TO_CAD_STEP_SCENE_CACHE_DIR": str(cache_dir)}):
first = step_scene.load_step_scene_cached(step_path)
self.assertEqual(1, len(first.prototype_shapes))
with mock.patch.object(step_scene, "load_step_scene", side_effect=AssertionError("cache miss")):
cached = step_scene.load_step_scene_cached(step_path)
self.assertEqual(first.step_hash, cached.step_hash)
self.assertEqual(1, len(cached.roots))
self.assertEqual(1, len(cached.prototype_shapes))
self.assertFalse(scene_occurrence_shape(cached, cached.roots[0]).IsNull())
def test_step_scene_cache_restores_locations_and_face_color_hashes(self) -> None:
with temporary_directory(prefix="cad-step-scene-cache-private-") as temp_dir:
temp_root = Path(temp_dir)
shape = build123d.Box(1, 1, 1).wrapped
explorer = TopExp_Explorer(shape, TopAbs_FACE)
face_hash = step_scene._shape_hash(TopoDS.Face_s(explorer.Current()))
transform = (
1.0,
0.0,
0.0,
5.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,
)
step_path = temp_root / "synthetic.step"
step_path.write_text("synthetic cache key only", encoding="utf-8")
scene = LoadedStepScene(
step_path=step_path,
roots=[
OccurrenceNode(
path=(1,),
name="moved_box",
source_name="box",
transform=transform,
local_transform=transform,
prototype_key=7,
)
],
prototype_shapes={7: shape},
prototype_face_colors={7: {face_hash: (1.0, 0.0, 0.0, 1.0)}},
)
cache_root = temp_root / "cache"
step_hash = "a" * 64
step_scene._write_step_scene_cache(scene, step_hash=step_hash, root=cache_root)
cached = step_scene._read_step_scene_cache(step_path, step_hash=step_hash, root=cache_root)
self.assertIsNotNone(cached)
assert cached is not None
self.assertEqual(1, sum(len(colors) for colors in cached.prototype_face_colors.values()))
located = scene_occurrence_shape(cached, cached.roots[0])
bounds = Bnd_Box()
BRepBndLib.Add_s(located, bounds)
x_min, _y_min, _z_min, x_max, _y_max, _z_max = bounds.Get()
self.assertGreater(x_min, 4.0)
self.assertGreater(x_max, 5.0)
def test_artifact_topology_uses_glb_face_runs_without_duplicate_face_buffers(self) -> None:
with temporary_directory(prefix="cad-topology-v2-") as temp_dir:
step_path = Path(temp_dir) / "box.step"
build123d.export_step(build123d.Box(1, 1, 1), step_path)
scene = load_step_scene(step_path)
bundle = extract_selectors_from_scene(
scene,
cad_ref="fixtures/box",
profile=SelectorProfile.ARTIFACT,
options=SelectorOptions(linear_deflection=0.1, angular_deflection=0.1),
)
self.assertEqual(STEP_TOPOLOGY_SCHEMA_VERSION, bundle.manifest["schemaVersion"])
self.assertTrue(bundle.manifest["capabilities"]["surfaceEdgeRendering"])
self.assertEqual(".box.step.glb", bundle.manifest["faceProxy"]["source"])
self.assertIn("faceRuns", bundle.buffers)
self.assertIn("surfaceHalfEdges", bundle.buffers)
self.assertTrue(scene.glb_mesh_payloads)
self.assertNotIn("facePositions", bundle.buffers)
self.assertNotIn("faceIndices", bundle.buffers)
self.assertNotIn("faceIds", bundle.buffers)
face_columns = bundle.manifest["tables"]["faceColumns"]
triangle_count_column = face_columns.index("triangleCount")
row_triangle_count = sum(int(row[triangle_count_column]) for row in bundle.manifest["faces"])
run_triangle_count = sum(int(bundle.buffers["faceRuns"][index + 3]) for index in range(0, len(bundle.buffers["faceRuns"]), 5))
self.assertEqual(row_triangle_count, run_triangle_count)
def test_shape_rows_include_occurrence_and_prototype_names(self) -> None:
transform = (
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,
)
scene = LoadedStepScene(
step_path=Path("labeled.step"),
roots=[
OccurrenceNode(
path=(1,),
name="base:front_left",
source_name="base",
transform=transform,
prototype_key=7,
)
],
prototype_shapes={7: build123d.Box(1, 1, 1).wrapped},
prototype_names={7: "base"},
)
bundle = extract_selectors_from_scene(
scene,
cad_ref="fixtures/labeled",
profile=SelectorProfile.REFS,
options=SelectorOptions(linear_deflection=0.1, angular_deflection=0.1),
)
shape_columns = bundle.manifest["tables"]["shapeColumns"]
shape = dict(zip(shape_columns, bundle.manifest["shapes"][0]))
self.assertEqual("base:front_left", shape["name"])
self.assertEqual("base", shape["sourceName"])
def test_adaptive_mesh_resolution_prefers_finer_defaults_for_small_simple_parts(self) -> None:
with temporary_directory(prefix="cad-adaptive-mesh-") as temp_dir:
step_path = Path(temp_dir) / "box.step"
build123d.export_step(build123d.Box(10, 8, 4), step_path)
scene = load_step_scene(step_path)
resolution = adaptive_mesh_resolution_for_scene(scene)
self.assertEqual("extra-fine", resolution.profile)
self.assertLess(resolution.settings.tolerance, DEFAULT_MESH_TOLERANCE)
self.assertLess(resolution.settings.angular_tolerance, DEFAULT_MESH_ANGULAR_TOLERANCE)
self.assertEqual(1, resolution.hints["leafOccurrenceCount"])
def test_adaptive_mesh_resolution_does_not_coarsen_simple_repeated_assemblies_by_leaf_count_alone(self) -> None:
box_shape = build123d.Box(10, 8, 4).wrapped
identity = (
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,
)
scene = LoadedStepScene(
step_path=Path("synthetic-repeated-box.step"),
roots=[
OccurrenceNode(
path=(index + 1,),
name=f"box_{index}",
source_name=f"box_{index}",
transform=identity,
prototype_key=1,
)
for index in range(100)
],
prototype_shapes={1: box_shape},
)
resolution = adaptive_mesh_resolution_for_scene(scene)
self.assertEqual("medium", resolution.profile)
self.assertLess(resolution.settings.tolerance, DEFAULT_MESH_TOLERANCE)
self.assertEqual(100, resolution.hints["leafOccurrenceCount"])
def test_adaptive_mesh_resolution_keeps_many_low_curvature_occurrences_balanced(self) -> None:
with mock.patch.object(
step_scene,
"_scene_mesh_resolution_hints",
return_value={
"bboxDiag": 190.0,
"prototypeFaceCount": 420,
"prototypeEdgeCount": 860,
"prototypeCurvedFaceCount": 30,
"prototypeCurvedEdgeCount": 70,
"occurrenceFaceCount": 2957,
"occurrenceEdgeCount": 6012,
"occurrenceCurvedFaceCount": 80,
"occurrenceCurvedEdgeCount": 120,
"leafOccurrenceCount": 481,
"complexityScore": 18083.7,
"effectiveComplexityScore": 18083.7,
"curvaturePressureScore": 280.0,
},
):
resolution = adaptive_mesh_resolution_for_scene(
LoadedStepScene(step_path=Path("repeated-low-curvature.step"), roots=[], prototype_shapes={})
)
self.assertEqual("balanced-assembly", resolution.profile)
self.assertEqual(0.016, resolution.settings.tolerance)
self.assertEqual(0.5, resolution.settings.angular_tolerance)
def test_adaptive_mesh_resolution_uses_large_topology_profile_for_extreme_imports(self) -> None:
with mock.patch.object(
step_scene,
"_scene_mesh_resolution_hints",
return_value={
"bboxDiag": None,
"prototypeFaceCount": 12000,
"prototypeEdgeCount": 30000,
"prototypeCurvedFaceCount": 4000,
"prototypeCurvedEdgeCount": 12000,
"occurrenceFaceCount": 23000,
"occurrenceEdgeCount": 59000,
"occurrenceCurvedFaceCount": 8000,
"occurrenceCurvedEdgeCount": 24000,
"leafOccurrenceCount": 120,
"complexityScore": 60000.0,
"effectiveComplexityScore": 60000.0,
"curvaturePressureScore": 38000.0,
},
):
resolution = adaptive_mesh_resolution_for_scene(
LoadedStepScene(step_path=Path("huge.step"), roots=[], prototype_shapes={})
)
self.assertEqual("large-topology", resolution.profile)
self.assertGreater(resolution.settings.tolerance, DEFAULT_MESH_TOLERANCE)
self.assertGreater(resolution.settings.angular_tolerance, DEFAULT_MESH_ANGULAR_TOLERANCE)
def test_adaptive_mesh_resolution_uses_curvature_pressure_before_raw_counts_explode(self) -> None:
with mock.patch.object(
step_scene,
"_scene_mesh_resolution_hints",
return_value={
"bboxDiag": 120.0,
"prototypeFaceCount": 700,
"prototypeEdgeCount": 1600,
"prototypeCurvedFaceCount": 550,
"prototypeCurvedEdgeCount": 1500,
"occurrenceFaceCount": 700,
"occurrenceEdgeCount": 1600,
"occurrenceCurvedFaceCount": 550,
"occurrenceCurvedEdgeCount": 1500,
"leafOccurrenceCount": 8,
"complexityScore": 2100.0,
"effectiveComplexityScore": 2100.0,
"curvaturePressureScore": 3600.0,
},
):
resolution = adaptive_mesh_resolution_for_scene(
LoadedStepScene(step_path=Path("curvy.step"), roots=[], prototype_shapes={})
)
self.assertEqual("medium", resolution.profile)
self.assertEqual(0.014, resolution.settings.tolerance)
self.assertEqual(0.45, resolution.settings.angular_tolerance)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,58 @@
import shutil
import unittest
from pathlib import Path
from cadpy import render as cad_render
from tests.python.support.cad_test_roots import IsolatedCadRoots
class CadpyRenderTests(unittest.TestCase):
def setUp(self) -> None:
self._isolated_roots = IsolatedCadRoots(self, prefix="cadjs-")
tempdir = self._isolated_roots.temporary_cad_directory(prefix="tmp-cadjs-")
self._tempdir = tempdir
self.temp_root = Path(tempdir.name)
self.relative_dir = self.temp_root.relative_to(cad_render.CAD_ROOT).as_posix()
self.cleanup_paths: set[Path] = set()
def tearDown(self) -> None:
for path in self.cleanup_paths:
path.unlink(missing_ok=True)
shutil.rmtree(self.temp_root, ignore_errors=True)
self._tempdir.cleanup()
def _write_step(self, name: str, *, extension: str = ".step") -> Path:
step_path = self.temp_root / f"{name}{extension}"
step_path.write_text("ISO-10303-21; END-ISO-10303-21;\n")
self.cleanup_paths.update(
(
cad_render.part_glb_path(step_path),
)
)
return step_path
def test_direct_step_has_no_persistent_stl_path(self) -> None:
step_path = self._write_step("part")
with self.assertRaisesRegex(ValueError, "no configured STL output"):
cad_render.part_stl_path(step_path)
def test_glb_path_uses_adjacent_hidden_step_glb(self) -> None:
step_path = self._write_step("part")
glb_path = cad_render.part_glb_path(step_path)
self.assertEqual(self.temp_root / ".part.step.glb", glb_path)
self.assertEqual(self.temp_root / ".part.step" / "model.glb", cad_render.legacy_part_glb_path(step_path))
def test_glb_path_preserves_stp_extension(self) -> None:
step_path = self._write_step("part-stp", extension=".stp")
glb_path = cad_render.part_glb_path(step_path)
self.assertEqual(self.temp_root / ".part-stp.stp.glb", glb_path)
self.assertEqual(self.temp_root / ".part-stp.stp" / "model.glb", cad_render.legacy_part_glb_path(step_path))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,433 @@
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()