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
@@ -0,0 +1,4 @@
from tests.python.support.paths import add_repo_path
add_repo_path("skills/cad/scripts")
add_repo_path("skills/cad/scripts/packages/cadpy/src")
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()
@@ -0,0 +1,77 @@
from __future__ import annotations
import subprocess
import sys
import unittest
from tests.python.support.paths import repo_path
class InspectCliWrapperTests(unittest.TestCase):
def test_inspect_directory_invokes_cli(self) -> None:
skill_root = repo_path("skills/cad")
result = subprocess.run(
[sys.executable, "scripts/inspect", "--help"],
cwd=skill_root,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
self.assertIn("usage: inspect", result.stdout)
def test_inspect_help_does_not_import_heavy_cad_modules(self) -> None:
skill_root = repo_path("skills/cad")
code = (
"import sys; "
"sys.path.insert(0, 'scripts/inspect'); "
"import inspect_refs.cli; "
"print('OCP.OCP' in sys.modules); "
"print('cadpy.step_scene' in sys.modules)"
)
result = subprocess.run(
[sys.executable, "-c", code],
cwd=skill_root,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
self.assertEqual(["False", "False"], result.stdout.strip().splitlines())
def test_scripts_inspect_rejects_render_subcommand(self) -> None:
skill_root = repo_path("skills/cad")
result = subprocess.run(
[sys.executable, "scripts/inspect", "render", "--help"],
cwd=skill_root,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual(2, result.returncode)
self.assertIn("invalid choice", result.stderr)
def test_scripts_inspect_worker_reads_jsonl(self) -> None:
skill_root = repo_path("skills/cad")
result = subprocess.run(
[sys.executable, "scripts/inspect", "worker"],
cwd=skill_root,
input='{"id":"bad","argv":["refs"]}\n',
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
self.assertIn('"id":"bad"', result.stdout)
self.assertIn('"exitCode":2', result.stdout)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,933 @@
import contextlib
import io
import shutil
import unittest
from array import array
from pathlib import Path
from unittest import mock
from tests.python.support.paths import add_repo_path
add_repo_path("skills/cad/scripts/inspect")
from inspect_refs import cli as inspect_cli
from inspect_refs import inspect as refs_inspect
from cadpy import cad_ref_syntax as refs_syntax
from cadpy import assembly_spec
from cadpy import generation as cad_generation
from cadpy import step_targets
from cadpy.glb_topology import STEP_TOPOLOGY_SCHEMA_VERSION
from cadpy.render import part_glb_path
from cadpy.selector_types import SelectorBundle, SelectorProfile
from cadpy.source_hash import python_source_hash
from tests.python.support.cad_test_roots import IsolatedCadRoots
def _refs_manifest(cad_ref: str) -> dict[str, object]:
return {
"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION,
"profile": "refs",
"cadPath": cad_ref,
"stepPath": f"{cad_ref}.step",
"stepHash": "step-hash-123",
"bbox": {"min": [0.0, 0.0, 0.0], "max": [10.0, 10.0, 10.0]},
"stats": {
"occurrenceCount": 2,
"leafOccurrenceCount": 1,
"shapeCount": 1,
"faceCount": 2,
"edgeCount": 2,
"vertexCount": 1,
},
"tables": {
"occurrenceColumns": [
"id",
"path",
"name",
"sourceName",
"parentId",
"transform",
"bbox",
"shapeStart",
"shapeCount",
"faceStart",
"faceCount",
"edgeStart",
"edgeCount",
"vertexStart",
"vertexCount",
],
"shapeColumns": [
"id",
"occurrenceId",
"ordinal",
"kind",
"bbox",
"center",
"area",
"volume",
"faceStart",
"faceCount",
"edgeStart",
"edgeCount",
"vertexStart",
"vertexCount",
],
"faceColumns": [
"id",
"occurrenceId",
"shapeId",
"ordinal",
"surfaceType",
"area",
"center",
"normal",
"bbox",
"edgeStart",
"edgeCount",
"relevance",
"flags",
"params",
"triangleStart",
"triangleCount",
],
"edgeColumns": [
"id",
"occurrenceId",
"shapeId",
"ordinal",
"curveType",
"length",
"center",
"bbox",
"faceStart",
"faceCount",
"vertexStart",
"vertexCount",
"relevance",
"flags",
"params",
"segmentStart",
"segmentCount",
],
"vertexColumns": [
"id",
"occurrenceId",
"shapeId",
"ordinal",
"center",
"bbox",
"edgeStart",
"edgeCount",
"relevance",
"flags",
],
},
"occurrences": [
[
"o1",
"1",
"Root",
"Root",
None,
[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
{"min": [0.0, 0.0, 0.0], "max": [10.0, 10.0, 10.0]},
0,
1,
0,
2,
0,
2,
0,
1,
],
[
"o1.2",
"1.2",
"Bracket",
"Bracket",
"o1",
[1, 0, 0, 5, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
{"min": [5.0, 0.0, 0.0], "max": [10.0, 10.0, 10.0]},
0,
1,
0,
2,
0,
2,
0,
1,
],
],
"shapes": [
[
"o1.2.s1",
"o1.2",
1,
"solid",
{"min": [5.0, 0.0, 0.0], "max": [10.0, 10.0, 10.0]},
[7.5, 5.0, 5.0],
100.0,
250.0,
0,
2,
0,
2,
0,
1,
]
],
"faces": [
[
"o1.2.f1",
"o1.2",
"o1.2.s1",
1,
"plane",
20.0,
[6.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
{"min": [5.0, 0.0, 0.0], "max": [7.0, 2.0, 0.0]},
0,
2,
80,
0,
{"origin": [5.0, 0.0, 0.0], "axis": [0.0, 0.0, 1.0]},
0,
0,
],
[
"o1.2.f2",
"o1.2",
"o1.2.s1",
2,
"cylinder",
12.0,
[7.0, 2.0, 1.0],
[1.0, 0.0, 0.0],
{"min": [6.0, 1.0, 0.0], "max": [8.0, 3.0, 2.0]},
1,
0,
60,
0,
{"center": [7.0, 2.0, 1.0], "axis": [1.0, 0.0, 0.0], "radius": 1.0},
0,
0,
],
],
"edges": [
[
"o1.2.e1",
"o1.2",
"o1.2.s1",
1,
"line",
4.0,
[6.0, 1.0, 0.0],
{"min": [5.0, 0.0, 0.0], "max": [7.0, 2.0, 0.0]},
0,
2,
0,
1,
90,
0,
{"origin": [5.0, 0.0, 0.0], "direction": [1.0, 0.0, 0.0]},
0,
0,
],
[
"o1.2.e2",
"o1.2",
"o1.2.s1",
2,
"line",
3.0,
[5.5, 0.5, 0.0],
{"min": [5.0, 0.0, 0.0], "max": [6.0, 1.0, 0.0]},
2,
1,
1,
1,
75,
0,
{"origin": [5.0, 0.0, 0.0], "direction": [0.0, 1.0, 0.0]},
0,
0,
],
],
"vertices": [
[
"o1.2.v1",
"o1.2",
"o1.2.s1",
1,
[5.0, 0.0, 0.0],
{"min": [5.0, 0.0, 0.0], "max": [5.0, 0.0, 0.0]},
0,
2,
95,
0,
]
],
"assemblyMates": [
{
"id": "m1",
"label": "m1",
"sourceLabel": "face_to_face:block_pocket_floor_offset:bottom_center",
"type": "face_to_face",
"fixed": "block_pocket_floor:offset",
"moving": "bottom_center",
"parameters": {"offset": 0.2},
"fixedEndpoint": {
"position": [6.0, 1.0, 0.0],
"axes": {
"z": [0.0, 0.0, 1.0],
},
},
"movingEndpoint": {
"position": [7.0, 2.0, 1.0],
"axes": {
"z": [0.0, 0.0, 1.0],
},
},
}
],
"relations": {
"faceEdgeRows": [0, 1, 0],
"edgeFaceRows": [0, 1, 0],
"edgeVertexRows": [0, 0],
"vertexEdgeRows": [0, 1],
},
}
def _summary_manifest(cad_ref: str) -> dict[str, object]:
return {
"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION,
"profile": "summary",
"cadPath": cad_ref,
"stepPath": f"{cad_ref}.step",
"stepHash": "step-hash-123",
"bbox": {"min": [0.0, 0.0, 0.0], "max": [10.0, 10.0, 10.0]},
"stats": {
"occurrenceCount": 1,
"leafOccurrenceCount": 1,
"shapeCount": 1,
"faceCount": 2,
"edgeCount": 2,
"vertexCount": 1,
},
"tables": {
"occurrenceColumns": [
"id",
"path",
"name",
"sourceName",
"parentId",
"transform",
"bbox",
"shapeStart",
"shapeCount",
"faceStart",
"faceCount",
"edgeStart",
"edgeCount",
"vertexStart",
"vertexCount",
],
"shapeColumns": [],
"faceColumns": [],
"edgeColumns": [],
"vertexColumns": [],
},
"occurrences": [
[
"o1",
"1",
"Part",
"Part",
None,
[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
{"min": [0.0, 0.0, 0.0], "max": [10.0, 10.0, 10.0]},
0,
1,
0,
2,
0,
2,
0,
1,
]
],
"shapes": [],
"faces": [],
"edges": [],
"vertices": [],
}
class InspectRefsSyntaxTests(unittest.TestCase):
def test_normalize_selector_list_inherits_occurrence_prefix(self) -> None:
selectors = refs_syntax.normalize_selector_list("o1.2.f12,f13,e7,v2,s3")
self.assertEqual(
["o1.2.f12", "o1.2.f13", "o1.2.e7", "o1.2.v2", "o1.2.s3"],
selectors,
)
class InspectRefsTests(unittest.TestCase):
def setUp(self) -> None:
self._isolated_roots = IsolatedCadRoots(self, prefix="refs-inspect-")
tempdir = self._isolated_roots.temporary_cad_directory(prefix="tmp-refs-inspect-")
self._tempdir = tempdir
self.temp_root = Path(tempdir.name)
self.relative_dir = self.temp_root.relative_to(assembly_spec.CAD_ROOT).as_posix()
self.lookup_ref = f"{self.relative_dir}/sample"
self.cad_ref = self.lookup_ref
self.step_path = self.temp_root / "sample.step"
self.step_path.write_text("ISO-10303-21; END-ISO-10303-21;\n")
self.addCleanup(self._tempdir.cleanup)
self.addCleanup(lambda: shutil.rmtree(self.temp_root, ignore_errors=True))
def _touch_glb(self, step_path: Path | None = None) -> Path:
glb_path = part_glb_path(step_path or self.step_path)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_bytes(b"glb")
return glb_path
@contextlib.contextmanager
def _mock_glb_topology(
self,
manifest: dict[str, object],
*,
step_path: Path | None = None,
buffers: dict[str, array] | None = None,
include_selector: bool = True,
include_index: bool = True,
current_hash: str | None = None,
strip_selector_keys: tuple[str, ...] = (),
):
resolved_step_path = step_path or self.step_path
edge_rendering = {
"visibilityClasses": ["feature", "tangent", "seam", "degenerate"],
"generatedVisibilityClasses": ["feature"],
"visibilityClassCounts": {"feature": 1},
"generatedVisibilityClassCounts": {"feature": 1},
}
mesh = {
"linearDeflection": 0.006,
"angularDeflection": 0.2,
"relative": True,
"resolution": {
"profile": "extra-fine",
"hints": {
"effectiveComplexityScore": 2,
"curvaturePressureScore": 2,
"leafOccurrenceCount": 1,
"occurrenceFaceCount": 2,
"occurrenceEdgeCount": 2,
},
},
}
topology_manifest = {"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION, **manifest}
source_kind = str(topology_manifest.get("sourceKind") or "step").strip().lower()
source_path = resolved_step_path.with_suffix(".py") if source_kind == "python" else resolved_step_path
topology_manifest.setdefault("sourceKind", source_kind)
topology_manifest.setdefault("sourcePath", self._manifest_path(source_path))
topology_manifest.setdefault("stepPath", self._manifest_path(resolved_step_path))
topology_manifest.setdefault("edgeRendering", edge_rendering)
topology_manifest.setdefault("mesh", mesh)
edge_manifest = {
"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION,
"profile": "surface-edges",
"edgeRendering": edge_rendering,
"primitiveAttributes": {
"barycentric": "_CAD_EDGE_BARYCENTRIC",
"class": "_CAD_EDGE_CLASS",
},
"buffers": {"views": {"surfaceHalfEdges": {}}},
}
if source_kind == "python":
edge_manifest["sourceKind"] = "python"
edge_manifest["sourcePath"] = topology_manifest.get("sourcePath")
edge_manifest["sourceHash"] = topology_manifest.get("sourceHash")
if topology_manifest.get("stepHash"):
edge_manifest["stepHash"] = topology_manifest.get("stepHash")
else:
edge_manifest["sourceKind"] = "step"
edge_manifest["sourcePath"] = topology_manifest.get("sourcePath")
edge_manifest["stepHash"] = topology_manifest.get("stepHash")
self._touch_glb(resolved_step_path)
stack = contextlib.ExitStack()
with stack:
stack.enter_context(mock.patch.object(step_targets, "find_step_path", return_value=resolved_step_path))
expected_step_hash = str(topology_manifest.get("stepHash") or "") if current_hash is None else current_hash
stack.enter_context(mock.patch.object(step_targets, "step_file_hash", return_value=expected_step_hash))
stack.enter_context(mock.patch.object(cad_generation, "step_file_hash", return_value=expected_step_hash))
stack.enter_context(
mock.patch.object(
step_targets,
"read_step_topology_manifest_from_glb",
return_value=topology_manifest if include_index else None,
)
)
stack.enter_context(
mock.patch.object(
step_targets,
"read_step_display_edge_manifest_from_glb",
return_value=edge_manifest,
)
)
stack.enter_context(mock.patch.object(step_targets, "glb_primitives_have_surface_edge_attributes", return_value=True))
stack.enter_context(mock.patch.object(step_targets, "glb_surface_edge_class_has_nonzero_values", return_value=True))
selector_topology_manifest = {
key: value for key, value in topology_manifest.items() if key not in strip_selector_keys
}
stack.enter_context(
mock.patch.object(
step_targets,
"read_step_topology_bundle_from_glb",
return_value=(
SelectorBundle(manifest=selector_topology_manifest, buffers=buffers or {})
if include_selector
else None
),
)
)
yield
def _manifest_path(self, path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(assembly_spec.REPO_ROOT).as_posix()
except ValueError:
return resolved.as_posix()
def test_whole_entry_summary_uses_glb_index(self) -> None:
with self._mock_glb_topology(_summary_manifest(self.cad_ref), include_selector=False):
result = refs_inspect.inspect_cad_refs(self.cad_ref)
self.assertTrue(result["ok"])
token = result["tokens"][0]
self.assertEqual(1, token["summary"]["occurrenceCount"])
self.assertEqual(2, token["summary"]["faceCount"])
self.assertEqual([], token["selections"])
def test_facts_kind_falls_back_to_index_manifest_for_assembly(self) -> None:
manifest = {**_refs_manifest(self.cad_ref), "entryKind": "assembly"}
with self._mock_glb_topology(manifest, strip_selector_keys=("entryKind", "assembly")):
result = refs_inspect.inspect_cad_refs(self.cad_ref, facts=True)
self.assertTrue(result["ok"])
token = result["tokens"][0]
self.assertEqual("assembly", token["summary"]["kind"])
self.assertEqual("assembly", token["entryFacts"]["kind"])
def test_python_backed_glb_only_entry_inspects_without_step_file(self) -> None:
self.step_path.unlink()
script_path = self.step_path.with_suffix(".py")
script_path.write_text("def gen_step():\n return object()\n", encoding="utf-8")
source_identity = python_source_hash(script_path)
manifest = {
**_summary_manifest(self.cad_ref),
"sourceKind": "python",
"sourceHash": source_identity.source_hash,
}
with self._mock_glb_topology(manifest, include_selector=False):
result = refs_inspect.inspect_cad_refs(self.cad_ref)
self.assertTrue(result["ok"])
token = result["tokens"][0]
self.assertEqual(refs_inspect._relative_to_repo(self.step_path), token["stepPath"])
self.assertEqual(1, token["summary"]["occurrenceCount"])
def test_context_provider_can_supply_in_memory_entry_context(self) -> None:
requested_profiles = []
def provider(cad_path, profile):
requested_profiles.append(profile)
if cad_path != self.cad_ref:
return None
manifest = _summary_manifest(cad_path)
return refs_inspect.EntryContext(
cad_path=cad_path,
kind="part",
source_path=self.step_path,
step_path=self.step_path,
manifest=manifest,
selector_index=refs_inspect.lookup.build_selector_index(manifest),
)
result = refs_inspect.inspect_cad_refs(self.cad_ref, context_provider=provider)
self.assertTrue(result["ok"])
self.assertEqual([SelectorProfile.SUMMARY], requested_profiles)
self.assertEqual(2, result["tokens"][0]["summary"]["faceCount"])
def test_face_lookup_resolves_single_occurrence_alias_and_detail(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#o1.2.f1", detail=True)
self.assertTrue(result["ok"])
selection = result["tokens"][0]["selections"][0]
self.assertEqual("face", selection["selectorType"])
self.assertEqual("o1.2.f1", selection["normalizedSelector"])
self.assertEqual("plane area=20.0", selection["summary"])
self.assertEqual(["e1", "e2"], selection["detail"]["adjacentEdgeSelectors"])
def test_assembly_mate_lookup_resolves_numbered_ref(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#m1", detail=True, positioning=True)
self.assertTrue(result["ok"])
selection = result["tokens"][0]["selections"][0]
self.assertEqual("mate", selection["selectorType"])
self.assertEqual("m1", selection["normalizedSelector"])
self.assertEqual("#m1", selection["copyText"])
self.assertEqual("Mate face_to_face:block_pocket_floor_offset:bottom_center", selection["label"])
self.assertEqual("face_to_face block_pocket_floor:offset -> bottom_center", selection["summary"])
self.assertEqual("face_to_face:block_pocket_floor_offset:bottom_center", selection["detail"]["sourceLabel"])
self.assertEqual({"offset": 0.2}, selection["detail"]["parameters"])
self.assertEqual("mate", selection["positioning"]["selectorType"])
self.assertEqual([6.0, 1.0, 0.0], selection["positioning"]["fixedEndpoint"]["position"])
def test_vertex_lookup_resolves_corner_detail(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#o1.2.v1", detail=True)
self.assertTrue(result["ok"])
selection = result["tokens"][0]["selections"][0]
self.assertEqual("vertex", selection["selectorType"])
self.assertEqual("o1.2.v1", selection["normalizedSelector"])
self.assertEqual("corner edges=2", selection["summary"])
self.assertEqual(["e1", "e2"], selection["detail"]["adjacentEdgeSelectors"])
self.assertEqual(["f1", "f2"], selection["detail"]["adjacentFaceSelectors"])
def test_single_occurrence_alias_is_compacted_in_copy_text(self) -> None:
with self._mock_glb_topology(_summary_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#f2", detail=True)
self.assertFalse(result["ok"])
with self._mock_glb_topology(
{
**_refs_manifest(self.cad_ref),
"stats": {
"occurrenceCount": 1,
"leafOccurrenceCount": 1,
"shapeCount": 1,
"faceCount": 2,
"edgeCount": 2,
"vertexCount": 1,
},
"occurrences": [_refs_manifest(self.cad_ref)["occurrences"][1]],
},
):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#v1", detail=True)
self.assertTrue(result["ok"])
selection = result["tokens"][0]["selections"][0]
self.assertEqual("v1", selection["displaySelector"])
self.assertEqual("#v1", selection["copyText"])
def test_old_part_selector_syntax_is_rejected(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#p:legacy.f1")
self.assertFalse(result["ok"])
self.assertEqual("selector", result["errors"][0]["kind"])
def test_topology_flag_returns_full_selector_lists(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(self.cad_ref, include_topology=True)
self.assertTrue(result["ok"])
topology = result["tokens"][0]["topology"]
self.assertIn("f1", topology["faces"])
self.assertIn("e1", topology["edges"])
self.assertIn("v1", topology["vertices"])
def test_detail_uses_glb_buffer_backed_relation_rows(self) -> None:
manifest = {
**_refs_manifest(self.cad_ref),
"relations": {
"faceEdgeRowsView": "faceEdgeRows",
"edgeFaceRowsView": "edgeFaceRows",
"edgeVertexRowsView": "edgeVertexRows",
"vertexEdgeRowsView": "vertexEdgeRows",
},
}
buffers = {
"faceEdgeRows": array("I", [0, 1, 0]),
"edgeFaceRows": array("I", [0, 1, 0]),
"edgeVertexRows": array("I", [0, 0]),
"vertexEdgeRows": array("I", [0, 1]),
}
with self._mock_glb_topology(manifest, buffers=buffers):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#o1.2.f1", detail=True)
self.assertTrue(result["ok"])
selection = result["tokens"][0]["selections"][0]
self.assertEqual(["e1", "e2"], selection["detail"]["adjacentEdgeSelectors"])
def test_missing_glb_topology_is_an_inspect_error(self) -> None:
result = refs_inspect.inspect_cad_refs(self.cad_ref)
self.assertFalse(result["ok"])
error = result["errors"][0]
self.assertEqual("glb_regeneration_failed", error["code"])
self.assertIn("\nRegenerate STEP artifacts with the following command using the CAD skill:", error["message"])
self.assertNotIn("scripts.step", error["message"])
self.assertIn("regenerateCommand", error)
self.assertEqual("python scripts/step", error["regenerateCommand"])
def test_missing_selector_topology_is_an_inspect_error(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref), include_selector=False):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#f1")
self.assertFalse(result["ok"])
error = result["errors"][0]
self.assertEqual("glb_regeneration_failed", error["code"])
self.assertIn("\nRegenerate STEP artifacts with the following command using the CAD skill:", error["message"])
def test_missing_step_topology_is_an_inspect_error(self) -> None:
with self._mock_glb_topology(_summary_manifest(self.cad_ref), include_index=False):
result = refs_inspect.inspect_cad_refs(self.cad_ref)
self.assertFalse(result["ok"])
error = result["errors"][0]
self.assertEqual("glb_regeneration_failed", error["code"])
self.assertIn("\nRegenerate STEP artifacts with the following command using the CAD skill:", error["message"])
def test_unsupported_step_topology_is_an_inspect_error(self) -> None:
self._touch_glb()
manifest = {**_summary_manifest(self.cad_ref), "schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION + 1}
with mock.patch.object(step_targets, "find_step_path", return_value=self.step_path), mock.patch.object(
step_targets,
"read_step_topology_manifest_from_glb",
return_value=manifest,
):
result = refs_inspect.inspect_cad_refs(self.cad_ref)
self.assertFalse(result["ok"])
error = result["errors"][0]
self.assertEqual("glb_regeneration_failed", error["code"])
self.assertIn("\nRegenerate STEP artifacts with the following command using the CAD skill:", error["message"])
def test_stale_glb_topology_is_an_inspect_error(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref), current_hash="new-step-hash"):
result = refs_inspect.inspect_cad_refs(self.cad_ref)
self.assertFalse(result["ok"])
error = result["errors"][0]
self.assertEqual("glb_regeneration_failed", error["code"])
self.assertIn("\nRegenerate STEP artifacts with the following command using the CAD skill:", error["message"])
def test_legacy_cad_ref_mismatch_is_accepted_when_hash_matches(self) -> None:
with self._mock_glb_topology({**_refs_manifest("other/ref"), "stepHash": "step-hash-123"}):
result = refs_inspect.inspect_cad_refs(self.cad_ref)
self.assertTrue(result["ok"])
self.assertEqual(self.cad_ref, result["tokens"][0]["cadPath"])
def test_non_leaf_occurrence_detail_reports_children(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#o1", detail=True)
self.assertTrue(result["ok"])
selection = result["tokens"][0]["selections"][0]
self.assertEqual("occurrence", selection["selectorType"])
self.assertEqual("o1", selection["normalizedSelector"])
self.assertEqual(1, selection["detail"]["childCount"])
self.assertEqual(["o1.2"], selection["detail"]["descendantOccurrenceIds"])
def test_assembly_topology_lookup_resolves_from_generated_step(self) -> None:
assembly_cad_ref = f"{self.relative_dir}/sample-assembly"
assembly_path = self.temp_root / "sample-assembly.py"
assembly_step_path = self.temp_root / "sample-assembly.step"
assembly_path.write_text(
"def gen_step():\n"
" return {'instances': []}\n",
encoding="utf-8",
)
assembly_step_path.write_text("ISO-10303-21; END-ISO-10303-21;\n", encoding="utf-8")
source_identity = python_source_hash(assembly_path)
with mock.patch.object(
step_targets,
"resolve_cad_source_path",
return_value=("assembly", assembly_path),
), self._mock_glb_topology(
{
**_refs_manifest(assembly_cad_ref),
"sourceKind": "python",
"sourceHash": source_identity.source_hash,
"stepHash": cad_generation.step_file_hash(assembly_step_path),
},
step_path=assembly_step_path,
):
result = refs_inspect.inspect_cad_refs(assembly_cad_ref, "#o1.2.f1", detail=True)
self.assertTrue(result["ok"])
selection = result["tokens"][0]["selections"][0]
self.assertEqual("assembly", result["tokens"][0]["summary"]["kind"])
self.assertEqual("face", selection["selectorType"])
self.assertEqual("o1.2.f1", selection["normalizedSelector"])
def test_positioning_flag_returns_plane_facts(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(
self.cad_ref,
"#o1.2.f1",
positioning=True,
)
self.assertTrue(result["ok"])
positioning = result["tokens"][0]["selections"][0]["positioning"]
self.assertEqual("plane", positioning["kind"])
self.assertEqual("z", positioning["axis"])
self.assertEqual(0.0, positioning["coordinate"])
self.assertEqual([0.0, 0.0, 1.0], positioning["normal"])
def test_planes_flag_returns_entry_planes(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(
self.cad_ref,
planes=True,
plane_coordinate_tolerance=0.01,
plane_min_area_ratio=0.0,
plane_limit=1,
)
self.assertTrue(result["ok"])
planes = result["tokens"][0]["planes"]
self.assertEqual(1, len(planes))
self.assertEqual("z", planes[0]["axis"])
def test_refs_text_format_includes_entry_reports(self) -> None:
result = {
"ok": True,
"tokens": [
{
"cadPath": self.cad_ref,
"summary": {"faceCount": 2, "edgeCount": 2},
"entryFacts": {
"size": [10.0, 10.0, 10.0],
"center": [5.0, 5.0, 5.0],
"extentAxis": "x",
"diag": 17.320508,
"kind": "part",
},
"planes": [
{
"axis": "z",
"coordinate": 0.0,
"normalSign": 1,
"faceCount": 1,
"totalArea": 100.0,
}
],
"selections": [],
}
],
"errors": [],
}
text = inspect_cli._format_refs_text(result, quiet=False, verbose=False)
self.assertIn("facts: size=[10, 10, 10]", text)
self.assertIn("planes: 1 major groups", text)
self.assertIn("z=0", text)
def test_diff_planes_returns_entry_planes(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.diff_entry_targets(
self.cad_ref,
self.cad_ref,
planes=True,
plane_limit=1,
)
self.assertTrue(result["ok"])
self.assertEqual(1, len(result["diff"]["leftMajorPlanes"]))
self.assertEqual(1, len(result["diff"]["rightMajorPlanes"]))
def test_cli_parses_current_agentic_commands(self) -> None:
parser = inspect_cli.build_parser()
refs_args = parser.parse_args(["refs", "entry.step", "#f1", "--detail", "--facts"])
self.assertEqual("refs", refs_args.command)
self.assertTrue(refs_args.detail)
self.assertTrue(refs_args.facts)
diff_args = parser.parse_args(
["diff", "left", "right", "--planes", "--plane-coordinate-tolerance", "0.02", "--plane-limit", "3"]
)
self.assertTrue(diff_args.planes)
self.assertEqual(0.02, diff_args.plane_coordinate_tolerance)
self.assertEqual(3, diff_args.plane_limit)
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as render_exit:
parser.parse_args(["render", "list", "part.step", "--format", "text"])
self.assertEqual(2, render_exit.exception.code)
worker_args = parser.parse_args(["worker"])
self.assertEqual("worker", worker_args.command)
top_level_verbose_args = parser.parse_args(["--verbose", "refs", "entry.step", "#f1"])
self.assertTrue(top_level_verbose_args.verbose)
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as verbose_render_exit:
parser.parse_args(["--verbose", "render", "view", "part.step", "--output", "part.png"])
self.assertEqual(2, verbose_render_exit.exception.code)
def test_worker_response_wraps_inspect_result(self) -> None:
response = inspect_cli._worker_response('{"id":"missing-input","argv":["refs"]}')
self.assertFalse(response["ok"])
self.assertEqual("missing-input", response["id"])
self.assertEqual(2, response["exitCode"])
self.assertIn("No STEP/CAD entry target provided", response["result"]["errors"][0]["message"])
def test_frame_command_returns_occurrence_axes(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_target_frame(self.cad_ref, "#o1.2")
self.assertTrue(result["ok"])
self.assertEqual([5.0, 0.0, 0.0], result["frame"]["translation"])
self.assertEqual([1.0, 0.0, 0.0], result["frame"]["localAxes"]["x"])
def test_measure_targets_returns_signed_axis_distance(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.measure_targets(
self.cad_ref,
"#o1.2.f1",
"#o1.2.f2",
axis="x",
)
self.assertTrue(result["ok"])
self.assertEqual("x", result["axis"])
self.assertEqual(5.0, result["from"]["coordinate"])
self.assertEqual(7.0, result["to"]["coordinate"])
self.assertEqual(2.0, result["measurement"]["signedDistance"])
def test_align_targets_returns_flush_translation_delta(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.align_targets(
self.cad_ref,
"#o1.2.f1",
"#o1.2.f2",
axis="x",
)
self.assertTrue(result["ok"])
self.assertEqual([2.0, 0.0, 0.0], result["alignment"]["translationVector"])
self.assertEqual(2.0, result["alignment"]["transformTranslationDelta"]["3"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,655 @@
from __future__ import annotations
import asyncio
import io
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace
from tests.python.support.paths import add_repo_path, repo_path
add_repo_path("skills/cad/scripts")
import snapshot.__main__ as snapshot_main
from snapshot.__main__ import (
RENDER_HTML_PATH,
RUNTIME_DIR,
SnapshotError,
load_job_from_options,
parse_snapshot_args,
resolve_render_job_packet,
resolve_snapshot_route_file,
timestamp_output_path,
)
class _TtyStringIO(io.StringIO):
def isatty(self) -> bool:
return True
def _selector_artifact(*occurrence_ids: str) -> SimpleNamespace:
return SimpleNamespace(
selector_bundle=SimpleNamespace(
manifest={
"tables": {
"occurrenceColumns": ["id"],
"shapeColumns": ["id", "occurrenceId"],
},
"occurrences": [[occurrence_id] for occurrence_id in occurrence_ids],
"shapes": [],
},
buffers={},
)
)
class SnapshotCliTests(unittest.TestCase):
def test_cli_import_does_not_import_heavy_cad_modules(self) -> None:
skill_root = repo_path("skills/cad")
code = (
"import sys; sys.path.insert(0, 'scripts'); import snapshot.__main__; "
"print('OCP.OCP' in sys.modules); "
"print('cadpy.step_scene' in sys.modules)"
)
result = subprocess.run(
[sys.executable, "-c", code],
cwd=skill_root,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
self.assertEqual(["False", "False"], result.stdout.strip().splitlines())
def test_shortcut_job_shape_stays_owned_by_python_cli(self) -> None:
options = parse_snapshot_args(
[
"--input",
"models/simple/cylindrical_cap.step",
"--output",
"tmp/cap.png",
"--display",
"wireframe",
"--size-profile",
"simple",
]
)
job = load_job_from_options(options, stdin=_TtyStringIO(), cwd=Path.cwd())
self.assertEqual(job["input"], "models/simple/cylindrical_cap.step")
self.assertNotIn("workspaceRoot", job)
self.assertNotIn("rootDir", job)
self.assertEqual(job["outputs"][0]["path"], "tmp/cap.png")
self.assertEqual(job["display"], {"mode": "wireframe"})
self.assertEqual(job["render"]["sizeProfile"], "simple")
def test_shortcut_focus_and_hide_flags_are_mutually_exclusive(self) -> None:
with self.assertRaisesRegex(SnapshotError, "--focus and --hide cannot be used"):
parse_snapshot_args(
[
"--input",
"models/assembly.step",
"--output",
"tmp/assembly.png",
"--focus",
"#o1.2",
"--hide=#o1.3.1",
]
)
def test_display_shortcut_accepts_cad_display_modes(self) -> None:
for raw_mode, expected_display in [
("edges", {"mode": "solid"}),
("x-ray", {"mode": "transparent"}),
("hidden edges visible", {"mode": "hidden_edges"}),
("hidden-lines-removed", {"mode": "hidden_lines_removed"}),
("flat", {"mode": "unshaded"}),
("appearance", {"mode": "rendered"}),
("wire", {"mode": "wireframe"}),
]:
options = parse_snapshot_args(
[
"--input",
"models/simple/cylindrical_cap.step",
"--output",
"tmp/cap.png",
"--display",
raw_mode,
]
)
job = load_job_from_options(options, stdin=_TtyStringIO(), cwd=Path.cwd())
self.assertEqual(job["display"], expected_display)
def test_display_json_accepts_exploded_settings(self) -> None:
options = parse_snapshot_args(
[
"--input",
"models/simple/cylindrical_cap.step",
"--output",
"tmp/cap.png",
"--display",
'{"projection":"perspective","mode":"rendered","exploded":{"enabled":true,"axis":"radial","spacing":1.6}}',
]
)
job = load_job_from_options(options, stdin=_TtyStringIO(), cwd=Path.cwd())
self.assertEqual(
job["display"],
{
"projection": "perspective",
"mode": "rendered",
"exploded": {"enabled": True, "axis": "radial", "spacing": 1.6},
},
)
def test_edge_settings_belong_to_display_json(self) -> None:
options = parse_snapshot_args(
[
"--input",
"models/simple/cylindrical_cap.step",
"--output",
"tmp/cap.png",
"--display",
'{"edges":{"enabled":false,"color":"#123456"}}',
]
)
job = load_job_from_options(options, stdin=_TtyStringIO(), cwd=Path.cwd())
self.assertEqual(job["display"], {"edges": {"enabled": False, "color": "#123456"}})
appearance_options = parse_snapshot_args(
[
"--input",
"models/simple/cylindrical_cap.step",
"--output",
"tmp/cap.png",
"--appearance",
'{"edges":{"enabled":false}}',
]
)
with self.assertRaisesRegex(SnapshotError, "unsupported keys: edges"):
load_job_from_options(appearance_options, stdin=_TtyStringIO(), cwd=Path.cwd())
def test_display_shortcut_rejects_unknown_modes(self) -> None:
options = parse_snapshot_args(
[
"--input",
"models/simple/cylindrical_cap.step",
"--output",
"tmp/cap.png",
"--display",
"mist",
]
)
with self.assertRaisesRegex(SnapshotError, "Unsupported display mode"):
load_job_from_options(options, stdin=_TtyStringIO(), cwd=Path.cwd())
def test_display_shortcut_rejects_exploded_mode_alias(self) -> None:
options = parse_snapshot_args(
[
"--input",
"models/simple/cylindrical_cap.step",
"--output",
"tmp/cap.png",
"--display",
"exploded",
]
)
with self.assertRaisesRegex(SnapshotError, "Unsupported display mode"):
load_job_from_options(options, stdin=_TtyStringIO(), cwd=Path.cwd())
def test_shortcut_focus_flags_apply_selection(self) -> None:
options = parse_snapshot_args(
[
"--input",
"models/assembly.step",
"--output",
"tmp/assembly.png",
"--focus",
"#o1.2",
"#o1.3",
]
)
job = load_job_from_options(options, stdin=_TtyStringIO(), cwd=Path.cwd())
self.assertEqual(
job["selection"],
{
"focus": ["#o1.2", "#o1.3"],
},
)
def test_output_paths_are_timestamped_when_jobs_are_resolved(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
(models / "part.step").write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
(models / ".part.step.glb").write_bytes(b"glb")
original_timestamp = snapshot_main.snapshot_timestamp
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.snapshot_timestamp = lambda: "20260527T163012Z"
snapshot_main.ensure_step_topology_artifact = lambda *args, **kwargs: None
packet = resolve_render_job_packet(
{
"jobs": [
{
"input": "models/part.step",
"outputs": [
{"path": "tmp/iso.png", "camera": "iso"},
{"path": "tmp/front.png", "camera": "front"},
],
},
{
"input": "models/part.step",
"mode": "orbit",
"outputs": [{"path": "tmp/orbit.gif"}],
},
]
},
cwd=root,
)
finally:
snapshot_main.snapshot_timestamp = original_timestamp
snapshot_main.ensure_step_topology_artifact = original_ensure
output_paths = [
Path(output["path"]).relative_to(root).as_posix()
for job in packet["jobs"]
for output in job["outputs"]
]
self.assertEqual(
output_paths,
[
"tmp/iso_20260527T163012Z.png",
"tmp/front_20260527T163012Z.png",
"tmp/orbit_20260527T163012Z.gif",
],
)
def test_render_job_derives_asset_root_from_input_path(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
(models / "part.step").write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
(models / ".part.step.glb").write_bytes(b"glb")
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.ensure_step_topology_artifact = lambda *args, **kwargs: None
packet = resolve_render_job_packet(
{
"input": "models/part.step",
"outputs": [{"path": "tmp/iso.png", "camera": "iso"}],
},
cwd=root,
)
finally:
snapshot_main.ensure_step_topology_artifact = original_ensure
job = packet["jobs"][0]
self.assertNotIn("workspaceRoot", job)
self.assertNotIn("rootDir", job)
self.assertEqual(job["resolved"]["rootPath"], str(models))
self.assertEqual(job["resolved"]["inputUrl"], "/__render_asset/part.step")
self.assertEqual(job["resolved"]["glbUrl"], "/__render_asset/.part.step.glb")
def test_render_job_ensures_step_artifact_for_step_input(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
step_path = models / "part.step"
step_path.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
(models / ".part.step.glb").write_bytes(b"glb")
calls = []
def fake_ensure(target, **kwargs):
calls.append((target, kwargs))
return None
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.ensure_step_topology_artifact = fake_ensure
resolve_render_job_packet(
{
"input": "models/part.step",
"outputs": [{"path": "tmp/iso.png", "camera": "iso"}],
},
cwd=root,
)
finally:
snapshot_main.ensure_step_topology_artifact = original_ensure
self.assertEqual(len(calls), 1)
target, kwargs = calls[0]
self.assertEqual(target.step_path, step_path)
self.assertEqual(target.source_path, step_path)
self.assertEqual(kwargs["owner"], "cad-snapshot")
self.assertFalse(kwargs["require_selector"])
def test_render_job_rejects_non_step_input_without_artifact_generation(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
(models / "robot.urdf").write_text("<robot name=\"r\" />\n", encoding="utf-8")
calls = []
def fake_ensure(target, **kwargs):
calls.append((target, kwargs))
return None
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.ensure_step_topology_artifact = fake_ensure
with self.assertRaisesRegex(
SnapshotError,
"Snapshot supports only STEP/STP inputs or same-stem Python generators",
):
resolve_render_job_packet(
{
"input": "models/robot.urdf",
"outputs": [{"path": "tmp/iso.png", "camera": "iso"}],
},
cwd=root,
)
finally:
snapshot_main.ensure_step_topology_artifact = original_ensure
self.assertEqual(calls, [])
def test_render_job_requires_selector_topology_for_cad_refs(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
step_path = models / "assembly.step"
step_path.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
(models / ".assembly.step.glb").write_bytes(b"glb")
calls = []
def fake_ensure(target, **kwargs):
calls.append((target, kwargs))
return _selector_artifact("o1", "o1.2")
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.ensure_step_topology_artifact = fake_ensure
resolve_render_job_packet(
{
"input": "models/assembly.step",
"selection": {"focus": ["#o1.2"]},
"outputs": [{"path": "tmp/iso.png", "camera": "iso"}],
},
cwd=root,
)
finally:
snapshot_main.ensure_step_topology_artifact = original_ensure
self.assertEqual(len(calls), 1)
target, kwargs = calls[0]
self.assertEqual(target.step_path, step_path)
self.assertTrue(kwargs["require_selector"])
def test_render_job_normalizes_focus_selector_refs(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
(models / "assembly.step").write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
(models / ".assembly.step.glb").write_bytes(b"glb")
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.ensure_step_topology_artifact = lambda *args, **kwargs: _selector_artifact(
"o1",
"o1.2",
"o1.2.1",
"o1.3",
)
packet = resolve_render_job_packet(
{
"input": "models/assembly.step",
"selection": {
"focus": ["#o1.2", "#o1.3"],
},
"outputs": [{"path": "tmp/iso.png", "camera": "iso"}],
},
cwd=root,
)
finally:
snapshot_main.ensure_step_topology_artifact = original_ensure
selection = packet["jobs"][0]["selection"]
self.assertEqual(selection["focus"], ["o1.2", "o1.3"])
def test_render_job_normalizes_hide_selector_refs(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
(models / "assembly.step").write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
(models / ".assembly.step.glb").write_bytes(b"glb")
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.ensure_step_topology_artifact = lambda *args, **kwargs: _selector_artifact(
"o1",
"o1.2",
"o1.2.1",
"o1.3",
)
packet = resolve_render_job_packet(
{
"input": "models/assembly.step",
"selection": {"hide": ["#o1.2.1"]},
"outputs": [{"path": "tmp/iso.png", "camera": "iso"}],
},
cwd=root,
)
finally:
snapshot_main.ensure_step_topology_artifact = original_ensure
selection = packet["jobs"][0]["selection"]
self.assertEqual(selection["hide"], ["o1.2.1"])
def test_render_job_rejects_face_focus_refs(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
(models / "assembly.step").write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
(models / ".assembly.step.glb").write_bytes(b"glb")
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.ensure_step_topology_artifact = lambda *args, **kwargs: _selector_artifact("o1", "o1.2")
with self.assertRaisesRegex(SnapshotError, "part/subassembly occurrence refs"):
resolve_render_job_packet(
{
"input": "models/assembly.step",
"selection": {"focus": ["#o1.2.f1"]},
"outputs": [{"path": "tmp/iso.png", "camera": "iso"}],
},
cwd=root,
)
finally:
snapshot_main.ensure_step_topology_artifact = original_ensure
def test_render_job_rejects_mixed_focus_and_hide_selection(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
(models / "assembly.step").write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
(models / ".assembly.step.glb").write_bytes(b"glb")
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.ensure_step_topology_artifact = lambda *args, **kwargs: _selector_artifact(
"o1",
"o1.2",
"o1.3",
)
with self.assertRaisesRegex(SnapshotError, "selection.focus/refs and selection.hide cannot be used"):
resolve_render_job_packet(
{
"input": "models/assembly.step",
"selection": {
"focus": ["#o1.2"],
"hide": ["#o1.3"],
},
"outputs": [{"path": "tmp/iso.png", "camera": "iso"}],
},
cwd=root,
)
finally:
snapshot_main.ensure_step_topology_artifact = original_ensure
def test_snapshot_root_flags_and_job_fields_are_removed(self) -> None:
with self.assertRaisesRegex(SnapshotError, "Unknown argument: --workspace-root"):
parse_snapshot_args(["--workspace-root", "/tmp"])
with self.assertRaisesRegex(SnapshotError, "Unknown argument: --root-dir"):
parse_snapshot_args(["--root-dir", "models"])
with self.assertRaisesRegex(SnapshotError, "no longer accept workspaceRoot or rootDir"):
resolve_render_job_packet(
{
"input": "part.step",
"workspaceRoot": "/tmp",
"outputs": [{"path": "tmp/iso.png"}],
},
cwd=Path.cwd(),
)
def test_timestamp_output_path_preserves_extension(self) -> None:
self.assertEqual(
timestamp_output_path("snapshots/review.png", "20260527T163012Z"),
"snapshots/review_20260527T163012Z.png",
)
def test_removed_daemon_flags_stay_removed(self) -> None:
with self.assertRaisesRegex(SnapshotError, "daemon commands have been removed"):
parse_snapshot_args(["daemon"])
with self.assertRaisesRegex(SnapshotError, "--socket has been removed"):
parse_snapshot_args(["--socket", "snapshot.sock"])
def test_runtime_routes_are_self_contained(self) -> None:
self.assertEqual(
resolve_snapshot_route_file("http://snapshot.local/render.html"),
RENDER_HTML_PATH,
)
self.assertEqual(
resolve_snapshot_route_file("http://snapshot.local/snapshot-render.js"),
RUNTIME_DIR / "snapshot-render.js",
)
def test_snapshot_renderer_does_not_force_chromium_single_process(self) -> None:
captured_launch_options = {}
class FakePage:
async def route(self, *args, **kwargs):
pass
async def goto(self, *args, **kwargs):
pass
async def wait_for_function(self, *args, **kwargs):
pass
class FakeContext:
async def new_page(self):
return FakePage()
async def close(self):
pass
class FakeBrowser:
async def new_context(self, *args, **kwargs):
return FakeContext()
async def close(self):
pass
class FakeChromium:
async def launch(self, **kwargs):
captured_launch_options.update(kwargs)
return FakeBrowser()
class FakePlaywright:
def __init__(self) -> None:
self.chromium = FakeChromium()
async def stop(self):
pass
fake_playwright = FakePlaywright()
class FakeAsyncPlaywright:
async def start(self):
return fake_playwright
async_api_module = ModuleType("playwright.async_api")
async_api_module.async_playwright = FakeAsyncPlaywright
playwright_module = ModuleType("playwright")
playwright_module.__path__ = []
original_playwright = sys.modules.get("playwright")
original_async_api = sys.modules.get("playwright.async_api")
try:
sys.modules["playwright"] = playwright_module
sys.modules["playwright.async_api"] = async_api_module
async def start_renderer() -> None:
renderer = snapshot_main.BatchSnapshotRenderer()
try:
await renderer.start()
finally:
await renderer.close()
asyncio.run(start_renderer())
finally:
if original_playwright is None:
sys.modules.pop("playwright", None)
else:
sys.modules["playwright"] = original_playwright
if original_async_api is None:
sys.modules.pop("playwright.async_api", None)
else:
sys.modules["playwright.async_api"] = original_async_api
self.assertNotIn("--single-process", captured_launch_options.get("args") or [])
def test_snapshot_tool_has_no_sideways_runtime_dependencies(self) -> None:
snapshot_root = repo_path("skills/cad/scripts/snapshot")
checked_files = [
snapshot_root / "__main__.py",
snapshot_root / "runtime" / "render.html",
snapshot_root / "runtime" / "snapshot-render.js",
]
forbidden = (
"packages/cadjs",
"skills/cad-viewer",
"/node_modules/",
"\\node_modules\\",
"CADJS_NODE_MODULES_ROOT",
)
for checked_file in checked_files:
text = checked_file.read_text(encoding="utf-8")
for token in forbidden:
self.assertNotIn(token, text, f"{checked_file} should not reference {token}")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,147 @@
import contextlib
import io
import subprocess
import sys
import unittest
from unittest import mock
from tests.python.support.paths import add_repo_path, repo_path
add_repo_path("skills/cad/scripts")
from step import cli
class StepCliTests(unittest.TestCase):
def test_requires_explicit_target(self) -> None:
with self.assertRaises(SystemExit) as cm:
cli.main([])
self.assertEqual(2, cm.exception.code)
def test_passes_targets_in_order_without_kind_for_generated_sources(self) -> None:
with mock.patch.object(cli, "generate_step_targets", return_value=0) as generate:
self.assertEqual(0, cli.main(["parts/second.py", "parts/first.py"]))
generate.assert_called_once()
self.assertEqual(["parts/second.py", "parts/first.py"], generate.call_args.args[0])
self.assertIsNone(generate.call_args.kwargs["direct_step_kind"])
self.assertFalse(generate.call_args.kwargs["step_options"].has_metadata)
self.assertIsNone(generate.call_args.kwargs["output"])
self.assertFalse(generate.call_args.kwargs["skip_step_write"])
self.assertFalse(generate.call_args.kwargs["verbose"])
def test_passes_direct_step_kind(self) -> None:
with mock.patch.object(cli, "generate_step_targets", return_value=0) as generate:
self.assertEqual(0, cli.main(["--kind", "assembly", "assemblies/second.step", "assemblies/first.step"]))
generate.assert_called_once()
self.assertEqual(["assemblies/second.step", "assemblies/first.step"], generate.call_args.args[0])
self.assertEqual("assembly", generate.call_args.kwargs["direct_step_kind"])
self.assertFalse(generate.call_args.kwargs["step_options"].has_metadata)
self.assertIsNone(generate.call_args.kwargs["output"])
self.assertFalse(generate.call_args.kwargs["verbose"])
def test_passes_verbose_flag(self) -> None:
with mock.patch.object(cli, "generate_step_targets", return_value=0) as generate:
self.assertEqual(0, cli.main(["parts/sample.py", "--verbose"]))
self.assertTrue(generate.call_args.kwargs["verbose"])
def test_passes_skip_step_write_flag(self) -> None:
with mock.patch.object(cli, "generate_step_targets", return_value=0) as generate:
self.assertEqual(0, cli.main(["parts/sample.py", "--skip-step-write"]))
self.assertTrue(generate.call_args.kwargs["skip_step_write"])
def test_passes_output_flag(self) -> None:
with mock.patch.object(cli, "generate_step_targets", return_value=0) as generate:
self.assertEqual(0, cli.main(["parts/sample.py", "-o", "STEP/sample.step"]))
self.assertEqual("STEP/sample.step", generate.call_args.kwargs["output"])
def test_output_flag_rejects_multiple_targets(self) -> None:
with self.assertRaises(SystemExit) as cm:
cli.main(["parts/first.py", "parts/second.py", "-o", "STEP/first.step"])
self.assertEqual(2, cm.exception.code)
def test_passes_import_metadata_flags(self) -> None:
with mock.patch.object(cli, "generate_step_targets", return_value=0) as generate:
self.assertEqual(
0,
cli.main(
[
"--kind",
"part",
"imports/sample_part.step",
"--stl",
"../meshes/sample_part.stl",
"--3mf",
"../meshes/sample_part.3mf",
"--glb",
"../meshes/sample_part.glb",
"--mesh-tolerance",
"0.2",
"--mesh-angular-tolerance",
"0.25",
]
),
)
generate.assert_called_once()
self.assertEqual(["imports/sample_part.step"], generate.call_args.args[0])
self.assertEqual("part", generate.call_args.kwargs["direct_step_kind"])
options = generate.call_args.kwargs["step_options"]
self.assertEqual("../meshes/sample_part.stl", options.stl)
self.assertEqual("../meshes/sample_part.3mf", options.three_mf)
self.assertEqual("../meshes/sample_part.glb", options.glb)
self.assertEqual(0.2, options.mesh_tolerance)
self.assertEqual(0.25, options.mesh_angular_tolerance)
def test_rejects_invalid_numeric_flag(self) -> None:
with self.assertRaises(SystemExit) as cm:
cli.main(["imports/sample_part.step", "--mesh-tolerance", "nan"])
self.assertEqual(2, cm.exception.code)
def test_rejects_removed_inspect_flag(self) -> None:
with self.assertRaises(SystemExit) as cm:
cli.main(["parts/sample.step", "--inspect", "refs {cad_token} --facts"])
self.assertEqual(2, cm.exception.code)
def test_help_has_current_step_flags_only(self) -> None:
stream = io.StringIO()
with self.assertRaises(SystemExit) as cm, contextlib.redirect_stdout(stream):
cli.main(["--help"])
self.assertEqual(0, cm.exception.code)
help_text = stream.getvalue()
self.assertIn("--kind", help_text)
self.assertIn("--stl", help_text)
self.assertIn("--3mf", help_text)
self.assertIn("--glb", help_text)
self.assertIn("--skip-step-write", help_text)
self.assertIn("--output", help_text)
self.assertIn("--mesh-tolerance", help_text)
self.assertIn("--verbose", help_text)
def test_cli_does_not_reserve_common_module_name(self) -> None:
skill_root = repo_path("skills/cad")
code = (
"import sys; sys.path.insert(0, 'scripts'); import step.cli; "
"print('common' in sys.modules); "
"print('OCP.OCP' in sys.modules); "
"print('cadpy.step_scene' in sys.modules)"
)
result = subprocess.run(
[sys.executable, "-c", code],
cwd=skill_root,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
self.assertEqual(["False", "False", "False"], result.stdout.strip().splitlines())
if __name__ == "__main__":
unittest.main()