first commit
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user