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