Files
cadSet/text-to-cad/tests/python/skills/cad/cadpy/test_generation.py
T
2026-07-22 19:38:36 +08:00

2692 lines
108 KiB
Python

import hashlib
import shutil
import unittest
from pathlib import Path
from unittest import mock
from cadpy import generation as cad_generation
from cadpy import render as cad_render
from cadpy import catalog as cad_catalog
from cadpy import source_hash as cad_source_hash
from cadpy.assembly_spec import (
AssemblyInstanceSpec,
AssemblyNodeSpec,
AssemblySpec,
)
from cadpy.catalog import StepImportOptions
from cadpy.glb_topology import STEP_TOPOLOGY_SCHEMA_VERSION
from cadpy.step_scene import LoadedStepScene, OccurrenceNode, SelectorBundle
from cadpy.step_metadata import TEXT_TO_CAD_GENERATOR, read_text_to_cad_step_metadata
from tests.python.support.cad_test_roots import IsolatedCadRoots
IDENTITY_TRANSFORM = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 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": [1.0, 1.0, 1.0]},
"stats": {
"occurrenceCount": 1,
"leafOccurrenceCount": 1,
"shapeCount": 1,
"faceCount": 6,
"edgeCount": 12,
},
"tables": {
"occurrenceColumns": [
"id",
"path",
"name",
"sourceName",
"parentId",
"transform",
"bbox",
"shapeStart",
"shapeCount",
"faceStart",
"faceCount",
"edgeStart",
"edgeCount",
],
"shapeColumns": [],
"faceColumns": [],
"edgeColumns": [],
},
"occurrences": [
[
"o1",
"1",
"Part",
"Part",
None,
IDENTITY_TRANSFORM,
{"min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 1.0]},
0,
1,
0,
6,
0,
12,
]
],
"shapes": [],
"faces": [],
"edges": [],
}
class CadGenerationTests(unittest.TestCase):
def setUp(self) -> None:
self._isolated_roots = IsolatedCadRoots(self, prefix="cad-generation-")
tempdir = self._isolated_roots.temporary_cad_directory(prefix="tmp-cad-")
self._tempdir = tempdir
self.temp_root = Path(tempdir.name)
self.relative_dir = self.temp_root.relative_to(cad_generation.CAD_ROOT).as_posix()
def tearDown(self) -> None:
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_step_at(
self,
directory: Path,
name: str,
*,
suffix: str = ".step",
) -> Path:
step_path = directory / f"{name}{suffix}"
step_path.write_text("ISO-10303-21; END-ISO-10303-21;\n", encoding="utf-8")
return step_path
def _step_options(
self,
*,
stl: str | None = None,
three_mf: str | None = None,
glb: str | None = None,
mesh_tolerance: float | None = None,
mesh_angular_tolerance: float | None = None,
) -> StepImportOptions:
return StepImportOptions(
stl=stl,
three_mf=three_mf,
glb=glb,
mesh_tolerance=mesh_tolerance,
mesh_angular_tolerance=mesh_angular_tolerance,
)
def _write_step(
self,
name: str,
*,
suffix: str = ".step",
) -> Path:
return self._write_step_at(self.temp_root, name, suffix=suffix)
def test_catalog_discovery_ignores_urdf_only_generators(self) -> None:
self._write_step("sample")
(self._isolated_roots.cad_root / "sample_urdf.py").write_text(
"def gen_urdf():\n"
" return {'xml': '<robot name=\"sample\" />'}\n",
encoding="utf-8",
)
(self._isolated_roots.cad_root / "sample_sdf.py").write_text(
"def gen_sdf():\n"
" return {'xml': '<sdf version=\"1.12\"><model name=\"sample\" /></sdf>'}\n",
encoding="utf-8",
)
sources = cad_catalog.iter_cad_sources()
self.assertIn(self._cad_ref("sample"), {source.cad_ref for source in sources})
self.assertNotIn("sample_urdf", {source.cad_ref for source in sources})
self.assertNotIn("sample_sdf", {source.cad_ref for source in sources})
def _generator_script(
self,
name: str,
*,
with_dxf: bool = False,
with_urdf: bool = False,
with_sdf: bool = False,
dxf_before_step: bool = False,
step_output: str | None = None,
stl: str | None = None,
three_mf: str | None = None,
dxf_output: str | None = None,
urdf_output: str | None = None,
sdf_output: str | None = None,
mesh_tolerance: float | None = None,
mesh_angular_tolerance: float | None = None,
) -> Path:
fields: list[str] = ["'shape': _shape()"]
if step_output is not None:
fields.append(f"'step_output': {step_output!r}")
if stl is not None:
fields.append(f"'stl': {stl!r}")
if three_mf is not None:
fields.append(f"'3mf': {three_mf!r}")
if mesh_tolerance is not None:
fields.append(f"'mesh_tolerance': {mesh_tolerance!r}")
if mesh_angular_tolerance is not None:
fields.append(f"'mesh_angular_tolerance': {mesh_angular_tolerance!r}")
prologue = [
"from pathlib import Path",
f'DISPLAY_NAME = "{name}"',
"CALLS = Path(__file__).with_suffix('.calls')",
"def _output_path(suffix, output):",
" path = Path(__file__).parent / output if output else Path(__file__).with_suffix(suffix)",
" path.parent.mkdir(parents=True, exist_ok=True)",
" return path",
"def _record(name):",
" with CALLS.open('a', encoding='utf-8') as handle:",
" handle.write(name + '\\n')",
"class _FakeDxf:",
" def saveas(self, output_path):",
" Path(output_path).write_text('0\\nEOF\\n', encoding='utf-8')",
"def _shape():",
" import build123d",
" return build123d.Box(1, 1, 1)",
"",
]
step_block = [
"def gen_step():",
" _record('gen_step')",
" return {",
*[f" {field}," for field in fields],
" }",
"",
]
dxf_block = [
"def gen_dxf():",
" _record('gen_dxf')",
" return {",
" 'document': _FakeDxf(),",
*([f" 'dxf_output': {dxf_output!r},"] if dxf_output is not None else []),
" }",
"",
]
urdf_block = [
"def gen_urdf():",
" _record('gen_urdf')",
" return {",
" 'xml': '<robot name=\"part\"><link name=\"base\" /></robot>',",
*([f" 'urdf_output': {urdf_output!r},"] if urdf_output is not None else []),
" }",
"",
]
sdf_block = [
"def gen_sdf():",
" _record('gen_sdf')",
" return {",
" 'xml': '<sdf version=\"1.12\"><model name=\"part\"><link name=\"base\" /></model></sdf>',",
*([f" 'sdf_output': {sdf_output!r},"] if sdf_output is not None else []),
" }",
"",
]
blocks = [prologue]
if with_dxf and dxf_before_step:
blocks.append(dxf_block)
blocks.append(step_block)
if with_dxf and not dxf_before_step:
blocks.append(dxf_block)
if with_urdf:
blocks.append(urdf_block)
if with_sdf:
blocks.append(sdf_block)
script_path = self.temp_root / f"{name}.py"
script_path.write_text("\n".join(line for block in blocks for line in block), encoding="utf-8")
return script_path
def _write_assembly_generator(
self,
name: str,
*,
instances: list[dict[str, object]],
with_dxf: bool = False,
with_urdf: bool = False,
with_sdf: bool = False,
step_output: str | None = None,
stl: str | None = None,
three_mf: str | None = None,
dxf_output: str | None = None,
urdf_output: str | None = None,
sdf_output: str | None = None,
mesh_tolerance: float | None = None,
mesh_angular_tolerance: float | None = None,
) -> Path:
fields: list[str] = [f"'instances': {instances!r}"]
if step_output is not None:
fields.append(f"'step_output': {step_output!r}")
if stl is not None:
fields.append(f"'stl': {stl!r}")
if three_mf is not None:
fields.append(f"'3mf': {three_mf!r}")
if mesh_tolerance is not None:
fields.append(f"'mesh_tolerance': {mesh_tolerance!r}")
if mesh_angular_tolerance is not None:
fields.append(f"'mesh_angular_tolerance': {mesh_angular_tolerance!r}")
lines = [
"from pathlib import Path",
"CALLS = Path(__file__).with_suffix('.calls')",
"def _output_path(suffix, output):",
" path = Path(__file__).parent / output if output else Path(__file__).with_suffix(suffix)",
" path.parent.mkdir(parents=True, exist_ok=True)",
" return path",
"def _record(name):",
" with CALLS.open('a', encoding='utf-8') as handle:",
" handle.write(name + '\\n')",
"class _FakeDxf:",
" def saveas(self, output_path):",
" Path(output_path).write_text('0\\nEOF\\n', encoding='utf-8')",
"",
"def gen_step():",
" _record('gen_step')",
" return {",
*[f" {field}," for field in fields],
" }",
"",
]
if with_dxf:
lines.extend(
[
"def gen_dxf():",
" _record('gen_dxf')",
" return {",
" 'document': _FakeDxf(),",
*([f" 'dxf_output': {dxf_output!r},"] if dxf_output is not None else []),
" }",
"",
]
)
if with_urdf:
lines.extend(
[
"def gen_urdf():",
" _record('gen_urdf')",
" return {",
" 'xml': '<robot name=\"sample\"><link name=\"base\" /></robot>',",
*([f" 'urdf_output': {urdf_output!r},"] if urdf_output is not None else []),
" }",
"",
]
)
if with_sdf:
lines.extend(
[
"def gen_sdf():",
" _record('gen_sdf')",
" return {",
" 'xml': '<sdf version=\"1.12\"><model name=\"sample\"><link name=\"base\" /></model></sdf>',",
*([f" 'sdf_output': {sdf_output!r},"] if sdf_output is not None else []),
" }",
"",
]
)
assembly_path = self.temp_root / f"{name}.py"
assembly_path.write_text("\n".join(lines), encoding="utf-8")
return assembly_path
def test_generated_part_discovery_includes_missing_step_output(self) -> None:
script_path = self._generator_script("flat")
specs = [spec for spec in cad_generation.list_entry_specs() if spec.cad_ref == self._cad_ref("flat")]
self.assertEqual(1, len(specs))
self.assertEqual("part", specs[0].kind)
self.assertEqual(script_path, specs[0].source_path)
self.assertFalse(specs[0].step_path.exists())
def test_generated_part_discovery_ignores_virtualenv_python(self) -> None:
self._generator_script("flat")
dependency_dir = self.temp_root / ".venv" / "lib" / "python3.13" / "site-packages"
dependency_dir.mkdir(parents=True)
(dependency_dir / "dependency.py").write_bytes(b"\xe9")
specs = [spec for spec in cad_generation.list_entry_specs() if spec.cad_ref == self._cad_ref("flat")]
self.assertEqual(1, len(specs))
def test_generated_part_discovery_ignores_non_generator_decode_failures(self) -> None:
self._generator_script("flat")
(self.temp_root / "notes.py").write_bytes(b"\xe9")
specs = [spec for spec in cad_generation.list_entry_specs() if spec.cad_ref == self._cad_ref("flat")]
self.assertEqual(1, len(specs))
def test_python_source_hash_uses_generator_file_contents(self) -> None:
script_path = self.temp_root / "uses_helper.py"
helper_path = self.temp_root / "helper.py"
helper_path.write_text("SIZE = 1\n", encoding="utf-8")
script_path.write_text(
"\n".join(
[
"from helper import SIZE",
"def gen_step():",
" import build123d",
" return build123d.Box(SIZE, 1, 1)",
"",
]
),
encoding="utf-8",
)
before = cad_generation.python_source_hash(script_path)
helper_path.write_text("SIZE = 2\n", encoding="utf-8")
after = cad_generation.python_source_hash(script_path)
script_path.write_text(script_path.read_text(encoding="utf-8") + "\n# changed\n", encoding="utf-8")
script_changed = cad_generation.python_source_hash(script_path)
self.assertEqual(before.source_hash, after.source_hash)
self.assertNotEqual(before.source_hash, script_changed.source_hash)
def test_python_source_hash_has_no_manifest_payloads(self) -> None:
script_path = self.temp_root / "source.py"
script_path.write_text(
"def gen_step():\n"
" return object()\n",
encoding="utf-8",
)
identity = cad_source_hash.python_source_hash(script_path)
self.assertTrue(identity.source_hash)
self.assertFalse(hasattr(identity, "files"))
self.assertFalse(hasattr(identity, "manifest_files"))
def test_generated_step_output_is_not_discovered_as_imported_step(self) -> None:
self._generator_script("flat")
(self.temp_root / "flat.step").write_text("ISO-10303-21; END-ISO-10303-21;\n", encoding="utf-8")
specs = [spec for spec in cad_generation.list_entry_specs() if spec.cad_ref == self._cad_ref("flat")]
self.assertEqual(1, len(specs))
self.assertEqual("generated", specs[0].source)
def test_generated_source_defaults_step_output_to_sibling_stem(self) -> None:
script_path = self.temp_root / "missing_output.py"
script_path.write_text(
"\n".join(
[
"def gen_step():",
" return {'shape': object()}",
"",
]
),
encoding="utf-8",
)
spec = next(spec for spec in cad_generation.list_entry_specs() if spec.source_path == script_path)
self.assertEqual(script_path.with_suffix(".step"), spec.step_path)
self.assertEqual(self._cad_ref("missing_output"), spec.cad_ref)
def test_generated_part_ignores_sidecar_metadata(self) -> None:
script_path = self._generator_script(
"flat",
with_dxf=True,
with_sdf=True,
stl="../meshes/renamed.stl",
three_mf="../meshes/renamed.3mf",
)
spec = next(spec for spec in cad_generation.list_entry_specs() if spec.source_path == script_path)
self.assertEqual(self._cad_ref("flat"), spec.cad_ref)
self.assertEqual(self.temp_root / "flat.step", spec.step_path)
self.assertEqual(self.temp_root / "flat.dxf", spec.dxf_path)
self.assertEqual(self.temp_root / "flat.sdf", spec.sdf_path)
self.assertIsNone(spec.stl_path)
self.assertIsNone(spec.three_mf_path)
def test_generated_source_rejects_legacy_step_output_field(self) -> None:
self._generator_script("flat", step_output="custom/renamed.step")
with self.assertRaisesRegex(ValueError, "unsupported field\\(s\\): step_output"):
cad_generation.list_entry_specs()
def test_generated_dxf_rejects_legacy_dxf_output_field(self) -> None:
self._generator_script("flat", with_dxf=True, dxf_output="../drawings/renamed.dxf")
with self.assertRaisesRegex(ValueError, "unsupported field\\(s\\): dxf_output"):
cad_generation.list_entry_specs()
def test_generated_urdf_rejects_legacy_urdf_output_field(self) -> None:
self._generator_script("flat", with_urdf=True, urdf_output="robot/flat.urdf")
with self.assertRaisesRegex(ValueError, "unsupported field\\(s\\): urdf_output"):
cad_generation.list_entry_specs()
def test_generated_sdf_rejects_legacy_sdf_output_field(self) -> None:
self._generator_script("flat", with_sdf=True, sdf_output="robot/flat.sdf")
with self.assertRaisesRegex(ValueError, "unsupported field\\(s\\): sdf_output"):
cad_generation.list_entry_specs()
def test_generated_sdf_outputs_default_to_sibling_stems(self) -> None:
self._generator_script("first", with_sdf=True)
self._generator_script("second", with_sdf=True)
specs = {
spec.cad_ref: spec
for spec in cad_generation.list_entry_specs()
if spec.cad_ref.startswith(f"{self.relative_dir}/")
}
self.assertEqual(self.temp_root / "first.sdf", specs[self._cad_ref("first")].sdf_path)
self.assertEqual(self.temp_root / "second.sdf", specs[self._cad_ref("second")].sdf_path)
def test_generated_source_rejects_legacy_parent_output(self) -> None:
self._generator_script("flat", step_output="../../../flat.step")
with self.assertRaisesRegex(ValueError, "unsupported field\\(s\\): step_output"):
cad_generation.list_entry_specs()
def test_generated_source_rejects_invalid_legacy_output_suffix(self) -> None:
self._generator_script("flat", step_output="flat.stp")
with self.assertRaisesRegex(ValueError, "unsupported field\\(s\\): step_output"):
cad_generation.list_entry_specs()
def test_generated_dxf_defaults_output_to_sibling_stem(self) -> None:
script_path = self.temp_root / "flat.py"
script_path.write_text(
"\n".join(
[
"def gen_step():",
" return {'shape': object()}",
"",
"def gen_dxf():",
" return {'document': object()}",
"",
]
),
encoding="utf-8",
)
spec = next(spec for spec in cad_generation.list_entry_specs() if spec.source_path == script_path)
self.assertEqual(script_path.with_suffix(".dxf"), spec.dxf_path)
def test_generated_urdf_and_sdf_direct_returns_default_to_sibling_stems(self) -> None:
script_path = self.temp_root / "robot.py"
script_path.write_text(
"\n".join(
[
"def gen_step():",
" return {'shape': object()}",
"",
"def gen_urdf():",
" return '<robot name=\"sample\" />'",
"",
"def gen_sdf():",
" return '<sdf version=\"1.12\"><model name=\"sample\" /></sdf>'",
"",
]
),
encoding="utf-8",
)
spec = next(spec for spec in cad_generation.list_entry_specs() if spec.source_path == script_path)
self.assertEqual(script_path.with_suffix(".urdf"), spec.urdf_path)
self.assertEqual(script_path.with_suffix(".sdf"), spec.sdf_path)
def test_bare_shape_return_is_supported_for_step_generation(self) -> None:
script_path = self.temp_root / "bare_part.py"
script_path.write_text(
"\n".join(
[
"def gen_step():",
" import build123d",
" return build123d.Box(1, 1, 1)",
"",
]
),
encoding="utf-8",
)
spec = next(spec for spec in cad_generation.list_entry_specs() if spec.source_path == script_path)
scene = cad_generation.run_script_generator(spec, "gen_step")
self.assertEqual("part", spec.kind)
self.assertTrue(script_path.with_suffix(".step").exists())
metadata = read_text_to_cad_step_metadata(script_path.with_suffix(".step"))
self.assertEqual(TEXT_TO_CAD_GENERATOR, metadata.get("generator"))
self.assertEqual("part", metadata.get("entryKind"))
self.assertEqual(cad_generation.python_source_hash(script_path).source_hash, metadata.get("sourceHash"))
self.assertIsNotNone(scene)
self.assertEqual("python", scene.source_kind)
self.assertEqual(cad_generation.python_source_hash(script_path).source_hash, scene.source_hash)
def test_bare_assembly_children_return_is_supported(self) -> None:
self._write_step("leaf")
script_path = self.temp_root / "bare_assembly.py"
script_path.write_text(
"\n".join(
[
f"IDENTITY = {IDENTITY_TRANSFORM!r}",
"def gen_step():",
" return [{'path': 'leaf.step', 'name': 'leaf', 'transform': IDENTITY}]",
"",
]
),
encoding="utf-8",
)
spec = next(spec for spec in cad_generation.list_entry_specs() if spec.source_path == script_path)
assembly_spec = cad_generation.read_assembly_spec(script_path)
self.assertEqual("assembly", spec.kind)
self.assertEqual(1, len(assembly_spec.children))
self.assertEqual("leaf", assembly_spec.children[0].name)
def test_bare_dxf_document_return_is_supported(self) -> None:
script_path = self.temp_root / "bare_dxf.py"
script_path.write_text(
"\n".join(
[
"from pathlib import Path",
"class _FakeDxf:",
" def saveas(self, output_path):",
" Path(output_path).write_text('0\\nEOF\\n', encoding='utf-8')",
"def gen_step():",
" import build123d",
" return build123d.Box(1, 1, 1)",
"def gen_dxf():",
" return _FakeDxf()",
"",
]
),
encoding="utf-8",
)
cad_generation.generate_dxf_targets([str(script_path)])
self.assertTrue(script_path.with_suffix(".dxf").exists())
def test_generator_stl_sidecar_paths_are_ignored_by_catalog(self) -> None:
self._generator_script("left", stl="shared.stl")
self._generator_script("right", stl="shared.stl")
specs = {
spec.cad_ref: spec
for spec in cad_generation.list_entry_specs()
if spec.cad_ref.startswith(f"{self.relative_dir}/")
}
self.assertIsNone(specs[self._cad_ref("left")].stl_path)
self.assertIsNone(specs[self._cad_ref("right")].stl_path)
def test_generator_3mf_sidecar_paths_are_ignored_by_catalog(self) -> None:
self._generator_script("left", three_mf="shared.3mf")
self._generator_script("right", three_mf="shared.3mf")
specs = {
spec.cad_ref: spec
for spec in cad_generation.list_entry_specs()
if spec.cad_ref.startswith(f"{self.relative_dir}/")
}
self.assertIsNone(specs[self._cad_ref("left")].three_mf_path)
self.assertIsNone(specs[self._cad_ref("right")].three_mf_path)
def test_direct_step_is_discovered_as_imported_part(self) -> None:
self._write_step("loose")
specs = [spec for spec in cad_generation.list_entry_specs() if spec.cad_ref == self._cad_ref("loose")]
self.assertEqual(1, len(specs))
self.assertEqual("part", specs[0].kind)
self.assertEqual(self.temp_root / "loose.step", specs[0].step_path)
def test_list_entry_specs_can_use_custom_root(self) -> None:
scoped_root = self.temp_root / "scoped"
scoped_root.mkdir()
self._write_step_at(scoped_root, "only")
self._write_step("outside")
specs = cad_generation.list_entry_specs(scoped_root)
self.assertEqual([f"{self.relative_dir}/scoped/only"], [spec.cad_ref for spec in specs])
def test_selection_requires_explicit_targets(self) -> None:
scoped_root = self.temp_root / "scoped"
scoped_root.mkdir()
self._write_step_at(scoped_root, "leaf")
self._write_assembly_generator(
"dependent-assembly",
instances=[
{
"path": "scoped/leaf.step",
"name": "leaf",
"transform": IDENTITY_TRANSFORM,
}
],
)
all_specs = [
spec
for spec in cad_generation.list_entry_specs()
if spec.cad_ref.startswith(f"{self.relative_dir}/")
]
with self.assertRaisesRegex(ValueError, "At least one CAD target is required"):
cad_generation.selected_entry_specs(all_specs, [])
def test_entry_selection_is_exact_and_ordered(self) -> None:
self._write_step("first")
self._write_step("second")
specs = [
spec
for spec in cad_generation.list_entry_specs()
if spec.cad_ref.startswith(f"{self.relative_dir}/")
]
selected = cad_generation.selected_entry_specs(
specs,
[self._cad_ref("second"), self._cad_ref("first"), self._cad_ref("second")],
)
self.assertEqual(
[self._cad_ref("second"), self._cad_ref("first"), self._cad_ref("second")],
[spec.cad_ref for spec in selected],
)
def test_direct_step_generation_regenerates_selected_entries_in_supplied_order(self) -> None:
first_path = self._write_step("first")
second_path = self._write_step("second")
calls: list[str] = []
def fake_generate(spec, *, entries_by_step_path):
self.assertIn(spec.step_path.resolve(), entries_by_step_path)
calls.append(spec.cad_ref)
with mock.patch.object(cad_generation, "_generate_step_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets(
[str(second_path), str(first_path)],
direct_step_kind="part",
)
self.assertEqual([self._cad_ref("second"), self._cad_ref("first")], calls)
def test_step_generation_passes_skip_step_write_flag(self) -> None:
script_path = self._generator_script("fast")
calls: list[bool] = []
def fake_generate(spec, *, entries_by_step_path, skip_step_write=False):
self.assertIn(spec.step_path.resolve(), entries_by_step_path)
calls.append(skip_step_write)
with mock.patch.object(cad_generation, "_generate_step_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets(
[str(script_path)],
skip_step_write=True,
)
self.assertEqual([True], calls)
def test_step_generation_rejects_skip_step_write_for_direct_step_target(self) -> None:
step_path = self._write_step("direct")
with self.assertRaisesRegex(ValueError, "--skip-step-write is valid only for Python gen_step"):
cad_generation.generate_step_targets(
[str(step_path)],
direct_step_kind="part",
skip_step_write=True,
)
def test_step_generation_rejects_skip_step_write_with_output_override(self) -> None:
script_path = self._generator_script("artifact-only")
with self.assertRaisesRegex(ValueError, "--skip-step-write cannot be combined with STEP output overrides"):
cad_generation.generate_step_targets(
[str(script_path)],
output=str(self.temp_root / "artifact-only.step"),
skip_step_write=True,
)
def test_step_generation_skip_step_write_allows_missing_logical_step(self) -> None:
script_path = self._generator_script("artifact_only")
logical_step_path = script_path.with_suffix(".step")
self.assertFalse(logical_step_path.exists())
calls: list[dict[str, object]] = []
scene = mock.Mock()
scene.step_path = logical_step_path.resolve()
def fake_outputs(spec, **kwargs):
calls.append(kwargs)
return cad_generation.GeneratedStepResult(spec=spec, scene=scene)
with mock.patch.object(cad_generation, "run_script_generator", return_value=scene) as run_generator, mock.patch.object(
cad_generation,
"_generate_part_outputs",
side_effect=fake_outputs,
):
cad_generation.generate_step_targets(
[str(script_path)],
skip_step_write=True,
)
run_generator.assert_called_once()
self.assertEqual(False, calls[0]["require_step_file"])
self.assertIs(scene, calls[0]["preloaded_scene"])
self.assertFalse(calls[0]["force"])
def test_generated_step_targets_expect_python_backed_topology_artifacts(self) -> None:
script_path = self._generator_script("generated_kind")
generated_spec = next(spec for spec in cad_generation.list_entry_specs() if spec.source_path == script_path)
direct_path = self._write_step("direct_kind")
_, direct_specs = cad_generation._selected_specs_for_targets(
[str(direct_path)],
direct_step_kind="part",
)
self.assertTrue(
cad_generation._artifact_source_kind_matches_spec(
generated_spec,
{"sourceKind": "python"},
)
)
self.assertFalse(
cad_generation._artifact_source_kind_matches_spec(
generated_spec,
{"sourceKind": "step"},
)
)
self.assertTrue(
cad_generation._artifact_source_kind_matches_spec(
direct_specs[0],
{"sourceKind": "step"},
)
)
self.assertFalse(
cad_generation._artifact_source_kind_matches_spec(
direct_specs[0],
{"sourceKind": "python"},
)
)
def test_step_output_override_retargets_single_generated_source(self) -> None:
script_path = self._generator_script("flat")
output_path = self.temp_root / "custom" / "flat-output.step"
calls: list[cad_generation.EntrySpec] = []
def fake_generate(spec, *, entries_by_step_path, preloaded_scene=None, force=False):
self.assertIn(output_path.resolve(), entries_by_step_path)
self.assertIsNotNone(preloaded_scene)
self.assertEqual(output_path.resolve(), preloaded_scene.step_path)
calls.append(spec)
with mock.patch.object(cad_generation, "_generate_part_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets([str(script_path)], output=str(output_path))
self.assertEqual(1, len(calls))
self.assertEqual(output_path, calls[0].step_path)
self.assertEqual(f"{self.relative_dir}/custom/flat-output", calls[0].cad_ref)
self.assertTrue(output_path.exists())
self.assertFalse(script_path.with_suffix(".step").exists())
def test_step_output_pairs_retarget_generated_sources(self) -> None:
first_path = self._generator_script("first")
second_path = self._generator_script("second")
first_output = self.temp_root / "custom" / "first-output.step"
second_output = self.temp_root / "custom" / "second-output.step"
calls: list[cad_generation.EntrySpec] = []
def fake_generate(spec, *, entries_by_step_path, preloaded_scene=None, force=False):
self.assertIn(spec.step_path.resolve(), entries_by_step_path)
self.assertIsNotNone(preloaded_scene)
calls.append(spec)
with mock.patch.object(cad_generation, "_generate_part_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets(
[f"{first_path}={first_output}", f"{second_path}={second_output}"],
)
self.assertEqual([first_output, second_output], [call.step_path for call in calls])
self.assertTrue(first_output.exists())
self.assertTrue(second_output.exists())
self.assertFalse(first_path.with_suffix(".step").exists())
self.assertFalse(second_path.with_suffix(".step").exists())
def test_step_output_pairs_allow_mixed_plain_and_paired_targets(self) -> None:
first_path = self._generator_script("first")
second_path = self._generator_script("second")
second_output = self.temp_root / "custom" / "second-output.step"
calls: list[cad_generation.EntrySpec] = []
def fake_generate(spec, *, entries_by_step_path, preloaded_scene=None, force=False):
calls.append(spec)
with mock.patch.object(cad_generation, "_generate_part_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets([str(first_path), f"{second_path}={second_output}"])
self.assertEqual([first_path.with_suffix(".step"), second_output], [call.step_path for call in calls])
self.assertTrue(first_path.with_suffix(".step").exists())
self.assertTrue(second_output.exists())
self.assertFalse(second_path.with_suffix(".step").exists())
def test_step_output_override_rejects_pair_targets(self) -> None:
script_path = self._generator_script("flat")
with self.assertRaisesRegex(ValueError, "cannot be combined"):
cad_generation.generate_step_targets(
[f"{script_path}={self.temp_root / 'custom' / 'flat-output.step'}"],
output=str(self.temp_root / "other.step"),
)
def test_step_output_override_requires_single_target(self) -> None:
first_path = self._generator_script("first")
second_path = self._generator_script("second")
with self.assertRaisesRegex(ValueError, "--output can only be used with exactly one target"):
cad_generation.generate_step_targets(
[str(first_path), str(second_path)],
output=str(self.temp_root / "first-output.step"),
)
def test_step_sidecar_flags_are_relative_to_output_override(self) -> None:
script_path = self._generator_script("flat")
output_path = self.temp_root / "custom" / "flat-output.step"
calls: list[cad_generation.EntrySpec] = []
def fake_generate(spec, *, entries_by_step_path, preloaded_scene=None, force=False):
calls.append(spec)
with mock.patch.object(cad_generation, "_generate_part_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets(
[str(script_path)],
output=str(output_path),
step_options=self._step_options(
stl="flat-output.stl",
three_mf="flat-output.3mf",
glb="flat-output.glb",
),
)
self.assertEqual(1, len(calls))
self.assertEqual(output_path.parent / "flat-output.stl", calls[0].stl_path)
self.assertEqual(output_path.parent / "flat-output.3mf", calls[0].three_mf_path)
self.assertEqual(output_path.parent / "flat-output.glb", calls[0].native_glb_path)
def test_step_output_override_rejects_direct_step_target(self) -> None:
step_path = self._write_step("imported")
with self.assertRaisesRegex(ValueError, "--output can only be used with generated Python targets"):
cad_generation.generate_step_targets(
[str(step_path)],
direct_step_kind="part",
output=str(self.temp_root / "imported-output.step"),
)
def test_step_output_pair_rejects_direct_step_target(self) -> None:
step_path = self._write_step("imported")
with self.assertRaisesRegex(ValueError, "output pairs can only be used with generated Python targets"):
cad_generation.generate_step_targets(
[f"{step_path}={self.temp_root / 'imported-output.step'}"],
direct_step_kind="part",
)
def test_step_output_pairs_reject_duplicate_output_paths(self) -> None:
first_path = self._generator_script("first")
second_path = self._generator_script("second")
output_path = self.temp_root / "shared.step"
with self.assertRaisesRegex(ValueError, "used more than once"):
cad_generation.generate_step_targets([f"{first_path}={output_path}", f"{second_path}={output_path}"])
def test_entry_selection_does_not_execute_unrelated_assembly_generators(self) -> None:
selected_path = self._write_step("selected")
assembly_path = self.temp_root / "unrelated.py"
assembly_path.write_text(
"\n".join(
[
"def gen_step():",
" raise RuntimeError('unrelated assembly should not run')",
" return {'instances': []}",
"",
]
),
encoding="utf-8",
)
calls: list[str] = []
def fake_generate(spec, *, entries_by_step_path):
self.assertNotIn(assembly_path.with_suffix(".step").resolve(), entries_by_step_path)
calls.append(spec.cad_ref)
with mock.patch.object(cad_generation, "_generate_step_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets([str(selected_path)], direct_step_kind="part")
self.assertEqual([self._cad_ref("selected")], calls)
def test_step_generation_infers_assembly_target(self) -> None:
self._write_step("imported-part")
assembly_path = self._write_assembly_generator(
"robot",
instances=[
{
"path": "imported-part.step",
"name": "leaf",
"transform": IDENTITY_TRANSFORM,
}
],
)
calls: list[str] = []
def fake_generate(spec, *, entries_by_step_path):
calls.append(spec.kind)
with mock.patch.object(cad_generation, "_generate_step_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets([str(assembly_path)])
self.assertEqual(["assembly"], calls)
def test_assembly_dependency_expansion_uses_one_source_lookup(self) -> None:
child_step_paths: list[Path] = []
instances: list[dict[str, object]] = []
for index in range(6):
child_script = self._generator_script(f"leaf_{index}")
child_step = self._write_step(f"leaf_{index}")
child_step_paths.append(child_step.resolve())
instances.append(
{
"path": child_step.name,
"name": f"leaf_{index}",
"transform": IDENTITY_TRANSFORM,
}
)
self.assertEqual(child_script.with_suffix(".step"), child_step)
assembly_path = self._write_assembly_generator("robot", instances=instances)
iter_calls: list[Path | None] = []
real_iter_cad_sources = cad_generation.iter_cad_sources
def tracked_iter_cad_sources(root=None):
iter_calls.append(root)
return real_iter_cad_sources(root)
def fake_generate(spec, *, entries_by_step_path):
self.assertEqual("assembly", spec.kind)
for child_step in child_step_paths:
self.assertEqual("generated", entries_by_step_path[child_step].source)
with (
mock.patch.object(cad_generation, "iter_cad_sources", side_effect=tracked_iter_cad_sources),
mock.patch.object(cad_generation, "find_source_by_path", side_effect=AssertionError("unexpected repeated source scan")),
mock.patch.object(cad_generation, "_generate_step_outputs", side_effect=fake_generate),
):
cad_generation.generate_step_targets([str(assembly_path)])
self.assertEqual(1, len(iter_calls))
def test_dxf_generation_rejects_source_without_dxf(self) -> None:
script_path = self._generator_script("part")
with self.assertRaisesRegex(ValueError, "does not define gen_dxf\\(\\)"):
cad_generation.generate_dxf_targets([str(script_path)])
def test_dxf_output_override_retargets_single_generated_source(self) -> None:
script_path = self._generator_script("flat", with_dxf=True)
output_path = self.temp_root / "drawings" / "flat-output.dxf"
cad_generation.generate_dxf_targets([str(script_path)], output=str(output_path))
self.assertTrue(output_path.exists())
self.assertFalse(script_path.with_suffix(".dxf").exists())
def test_dxf_output_pair_retargets_generated_source(self) -> None:
first_path = self._generator_script("first", with_dxf=True)
second_path = self._generator_script("second", with_dxf=True)
first_output = self.temp_root / "drawings" / "first-output.dxf"
second_output = self.temp_root / "drawings" / "second-output.dxf"
cad_generation.generate_dxf_targets([f"{first_path}={first_output}", f"{second_path}={second_output}"])
self.assertTrue(first_output.exists())
self.assertTrue(second_output.exists())
self.assertFalse(first_path.with_suffix(".dxf").exists())
self.assertFalse(second_path.with_suffix(".dxf").exists())
def test_dxf_output_pair_allows_mixed_plain_and_paired_targets(self) -> None:
first_path = self._generator_script("first", with_dxf=True)
second_path = self._generator_script("second", with_dxf=True)
second_output = self.temp_root / "drawings" / "second-output.dxf"
cad_generation.generate_dxf_targets([str(first_path), f"{second_path}={second_output}"])
self.assertTrue(first_path.with_suffix(".dxf").exists())
self.assertTrue(second_output.exists())
self.assertFalse(second_path.with_suffix(".dxf").exists())
def test_dxf_output_override_rejects_pair_targets(self) -> None:
script_path = self._generator_script("flat", with_dxf=True)
with self.assertRaisesRegex(ValueError, "cannot be combined"):
cad_generation.generate_dxf_targets(
[f"{script_path}={self.temp_root / 'drawings' / 'flat-output.dxf'}"],
output=str(self.temp_root / "other.dxf"),
)
def test_dxf_output_pairs_reject_duplicate_output_paths(self) -> None:
first_path = self._generator_script("first", with_dxf=True)
second_path = self._generator_script("second", with_dxf=True)
output_path = self.temp_root / "shared.dxf"
with self.assertRaisesRegex(ValueError, "used more than once"):
cad_generation.generate_dxf_targets([f"{first_path}={output_path}", f"{second_path}={output_path}"])
def test_dxf_output_override_requires_single_target(self) -> None:
first_path = self._generator_script("first", with_dxf=True)
second_path = self._generator_script("second", with_dxf=True)
with self.assertRaisesRegex(ValueError, "--output can only be used with exactly one target"):
cad_generation.generate_dxf_targets(
[str(first_path), str(second_path)],
output=str(self.temp_root / "first-output.dxf"),
)
def test_step_generator_does_not_run_sidecars(self) -> None:
script_path = self._generator_script("flat", with_dxf=True, with_urdf=True, with_sdf=True, dxf_before_step=True)
spec = next(spec for spec in cad_generation.list_entry_specs() if spec.cad_ref == self._cad_ref("flat"))
cad_generation.run_script_generator(spec, "gen_step")
self.assertEqual("gen_step\n", script_path.with_suffix(".calls").read_text(encoding="utf-8"))
self.assertFalse(script_path.with_suffix(".dxf").exists())
self.assertTrue(script_path.with_suffix(".step").exists())
self.assertFalse(script_path.with_suffix(".urdf").exists())
self.assertFalse(script_path.with_suffix(".sdf").exists())
def test_generated_step_outputs_reuses_generated_scene(self) -> None:
script_path = self._generator_script("flat")
spec = next(spec for spec in cad_generation.list_entry_specs() if spec.cad_ref == self._cad_ref("flat"))
step_path = script_path.with_suffix(".step")
observed_scene = None
def fake_outputs(spec_arg, *, entries_by_step_path, preloaded_scene=None, force=False):
nonlocal observed_scene
observed_scene = preloaded_scene
self.assertIs(spec, spec_arg)
with mock.patch.object(cad_generation, "_generate_part_outputs", side_effect=fake_outputs):
cad_generation._generate_step_outputs(spec, entries_by_step_path={spec.step_path.resolve(): spec})
self.assertIsNotNone(observed_scene)
self.assertEqual(step_path.resolve(), observed_scene.step_path)
self.assertIsNotNone(observed_scene.doc)
self.assertEqual("python", observed_scene.source_kind)
self.assertEqual(cad_generation.python_source_hash(script_path).source_hash, observed_scene.source_hash)
digest = hashlib.sha256(step_path.read_bytes()).hexdigest()
self.assertEqual(digest, observed_scene.step_hash)
def test_normal_python_generation_reuses_current_glb_after_skip_step_write(self) -> None:
script_path = self._generator_script("flat")
spec = next(spec for spec in cad_generation.list_entry_specs() if spec.cad_ref == self._cad_ref("flat"))
step_path = script_path.with_suffix(".step")
step_path.write_text("ISO-10303-21; END-ISO-10303-21;\n", encoding="utf-8")
source_identity = cad_generation.python_source_hash(script_path)
scene = LoadedStepScene(
step_path=step_path.resolve(),
roots=[],
prototype_shapes={},
source_kind="python",
source_hash=source_identity.source_hash,
source_path=cad_generation.relative_to_repo(script_path),
)
with (
mock.patch.object(cad_generation, "_existing_topology_artifact_matches_options", return_value=True),
mock.patch.object(
cad_generation,
"mesh_step_scene",
side_effect=AssertionError("current Python-backed GLB should be reused"),
),
):
result = cad_generation._generate_part_outputs(
spec,
entries_by_step_path={spec.step_path.resolve(): spec},
preloaded_scene=scene,
force=False,
)
self.assertIs(scene, result.scene)
self.assertIsNone(result.selector_bundle)
def test_skip_step_write_reuses_current_glb_when_python_source_hash_changes(self) -> None:
script_path = self._generator_script("flat")
spec = next(spec for spec in cad_generation.list_entry_specs() if spec.cad_ref == self._cad_ref("flat"))
spec.step_path.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
artifact = mock.Mock()
artifact.manifest = {
"sourceKind": "python",
"sourceHash": "old-python-source-hash",
"stepHash": cad_generation.step_file_hash(spec.step_path),
"edgeRendering": {
"visibilityClasses": ["feature", "tangent", "seam", "degenerate"],
},
"mesh": {
"linearDeflection": 0.006,
"angularDeflection": 0.2,
"relative": True,
"resolution": {
"mode": "auto",
"hints": {
"bboxDiag": 10.0,
"prototypeFaceCount": 6,
"prototypeEdgeCount": 12,
"prototypeCurvedFaceCount": 0,
"prototypeCurvedEdgeCount": 0,
"occurrenceFaceCount": 6,
"occurrenceEdgeCount": 12,
"occurrenceCurvedFaceCount": 0,
"occurrenceCurvedEdgeCount": 0,
"leafOccurrenceCount": 1,
"complexityScore": 17.2,
"effectiveComplexityScore": 11.18,
"curvaturePressureScore": 0.0,
"profile": "extra-fine",
},
},
},
}
with (
mock.patch(
"cadpy.step_targets.validate_step_topology_artifact",
return_value=artifact,
),
mock.patch.object(
cad_generation,
"run_script_generator",
side_effect=AssertionError("current Python-backed GLB should be reused before gen_step"),
),
):
result = cad_generation._generate_step_outputs(
spec,
entries_by_step_path={spec.step_path.resolve(): spec},
skip_step_write=True,
force=False,
)
self.assertIsNone(result.scene)
self.assertIsNone(result.selector_bundle)
self.assertEqual(script_path.with_suffix(".step"), result.spec.step_path)
def test_generated_assembly_step_exports_with_source_hash(self) -> None:
child_step = self._write_step("leaf")
instances = [
{
"path": child_step.name,
"name": "leaf",
"transform": IDENTITY_TRANSFORM,
}
]
script_path = self._write_assembly_generator("robot", instances=instances)
output_path = script_path.with_suffix(".step")
scene = LoadedStepScene(step_path=output_path.resolve(), roots=[], prototype_shapes={})
with mock.patch("cadpy.assembly_export.export_assembly_step_scene", return_value=scene) as export_scene:
result = cad_generation._write_assembly_step_payload(
{"instances": instances},
output_path=output_path,
script_path=script_path,
logger=cad_generation.CliLogger("test"),
)
self.assertIs(scene, result)
self.assertEqual("python", result.source_kind)
self.assertEqual(cad_generation.python_source_hash(script_path).source_hash, result.source_hash)
export_scene.assert_called_once()
def test_sidecars_are_not_separate_generation_specs(self) -> None:
self._generator_script("flat", with_dxf=True)
self._write_step("imported-part")
self._write_assembly_generator(
"robot",
instances=[
{
"path": "imported-part.step",
"name": "leaf",
"transform": IDENTITY_TRANSFORM,
}
],
with_urdf=True,
with_sdf=True,
)
cad_refs = {
spec.cad_ref
for spec in cad_generation.list_entry_specs()
if spec.cad_ref.startswith(f"{self.relative_dir}/")
}
self.assertIn(self._cad_ref("flat"), cad_refs)
self.assertIn(self._cad_ref("robot"), cad_refs)
self.assertNotIn(self._cad_ref("flat") + ".dxf", cad_refs)
self.assertNotIn(self._cad_ref("robot") + ".urdf", cad_refs)
self.assertNotIn(self._cad_ref("robot") + ".sdf", cad_refs)
def test_step_toml_target_is_not_supported(self) -> None:
(self.temp_root / "broken.step.toml").write_text('kind = "part"\n', encoding="utf-8")
with self.assertRaisesRegex(FileNotFoundError, "Python generator or STEP/STP file path"):
cad_generation.generate_step_targets([str(self.temp_root / "broken.step.toml")])
def test_direct_step_generation_requires_kind(self) -> None:
step_path = self._write_step("source")
with self.assertRaisesRegex(ValueError, "--kind is required for direct STEP/STP targets"):
cad_generation.generate_step_targets([str(step_path)])
def test_direct_step_generation_reads_configured_stl(self) -> None:
step_path = self._write_step("source")
calls: list[Path | None] = []
def fake_generate(spec, *, entries_by_step_path):
calls.append(spec.stl_path)
with mock.patch.object(cad_generation, "_generate_step_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets(
[str(step_path)],
direct_step_kind="part",
step_options=self._step_options(
stl="../meshes/source.stl",
),
)
self.assertEqual([cad_generation.CAD_ROOT / "meshes" / "source.stl"], calls)
def test_direct_step_generation_reads_configured_3mf(self) -> None:
step_path = self._write_step("source")
calls: list[Path | None] = []
def fake_generate(spec, *, entries_by_step_path):
calls.append(spec.three_mf_path)
with mock.patch.object(cad_generation, "_generate_step_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets(
[str(step_path)],
direct_step_kind="part",
step_options=self._step_options(
three_mf="../meshes/source.3mf",
),
)
self.assertEqual([cad_generation.CAD_ROOT / "meshes" / "source.3mf"], calls)
def test_direct_step_stl_flag_sets_stl_path(self) -> None:
step_path = self._write_step("source")
calls: list[cad_generation.EntrySpec] = []
def fake_generate(spec, *, entries_by_step_path):
calls.append(spec)
with mock.patch.object(cad_generation, "_generate_step_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets(
[str(step_path)],
direct_step_kind="part",
step_options=self._step_options(stl="source.stl"),
)
self.assertEqual(step_path.parent / "source.stl", calls[0].stl_path)
def test_direct_step_3mf_flag_sets_3mf_path(self) -> None:
step_path = self._write_step("source")
calls: list[cad_generation.EntrySpec] = []
def fake_generate(spec, *, entries_by_step_path):
calls.append(spec)
with mock.patch.object(cad_generation, "_generate_step_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets(
[str(step_path)],
direct_step_kind="part",
step_options=self._step_options(three_mf="source.3mf"),
)
self.assertEqual(step_path.parent / "source.3mf", calls[0].three_mf_path)
def test_direct_step_glb_flag_sets_native_glb_path(self) -> None:
step_path = self._write_step("source")
calls: list[cad_generation.EntrySpec] = []
def fake_generate(spec, *, entries_by_step_path):
calls.append(spec)
with mock.patch.object(cad_generation, "_generate_step_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets(
[str(step_path)],
direct_step_kind="part",
step_options=self._step_options(glb="source.glb"),
)
self.assertEqual(step_path.parent / "source.glb", calls[0].native_glb_path)
def test_direct_step_rejects_invalid_stl_suffix(self) -> None:
step_path = self._write_step("source")
with self.assertRaisesRegex(ValueError, "stl must end in .stl"):
cad_generation.generate_step_targets(
[str(step_path)],
direct_step_kind="part",
step_options=self._step_options(stl="source.txt"),
)
def test_direct_step_rejects_invalid_3mf_suffix(self) -> None:
step_path = self._write_step("source")
with self.assertRaisesRegex(ValueError, "3mf must end in .3mf"):
cad_generation.generate_step_targets(
[str(step_path)],
direct_step_kind="part",
step_options=self._step_options(three_mf="source.txt"),
)
def test_direct_step_rejects_invalid_glb_suffix(self) -> None:
step_path = self._write_step("source")
with self.assertRaisesRegex(ValueError, "glb must end in .glb"):
cad_generation.generate_step_targets(
[str(step_path)],
direct_step_kind="part",
step_options=self._step_options(glb="source.txt"),
)
def test_direct_step_rejects_native_glb_topology_artifact_collision(self) -> None:
step_path = self._write_step("source")
with self.assertRaisesRegex(ValueError, "Native GLB output would overwrite"):
cad_generation.generate_step_targets(
[str(step_path)],
direct_step_kind="part",
step_options=self._step_options(glb=".source.step.glb"),
)
def test_direct_step_rejects_duplicate_native_glb_sidecars(self) -> None:
first_path = self._write_step("first")
second_path = self._write_step("second")
with self.assertRaisesRegex(ValueError, "Native GLB output collision"):
cad_generation.generate_step_targets(
[str(first_path), str(second_path)],
direct_step_kind="part",
step_options=self._step_options(glb="../meshes/shared.glb"),
)
def test_direct_step_allows_file_relative_parent_stl(self) -> None:
step_path = self._write_step("source")
calls: list[Path | None] = []
def fake_generate(spec, *, entries_by_step_path):
calls.append(spec.stl_path)
with mock.patch.object(cad_generation, "_generate_step_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets(
[str(step_path)],
direct_step_kind="part",
step_options=self._step_options(
stl="../../../../source.stl",
),
)
self.assertEqual([(self.temp_root / "../../../../source.stl").resolve()], calls)
def test_direct_step_reuses_mesh_numeric_validation(self) -> None:
step_path = self._write_step("broken")
with self.assertRaisesRegex(ValueError, "mesh_tolerance must be greater than 0"):
cad_generation.generate_step_targets(
[str(step_path)],
direct_step_kind="part",
step_options=self._step_options(mesh_tolerance=-0.1),
)
def test_step_cli_flags_apply_to_generated_python_targets(self) -> None:
script_path = self._generator_script("generated")
calls: list[cad_generation.EntrySpec] = []
def fake_generate(spec, *, entries_by_step_path):
calls.append(spec)
with mock.patch.object(cad_generation, "_generate_step_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets(
[str(script_path)],
step_options=self._step_options(
stl="generated.stl",
three_mf="generated.3mf",
mesh_tolerance=0.2,
mesh_angular_tolerance=0.3,
),
)
self.assertEqual(1, len(calls))
self.assertEqual(script_path.with_suffix(".stl"), calls[0].stl_path)
self.assertEqual(script_path.with_suffix(".3mf"), calls[0].three_mf_path)
self.assertEqual(0.2, calls[0].mesh_tolerance)
self.assertEqual(0.3, calls[0].mesh_angular_tolerance)
def test_step_cli_flags_apply_to_mixed_imported_and_generated_targets(self) -> None:
step_path = self._write_step("imported")
script_path = self._generator_script("generated")
calls: list[cad_generation.EntrySpec] = []
def fake_generate(spec, *, entries_by_step_path):
calls.append(spec)
with mock.patch.object(cad_generation, "_generate_step_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets(
[str(step_path), str(script_path)],
direct_step_kind="part",
step_options=self._step_options(mesh_tolerance=0.2),
)
self.assertEqual([0.2, 0.2], [spec.mesh_tolerance for spec in calls])
def test_generator_discovery_rejects_none_gen_step(self) -> None:
script_path = self.temp_root / "broken.py"
script_path.write_text(
"\n".join(
[
'DISPLAY_NAME = "broken"',
"def gen_step():",
" return None",
]
)
+ "\n"
)
with self.assertRaisesRegex(ValueError, "must return a shape, assembly list, or legacy envelope dict"):
cad_generation.list_entry_specs()
def test_generator_discovery_ignores_sidecar_only_scripts(self) -> None:
script_path = self.temp_root / "flat.py"
script_path.write_text(
"\n".join(
[
"def gen_dxf():",
" return {'document': object()}",
"",
]
),
encoding="utf-8",
)
specs = cad_generation.list_entry_specs()
self.assertFalse(any(spec.source_path == script_path for spec in specs))
def test_generated_part_ignores_mesh_settings_from_envelope_metadata(self) -> None:
self._generator_script(
"meshy",
stl="meshy.stl",
three_mf="meshy.3mf",
mesh_tolerance=0.2,
mesh_angular_tolerance=0.25,
)
specs = {
spec.cad_ref: spec
for spec in cad_generation.list_entry_specs()
if spec.cad_ref.startswith(f"{self.relative_dir}/")
}
self.assertIsNone(specs[self._cad_ref("meshy")].stl_path)
self.assertIsNone(specs[self._cad_ref("meshy")].three_mf_path)
self.assertEqual(cad_generation.DEFAULT_MESH_TOLERANCE, specs[self._cad_ref("meshy")].mesh_tolerance)
self.assertEqual(cad_generation.DEFAULT_MESH_ANGULAR_TOLERANCE, specs[self._cad_ref("meshy")].mesh_angular_tolerance)
def test_generated_assembly_paths_use_sibling_defaults_and_ignore_legacy_sidecars(self) -> None:
self._write_step("imported-part")
self._write_assembly_generator(
"assembly",
instances=[
{
"path": "imported-part.step",
"name": "leaf",
"transform": IDENTITY_TRANSFORM,
}
],
with_dxf=True,
with_urdf=True,
with_sdf=True,
stl="assembly.stl",
three_mf="assembly.3mf",
mesh_tolerance=0.3,
mesh_angular_tolerance=0.2,
)
spec = next(
spec
for spec in cad_generation.list_entry_specs()
if spec.cad_ref == self._cad_ref("assembly")
)
self.assertEqual("assembly", spec.kind)
self.assertEqual(self.temp_root / "assembly.step", spec.step_path)
self.assertEqual(self.temp_root / "assembly.dxf", spec.dxf_path)
self.assertEqual(self.temp_root / "assembly.urdf", spec.urdf_path)
self.assertEqual(self.temp_root / "assembly.sdf", spec.sdf_path)
self.assertIsNone(spec.stl_path)
self.assertIsNone(spec.three_mf_path)
self.assertEqual(cad_generation.DEFAULT_MESH_TOLERANCE, spec.mesh_tolerance)
self.assertEqual(cad_generation.DEFAULT_MESH_ANGULAR_TOLERANCE, spec.mesh_angular_tolerance)
def test_imported_step_defaults_to_part(self) -> None:
self._write_step("imported")
specs = [spec for spec in cad_generation.list_entry_specs() if spec.cad_ref == self._cad_ref("imported")]
self.assertEqual(1, len(specs))
self.assertEqual("part", specs[0].kind)
def test_imported_stp_defaults_to_part(self) -> None:
self._write_step("imported-stp", suffix=".stp")
specs = [spec for spec in cad_generation.list_entry_specs() if spec.cad_ref == self._cad_ref("imported-stp")]
self.assertEqual(1, len(specs))
self.assertEqual("part", specs[0].kind)
def test_imported_step_uses_default_mesh_settings(self) -> None:
self._write_step("imported-mesh")
specs = [spec for spec in cad_generation.list_entry_specs() if spec.cad_ref == self._cad_ref("imported-mesh")]
self.assertEqual(1, len(specs))
self.assertIsNone(specs[0].stl_path)
self.assertIsNone(specs[0].three_mf_path)
self.assertEqual(cad_generation.DEFAULT_MESH_TOLERANCE, specs[0].mesh_tolerance)
self.assertEqual(cad_generation.DEFAULT_MESH_ANGULAR_TOLERANCE, specs[0].mesh_angular_tolerance)
def test_imported_step_reads_mesh_settings_from_cli_options(self) -> None:
step_path = self._write_step("imported-heavy")
calls: list[cad_generation.EntrySpec] = []
def fake_generate(spec, *, entries_by_step_path):
calls.append(spec)
with mock.patch.object(cad_generation, "_generate_step_outputs", side_effect=fake_generate):
cad_generation.generate_step_targets(
[str(step_path)],
direct_step_kind="part",
step_options=self._step_options(
stl="imported-heavy.stl",
three_mf="imported-heavy.3mf",
mesh_tolerance=0.9,
mesh_angular_tolerance=0.45,
),
)
self.assertEqual(1, len(calls))
self.assertEqual(step_path.parent / "imported-heavy.stl", calls[0].stl_path)
self.assertEqual(step_path.parent / "imported-heavy.3mf", calls[0].three_mf_path)
self.assertEqual(0.9, calls[0].mesh_tolerance)
self.assertEqual(0.45, calls[0].mesh_angular_tolerance)
def test_script_step_material_colors_accepts_tuple_rgba(self) -> None:
script_path = self.temp_root / "colored_assembly.py"
script_path.write_text(
"\n".join(
[
"URDF_MATERIALS = {'black_aluminum': (0.168627, 0.184314, 0.2, 1.0)}",
"URDF_STEP_MATERIALS = {'imports/sample_component.step': 'black_aluminum'}",
"",
]
),
encoding="utf-8",
)
spec = cad_generation.EntrySpec(
source_ref=self._cad_ref("colored_assembly.py"),
cad_ref=self._cad_ref("colored_assembly"),
kind="assembly",
source_path=script_path,
display_name="colored_assembly",
source="generated",
script_path=script_path,
)
self.assertEqual(
{"imports/sample_component.step": (0.168627, 0.184314, 0.2, 1.0)},
cad_generation._script_step_material_colors(spec),
)
def test_generated_assembly_source_colors_use_loaded_scene_before_child_step_loads(self) -> None:
child_step = self._write_step("colored_leaf")
assembly_path = self._write_assembly_generator(
"colored_robot",
instances=[
{
"path": child_step.name,
"name": "colored_leaf",
"transform": IDENTITY_TRANSFORM,
}
],
)
spec = cad_generation.EntrySpec(
source_ref=self._cad_ref("colored_robot.py"),
cad_ref=self._cad_ref("colored_robot"),
kind="assembly",
source_path=assembly_path,
display_name="colored_robot",
source="generated",
step_path=assembly_path.with_suffix(".step"),
script_path=assembly_path,
)
color = (0.168627, 0.184314, 0.2, 1.0)
scene = LoadedStepScene(
step_path=spec.step_path,
roots=[
OccurrenceNode(
path=(1,),
name="colored_leaf",
source_name="colored_leaf",
transform=tuple(float(value) for value in IDENTITY_TRANSFORM),
prototype_key=1,
)
],
prototype_shapes={},
prototype_colors={1: color},
)
with mock.patch.object(
cad_generation,
"_uniform_source_step_color",
side_effect=AssertionError("child STEP should not be loaded when assembly scene already has a uniform color"),
):
occurrence_colors = cad_generation._generated_assembly_source_occurrence_colors(
spec,
scene,
entries_by_step_path={child_step.resolve(): cad_generation.EntrySpec(
source_ref=self._cad_ref("colored_leaf.py"),
cad_ref=self._cad_ref("colored_leaf"),
kind="part",
source_path=child_step,
display_name="colored_leaf",
source="generated",
step_path=child_step,
)},
)
self.assertEqual({"o1": color}, occurrence_colors)
def test_generate_part_outputs_embeds_selector_artifacts_in_glb(self) -> None:
step_path = self._write_step("selector-output")
_, selected_specs = cad_generation._selected_specs_for_targets(
[str(step_path)],
step_options=self._step_options(mesh_tolerance=0.3, mesh_angular_tolerance=0.2),
)
spec = selected_specs[0]
artifact_dir = cad_render.native_component_glb_dir(step_path).parent
selector_manifest_path = artifact_dir / "topology.json"
stale_binary_path = artifact_dir / "topology.bin"
stale_extra_path = artifact_dir / "stale-artifact.txt"
stale_extra_path.parent.mkdir(parents=True, exist_ok=True)
selector_manifest_path.write_text("old topology", encoding="utf-8")
stale_binary_path.write_bytes(b"old topology")
stale_extra_path.write_text("old artifact", encoding="utf-8")
scene = object()
def fake_export_glb(
step_path_arg,
scene_arg,
*,
linear_deflection,
angular_deflection,
color=None,
selector_bundle=None,
include_selector_topology=True,
):
self.assertIs(scene, scene_arg)
self.assertLessEqual(linear_deflection, spec.mesh_tolerance)
self.assertLessEqual(angular_deflection, spec.mesh_angular_tolerance)
self.assertIsInstance(selector_bundle, SelectorBundle)
self.assertTrue(include_selector_topology)
glb_path = cad_render.part_glb_path(step_path_arg)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_bytes(b"glb")
return glb_path
def fake_extract(scene_arg, *, cad_ref, profile, options, **kwargs):
self.assertIs(scene, scene_arg)
self.assertEqual(spec.cad_ref, cad_ref)
self.assertEqual(cad_generation.SelectorProfile.ARTIFACT, profile)
self.assertLessEqual(options.linear_deflection, spec.mesh_tolerance)
self.assertLessEqual(options.angular_deflection, spec.mesh_angular_tolerance)
return SelectorBundle(manifest={"schemaVersion": 1, "cadPath": spec.cad_ref}, buffers={})
with mock.patch.object(cad_generation, "load_step_scene", return_value=scene) as load_scene, mock.patch.object(
cad_generation, "export_part_stl_from_scene"
) as stl_export_mock, mock.patch.object(
cad_generation,
"export_part_glb_from_scene",
side_effect=fake_export_glb,
), mock.patch.object(
cad_generation,
"mesh_step_scene",
), mock.patch.object(
cad_generation,
"scene_export_shape",
), mock.patch.object(
cad_generation,
"extract_selectors_from_scene",
side_effect=fake_extract,
) as extract_selectors:
result = cad_generation._generate_part_outputs(spec, entries_by_step_path={spec.step_path.resolve(): spec})
load_scene.assert_called_once_with(step_path)
stl_export_mock.assert_not_called()
self.assertTrue(cad_render.part_glb_path(step_path).exists())
self.assertIsNotNone(result.selector_bundle)
self.assertFalse(selector_manifest_path.exists())
self.assertFalse(stale_binary_path.exists())
self.assertFalse(stale_extra_path.exists())
def test_generate_part_outputs_reuses_current_topology_artifact(self) -> None:
step_path = self._write_step("current-topology")
_, selected_specs = cad_generation._selected_specs_for_targets(
[str(step_path)],
step_options=self._step_options(mesh_tolerance=0.3, mesh_angular_tolerance=0.2),
)
spec = selected_specs[0]
scene = object()
artifact = mock.Mock()
artifact.manifest = {
"stepHash": hashlib.sha256(step_path.read_bytes()).hexdigest(),
"edgeRendering": {
"visibilityClasses": ["feature", "tangent", "seam", "degenerate"],
},
"mesh": {
"linearDeflection": 0.3,
"angularDeflection": 0.2,
"relative": True,
}
}
with mock.patch.object(cad_generation, "load_step_scene", return_value=scene) as load_scene, mock.patch(
"cadpy.step_targets.validate_step_topology_artifact",
return_value=artifact,
) as validate_artifact, mock.patch.object(
cad_generation,
"mesh_step_scene",
) as mesh_scene, mock.patch.object(
cad_generation,
"scene_export_shape",
) as export_shape, mock.patch.object(
cad_generation,
"extract_selectors_from_scene",
) as extract_selectors, mock.patch.object(
cad_generation,
"export_part_glb_from_scene",
) as export_glb:
result = cad_generation._generate_part_outputs(
spec,
entries_by_step_path={spec.step_path.resolve(): spec},
)
self.assertIsNone(result.scene)
self.assertIsNone(result.selector_bundle)
validate_artifact.assert_called_once()
load_scene.assert_not_called()
mesh_scene.assert_not_called()
export_shape.assert_not_called()
extract_selectors.assert_not_called()
export_glb.assert_not_called()
def test_generate_part_outputs_rebuilds_stale_step_topology_artifact(self) -> None:
step_path = self._write_step("stale-topology")
_, selected_specs = cad_generation._selected_specs_for_targets(
[str(step_path)],
step_options=self._step_options(mesh_tolerance=0.3, mesh_angular_tolerance=0.2),
)
spec = selected_specs[0]
scene = object()
artifact = mock.Mock()
artifact.manifest = {
"stepHash": "stale-step-hash",
"edgeRendering": {
"visibilityClasses": ["feature", "tangent", "seam", "degenerate"],
},
"mesh": {
"linearDeflection": 0.3,
"angularDeflection": 0.2,
"relative": True,
},
}
def fake_export_glb(
step_path_arg,
scene_arg,
*,
linear_deflection,
angular_deflection,
color=None,
selector_bundle=None,
include_selector_topology=True,
):
glb_path = cad_render.part_glb_path(step_path_arg)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_bytes(b"glb")
return glb_path
with mock.patch.object(cad_generation, "load_step_scene", return_value=scene) as load_scene, mock.patch(
"cadpy.step_targets.validate_step_topology_artifact",
return_value=artifact,
) as validate_artifact, mock.patch.object(
cad_generation,
"mesh_step_scene",
) as mesh_scene, mock.patch.object(
cad_generation,
"scene_export_shape",
), mock.patch.object(
cad_generation,
"extract_selectors_from_scene",
return_value=SelectorBundle(manifest={"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION, "cadPath": spec.cad_ref}, buffers={}),
) as extract_selectors, mock.patch.object(
cad_generation,
"export_part_glb_from_scene",
side_effect=fake_export_glb,
) as export_glb:
result = cad_generation._generate_part_outputs(
spec,
entries_by_step_path={spec.step_path.resolve(): spec},
)
self.assertIs(scene, result.scene)
self.assertIsNotNone(result.selector_bundle)
self.assertGreaterEqual(validate_artifact.call_count, 1)
load_scene.assert_called_once_with(step_path)
mesh_scene.assert_called_once()
extract_selectors.assert_called_once()
export_glb.assert_called_once()
def test_generate_part_outputs_reuses_current_auto_topology_artifact_without_scene_load(self) -> None:
step_path = self._write_step("current-auto-topology")
_, selected_specs = cad_generation._selected_specs_for_targets([str(step_path)])
spec = selected_specs[0]
artifact = mock.Mock()
artifact.manifest = {
"stepHash": hashlib.sha256(step_path.read_bytes()).hexdigest(),
"edgeRendering": {
"visibilityClasses": ["feature", "tangent", "seam", "degenerate"],
},
"mesh": {
"linearDeflection": 0.006,
"angularDeflection": 0.2,
"relative": True,
"resolution": {
"mode": "auto",
"hints": {
"bboxDiag": 10.0,
"prototypeFaceCount": 6,
"prototypeEdgeCount": 12,
"prototypeCurvedFaceCount": 0,
"prototypeCurvedEdgeCount": 0,
"occurrenceFaceCount": 6,
"occurrenceEdgeCount": 12,
"occurrenceCurvedFaceCount": 0,
"occurrenceCurvedEdgeCount": 0,
"leafOccurrenceCount": 1,
"complexityScore": 17.2,
"effectiveComplexityScore": 11.18,
"curvaturePressureScore": 0.0,
"profile": "extra-fine",
},
},
}
}
with mock.patch.object(cad_generation, "load_step_scene") as load_scene, mock.patch(
"cadpy.step_targets.validate_step_topology_artifact",
return_value=artifact,
), mock.patch.object(
cad_generation,
"extract_selectors_from_scene",
) as extract_selectors:
result = cad_generation._generate_part_outputs(
spec,
entries_by_step_path={spec.step_path.resolve(): spec},
)
self.assertIsNone(result.scene)
load_scene.assert_not_called()
extract_selectors.assert_not_called()
def test_generate_part_outputs_force_ignores_current_topology_artifact(self) -> None:
step_path = self._write_step("force-topology")
_, selected_specs = cad_generation._selected_specs_for_targets(
[str(step_path)],
step_options=self._step_options(mesh_tolerance=0.3, mesh_angular_tolerance=0.2),
)
spec = selected_specs[0]
scene = object()
artifact = mock.Mock()
artifact.manifest = {
"stepHash": hashlib.sha256(step_path.read_bytes()).hexdigest(),
"mesh": {
"linearDeflection": 0.3,
"angularDeflection": 0.2,
"relative": True,
}
}
def fake_export_glb(
step_path_arg,
scene_arg,
*,
linear_deflection,
angular_deflection,
color=None,
selector_bundle=None,
include_selector_topology=True,
):
glb_path = cad_render.part_glb_path(step_path_arg)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_bytes(b"glb")
return glb_path
with mock.patch.object(cad_generation, "load_step_scene", return_value=scene), mock.patch(
"cadpy.step_targets.validate_step_topology_artifact",
return_value=artifact,
) as validate_artifact, mock.patch.object(
cad_generation,
"mesh_step_scene",
) as mesh_scene, mock.patch.object(
cad_generation,
"scene_export_shape",
), mock.patch.object(
cad_generation,
"extract_selectors_from_scene",
return_value=SelectorBundle(manifest={"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION, "cadPath": spec.cad_ref}, buffers={}),
) as extract_selectors, mock.patch.object(
cad_generation,
"export_part_glb_from_scene",
side_effect=fake_export_glb,
) as export_glb:
result = cad_generation._generate_part_outputs(
spec,
entries_by_step_path={spec.step_path.resolve(): spec},
force=True,
)
self.assertIs(scene, result.scene)
self.assertIsNotNone(result.selector_bundle)
validate_artifact.assert_not_called()
mesh_scene.assert_called_once()
extract_selectors.assert_called_once()
export_glb.assert_called_once()
def test_generate_part_outputs_writes_requested_sidecar_and_topology(self) -> None:
step_path = self._write_step("stl-fast")
_, selected_specs = cad_generation._selected_specs_for_targets(
[str(step_path)],
step_options=self._step_options(stl="stl-fast.stl"),
)
spec = selected_specs[0]
scene = object()
def fake_export(step_path_arg, scene_arg, *, target_path=None):
self.assertEqual(step_path, step_path_arg)
self.assertIs(scene, scene_arg)
self.assertEqual(spec.stl_path, target_path)
target_path.write_text("solid ok\nendsolid ok\n", encoding="utf-8")
return target_path
with mock.patch.object(cad_generation, "load_step_scene", return_value=scene), mock.patch.object(
cad_generation,
"export_part_stl_from_scene",
side_effect=fake_export,
) as stl_export_mock, mock.patch.object(
cad_generation,
"export_part_glb_from_scene",
) as export_glb, mock.patch.object(
cad_generation,
"extract_selectors_from_scene",
return_value=SelectorBundle(manifest={"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION}, buffers={}),
) as extract_selectors, mock.patch.object(
cad_generation,
"mesh_step_scene",
), mock.patch.object(
cad_generation,
"scene_export_shape",
):
result = cad_generation._generate_part_outputs(
spec,
entries_by_step_path={spec.step_path.resolve(): spec},
)
stl_export_mock.assert_called_once()
export_glb.assert_called_once()
extract_selectors.assert_called_once()
self.assertIs(scene, result.scene)
self.assertIsNotNone(result.selector_bundle)
self.assertIsNotNone(spec.stl_path)
self.assertTrue(spec.stl_path.exists())
def test_generate_part_outputs_uses_preloaded_scene_without_reloading(self) -> None:
step_path = self._write_step("preloaded")
_, selected_specs = cad_generation._selected_specs_for_targets(
[str(step_path)],
step_options=self._step_options(mesh_tolerance=0.3, mesh_angular_tolerance=0.2),
)
spec = selected_specs[0]
scene = mock.Mock()
scene.step_path = step_path.resolve()
def fake_export_glb(
step_path_arg,
scene_arg,
*,
linear_deflection,
angular_deflection,
color=None,
selector_bundle=None,
include_selector_topology=True,
):
self.assertIs(scene, scene_arg)
self.assertIsInstance(selector_bundle, SelectorBundle)
self.assertTrue(include_selector_topology)
glb_path = cad_render.part_glb_path(step_path_arg)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_bytes(b"glb")
return glb_path
def fake_extract(scene_arg, *, cad_ref, profile, options, **kwargs):
self.assertIs(scene, scene_arg)
return SelectorBundle(manifest={"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION, "cadPath": spec.cad_ref}, buffers={})
with mock.patch.object(cad_generation, "load_step_scene") as load_scene, mock.patch.object(
cad_generation,
"export_part_glb_from_scene",
side_effect=fake_export_glb,
), mock.patch.object(
cad_generation,
"mesh_step_scene",
), mock.patch.object(
cad_generation,
"scene_export_shape",
), mock.patch.object(
cad_generation,
"extract_selectors_from_scene",
side_effect=fake_extract,
):
cad_generation._generate_part_outputs(
spec,
entries_by_step_path={spec.step_path.resolve(): spec},
preloaded_scene=scene,
)
load_scene.assert_not_called()
self.assertTrue(cad_render.part_glb_path(step_path).exists())
self.assertFalse(cad_render.native_component_glb_dir(step_path).parent.exists())
def test_generate_part_outputs_always_embeds_topology_with_stl_sidecar(self) -> None:
step_path = self._write_step("summary-only")
_, selected_specs = cad_generation._selected_specs_for_targets(
[str(step_path)],
step_options=self._step_options(
stl="summary-only.stl",
),
)
spec = selected_specs[0]
artifact_dir = cad_render.native_component_glb_dir(step_path).parent
selector_manifest_path = artifact_dir / "topology.json"
selector_binary_path = artifact_dir / "topology.bin"
selector_manifest_path.parent.mkdir(parents=True, exist_ok=True)
selector_manifest_path.write_text("stale", encoding="utf-8")
selector_binary_path.write_bytes(b"stale")
scene = object()
def fake_export(step_path_arg, scene_arg, *, target_path=None):
self.assertIs(scene, scene_arg)
stl_path = target_path
self.assertIsNotNone(stl_path)
stl_path.parent.mkdir(parents=True, exist_ok=True)
stl_path.write_text("solid ok\nendsolid ok\n")
return stl_path
def fake_export_glb(
step_path_arg,
scene_arg,
*,
linear_deflection,
angular_deflection,
color=None,
selector_bundle=None,
include_selector_topology=True,
):
self.assertIs(scene, scene_arg)
self.assertIsInstance(selector_bundle, SelectorBundle)
self.assertTrue(include_selector_topology)
glb_path = cad_render.part_glb_path(step_path_arg)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_bytes(b"glb")
return glb_path
def fake_extract(scene_arg, *, cad_ref, profile, options, **kwargs):
self.assertIs(scene, scene_arg)
self.assertEqual(cad_generation.SelectorProfile.ARTIFACT, profile)
return SelectorBundle(manifest={"schemaVersion": 1, "cadPath": cad_ref}, buffers={})
with mock.patch.object(cad_generation, "load_step_scene", return_value=scene), mock.patch.object(
cad_generation,
"export_part_stl_from_scene",
side_effect=fake_export,
), mock.patch.object(
cad_generation,
"export_part_glb_from_scene",
side_effect=fake_export_glb,
), mock.patch.object(
cad_generation,
"mesh_step_scene",
), mock.patch.object(
cad_generation,
"scene_export_shape",
), mock.patch.object(
cad_generation,
"extract_selectors_from_scene",
side_effect=fake_extract,
) as extract_selectors:
result = cad_generation._generate_part_outputs(spec, entries_by_step_path={spec.step_path.resolve(): spec})
extract_selectors.assert_called_once()
self.assertIsNotNone(spec.stl_path)
self.assertTrue(spec.stl_path.exists())
self.assertTrue(cad_render.part_glb_path(step_path).exists())
self.assertIsNotNone(result.selector_bundle)
self.assertFalse(selector_manifest_path.exists())
self.assertFalse(selector_binary_path.exists())
def test_generate_part_outputs_writes_3mf_sidecar(self) -> None:
step_path = self._write_step("printable")
_, selected_specs = cad_generation._selected_specs_for_targets(
[str(step_path)],
step_options=self._step_options(
three_mf="printable.3mf",
mesh_tolerance=0.4,
mesh_angular_tolerance=0.3,
),
)
spec = selected_specs[0]
scene = object()
def fake_three_mf_export(step_path_arg, scene_arg, *, target_path=None, color=None):
self.assertEqual(step_path, step_path_arg)
self.assertIs(scene, scene_arg)
self.assertEqual(spec.three_mf_path, target_path)
self.assertIsNone(color)
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(b"3mf")
return target_path
def fake_export_glb(
step_path_arg,
scene_arg,
*,
linear_deflection,
angular_deflection,
color=None,
selector_bundle=None,
include_selector_topology=True,
):
self.assertIs(scene, scene_arg)
self.assertIsInstance(selector_bundle, SelectorBundle)
self.assertTrue(include_selector_topology)
glb_path = cad_render.part_glb_path(step_path_arg)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_bytes(b"glb")
return glb_path
def fake_extract(scene_arg, *, cad_ref, profile, options, **kwargs):
self.assertIs(scene, scene_arg)
self.assertEqual(cad_generation.SelectorProfile.ARTIFACT, profile)
return SelectorBundle(manifest={"schemaVersion": 1, "cadPath": cad_ref}, buffers={})
with mock.patch.object(cad_generation, "load_step_scene", return_value=scene), mock.patch.object(
cad_generation,
"export_part_3mf_from_scene",
side_effect=fake_three_mf_export,
) as three_mf_export_mock, mock.patch.object(
cad_generation,
"export_part_glb_from_scene",
side_effect=fake_export_glb,
), mock.patch.object(
cad_generation,
"mesh_step_scene",
) as mesh_scene, mock.patch.object(
cad_generation,
"scene_export_shape",
), mock.patch.object(
cad_generation,
"extract_selectors_from_scene",
side_effect=fake_extract,
):
cad_generation._generate_part_outputs(spec, entries_by_step_path={spec.step_path.resolve(): spec})
three_mf_export_mock.assert_called_once()
self.assertTrue(any(
call.kwargs.get("linear_deflection") <= 0.4 and call.kwargs.get("angular_deflection") == 0.3
for call in mesh_scene.mock_calls
))
self.assertIsNotNone(spec.three_mf_path)
self.assertTrue(spec.three_mf_path.exists())
def test_generate_part_outputs_writes_native_glb_sidecar(self) -> None:
step_path = self._write_step("native")
_, selected_specs = cad_generation._selected_specs_for_targets(
[str(step_path)],
step_options=self._step_options(
glb="native.glb",
mesh_tolerance=0.4,
mesh_angular_tolerance=0.3,
),
)
spec = selected_specs[0]
scene = object()
def fake_native_glb_export(
step_path_arg,
scene_arg,
*,
target_path=None,
linear_deflection=None,
angular_deflection=None,
color=None,
):
self.assertEqual(step_path, step_path_arg)
self.assertIs(scene, scene_arg)
self.assertEqual(spec.native_glb_path, target_path)
self.assertLessEqual(linear_deflection, 0.4)
self.assertEqual(0.3, angular_deflection)
self.assertIsNone(color)
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(b"native glb")
return target_path
def fake_export_glb(
step_path_arg,
scene_arg,
*,
linear_deflection,
angular_deflection,
color=None,
selector_bundle=None,
include_selector_topology=True,
):
self.assertIs(scene, scene_arg)
self.assertIsInstance(selector_bundle, SelectorBundle)
self.assertTrue(include_selector_topology)
glb_path = cad_render.part_glb_path(step_path_arg)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_bytes(b"topology glb")
return glb_path
def fake_extract(scene_arg, *, cad_ref, profile, options, **kwargs):
self.assertIs(scene, scene_arg)
self.assertEqual(cad_generation.SelectorProfile.ARTIFACT, profile)
return SelectorBundle(manifest={"schemaVersion": 1, "cadPath": cad_ref}, buffers={})
with mock.patch.object(cad_generation, "load_step_scene", return_value=scene), mock.patch.object(
cad_generation,
"export_native_glb_from_scene",
side_effect=fake_native_glb_export,
) as native_glb_export_mock, mock.patch.object(
cad_generation,
"export_part_glb_from_scene",
side_effect=fake_export_glb,
), mock.patch.object(
cad_generation,
"mesh_step_scene",
), mock.patch.object(
cad_generation,
"scene_export_shape",
), mock.patch.object(
cad_generation,
"extract_selectors_from_scene",
side_effect=fake_extract,
):
cad_generation._generate_part_outputs(spec, entries_by_step_path={spec.step_path.resolve(): spec})
native_glb_export_mock.assert_called_once()
self.assertIsNotNone(spec.native_glb_path)
self.assertTrue(spec.native_glb_path.exists())
self.assertTrue(cad_render.part_glb_path(step_path).exists())
def test_generate_assembly_outputs_writes_self_contained_glb_and_removes_stale_components(self) -> None:
step_path = self._write_step("imported-assembly")
_, selected_specs = cad_generation._selected_specs_for_targets(
[str(step_path)],
direct_step_kind="assembly",
)
spec = selected_specs[0]
scene = object()
components_dir = cad_render.native_component_glb_dir(step_path)
components_dir.mkdir(parents=True, exist_ok=True)
(components_dir / "stale.glb").write_bytes(b"stale")
def fake_export_assembly_glb(
step_path_arg,
scene_arg,
*,
linear_deflection,
angular_deflection,
color=None,
occurrence_colors=None,
selector_bundle=None,
include_selector_topology=True,
):
self.assertEqual(step_path, step_path_arg)
self.assertIs(scene, scene_arg)
self.assertIsInstance(selector_bundle, SelectorBundle)
self.assertTrue(include_selector_topology)
glb_path = cad_render.part_glb_path(step_path_arg)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_bytes(b"assembly glb")
return glb_path
def fake_extract(scene_arg, *, cad_ref, profile, options, **kwargs):
self.assertIs(scene, scene_arg)
return SelectorBundle(manifest=_summary_manifest(cad_ref), buffers={})
with mock.patch.object(cad_generation, "load_step_scene", return_value=scene), mock.patch.object(
cad_generation,
"export_assembly_glb_from_scene",
side_effect=fake_export_assembly_glb,
), mock.patch.object(
cad_generation,
"mesh_step_scene",
), mock.patch.object(
cad_generation,
"scene_export_shape",
), mock.patch.object(
cad_generation,
"extract_selectors_from_scene",
side_effect=fake_extract,
):
result = cad_generation._generate_part_outputs(spec, entries_by_step_path={spec.step_path.resolve(): spec})
self.assertIs(scene, result.scene)
self.assertTrue(cad_render.part_glb_path(step_path).exists())
self.assertFalse(components_dir.exists())
self.assertIsNotNone(result.selector_bundle)
assert result.selector_bundle is not None
topology = result.selector_bundle.manifest
self.assertEqual("gltf-node-extras", topology["assembly"]["mesh"]["addressing"])
self.assertEqual(".imported-assembly.step.glb", topology["assembly"]["mesh"]["url"])
leaves = topology["assembly"]["root"]["children"]
self.assertTrue(leaves)
self.assertNotIn("assets", leaves[0])
def test_generate_assembly_outputs_passes_occurrence_colors_to_glb(self) -> None:
step_path = self._write_step("colored-assembly")
script_path = self.temp_root / "colored_assembly.py"
script_path.write_text("", encoding="utf-8")
spec = cad_generation.EntrySpec(
source_ref=self._cad_ref("colored_assembly.py"),
cad_ref=self._cad_ref("colored_assembly"),
kind="assembly",
source_path=script_path,
display_name="colored_assembly",
source="generated",
step_path=step_path,
script_path=script_path,
)
scene = object()
occurrence_colors = {"o1.1": (0.1, 0.2, 0.3, 1.0)}
export_calls: list[dict[str, object]] = []
def fake_export_assembly_glb(
step_path_arg,
scene_arg,
*,
linear_deflection,
angular_deflection,
color=None,
occurrence_colors=None,
selector_bundle=None,
include_selector_topology=True,
):
self.assertEqual(step_path, step_path_arg)
self.assertIs(scene, scene_arg)
self.assertIsInstance(selector_bundle, SelectorBundle)
self.assertTrue(include_selector_topology)
export_calls.append({"occurrence_colors": occurrence_colors})
glb_path = cad_render.part_glb_path(step_path_arg)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_bytes(b"assembly glb")
return glb_path
def fake_extract(scene_arg, *, cad_ref, profile, options, **kwargs):
self.assertIs(scene, scene_arg)
self.assertEqual(occurrence_colors, kwargs.get("occurrence_colors"))
return SelectorBundle(manifest=_summary_manifest(cad_ref), buffers={})
assembly_composition = {
"schemaVersion": 1,
"mode": "linked",
"mesh": {
"url": "model.glb",
"hash": "",
"addressing": "gltf-node-extras",
"occurrenceIdKey": "cadOccurrenceId",
},
"root": {
"id": "root",
"occurrenceId": "o1",
"children": [{"id": "o1.1", "occurrenceId": "o1.1", "children": []}],
},
}
with mock.patch.object(cad_generation, "load_step_scene", return_value=scene), mock.patch.object(
cad_generation,
"export_assembly_glb_from_scene",
side_effect=fake_export_assembly_glb,
), mock.patch.object(
cad_generation,
"mesh_step_scene",
), mock.patch.object(
cad_generation,
"scene_export_shape",
), mock.patch.object(
cad_generation,
"extract_selectors_from_scene",
side_effect=fake_extract,
) as extract_selectors, mock.patch.object(
cad_generation,
"_assembly_composition_for_spec",
return_value=assembly_composition,
), mock.patch.object(
cad_generation,
"_generated_assembly_source_occurrence_colors",
return_value=occurrence_colors,
) as generated_colors:
cad_generation._generate_part_outputs(spec, entries_by_step_path={step_path.resolve(): spec})
self.assertEqual([{"occurrence_colors": occurrence_colors}], export_calls)
extract_selectors.assert_called_once()
generated_colors.assert_called_once()
def test_generated_assembly_3mf_uses_shared_occurrence_colors_without_topology_deferral(self) -> None:
step_path = self._write_step("colored-printable-assembly")
script_path = self.temp_root / "colored_printable_assembly.py"
script_path.write_text("", encoding="utf-8")
three_mf_path = self.temp_root / "colored_printable_assembly.3mf"
spec = cad_generation.EntrySpec(
source_ref=self._cad_ref("colored_printable_assembly.py"),
cad_ref=self._cad_ref("colored_printable_assembly"),
kind="assembly",
source_path=script_path,
display_name="colored_printable_assembly",
source="generated",
step_path=step_path,
script_path=script_path,
three_mf_path=three_mf_path,
)
scene = object()
occurrence_colors = {"o1.1": (0.1, 0.2, 0.3, 1.0)}
expected_occurrence_colors = occurrence_colors
events: list[str] = []
def fake_three_mf_export(step_path_arg, scene_arg, *, target_path=None, color=None, occurrence_colors=None):
self.assertEqual(step_path, step_path_arg)
self.assertIs(scene, scene_arg)
self.assertEqual(three_mf_path, target_path)
self.assertIsNone(color)
self.assertEqual(expected_occurrence_colors, occurrence_colors)
events.append("3mf")
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(b"3mf")
return target_path
def fake_export_assembly_glb(
step_path_arg,
scene_arg,
*,
linear_deflection,
angular_deflection,
color=None,
occurrence_colors=None,
selector_bundle=None,
include_selector_topology=True,
):
self.assertEqual(step_path, step_path_arg)
self.assertIs(scene, scene_arg)
self.assertEqual(expected_occurrence_colors, occurrence_colors)
self.assertIsInstance(selector_bundle, SelectorBundle)
self.assertTrue(include_selector_topology)
glb_path = cad_render.part_glb_path(step_path_arg)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_bytes(b"assembly glb")
events.append("glb")
return glb_path
def fake_extract(scene_arg, *, cad_ref, profile, options, **kwargs):
self.assertIs(scene, scene_arg)
self.assertEqual(occurrence_colors, kwargs.get("occurrence_colors"))
events.append("topology")
return SelectorBundle(manifest=_summary_manifest(cad_ref), buffers={})
with mock.patch.object(
cad_generation,
"load_step_scene",
return_value=scene,
), mock.patch.object(
cad_generation,
"export_part_3mf_from_scene",
side_effect=fake_three_mf_export,
) as three_mf_export_mock, mock.patch.object(
cad_generation,
"export_assembly_glb_from_scene",
side_effect=fake_export_assembly_glb,
), mock.patch.object(
cad_generation,
"mesh_step_scene",
), mock.patch.object(
cad_generation,
"scene_export_shape",
), mock.patch.object(
cad_generation,
"extract_selectors_from_scene",
side_effect=fake_extract,
), mock.patch.object(
cad_generation,
"_assembly_composition_for_spec",
return_value=None,
), mock.patch.object(
cad_generation,
"_generated_assembly_source_occurrence_colors",
return_value=occurrence_colors,
) as generated_colors:
cad_generation._generate_part_outputs(spec, entries_by_step_path={step_path.resolve(): spec})
three_mf_export_mock.assert_called_once()
generated_colors.assert_called_once()
self.assertIn("3mf", events)
self.assertIn("topology", events)
self.assertLess(events.index("3mf"), events.index("topology"))
self.assertTrue(three_mf_path.exists())
def test_expand_specs_walks_leaves_through_compound_grouping_nodes(self):
"""Compound grouping AssemblyNodeSpec (``source_path=None`` with
nested children) is a valid pattern per the data model:
``AssemblyNodeSpec.source_path`` is typed ``Path | None`` and
``_leaf_instances_from_nodes`` recursively flattens nested groups.
Dependency expansion must therefore skip the compound itself
without crashing on ``compound.source_path.resolve()``, while still
discovering the nested leaf sources by way of
``assembly_spec.instances`` (which is the flattened leaf view).
"""
# Real part on disk so source resolution finds it.
leaf_script = self._generator_script("nested_part")
assembly_path = self.temp_root / "compound_root.py"
assembly_path.write_text("# noop\n", encoding="utf-8")
identity = tuple(IDENTITY_TRANSFORM)
leaf_step_rel = "nested_part.step"
leaf_instance = AssemblyInstanceSpec(
instance_id="nested_part",
source_path=leaf_script.resolve(),
path=leaf_step_rel,
name="nested_part",
transform=identity,
)
leaf_node = AssemblyNodeSpec(
instance_id="nested_part",
name="nested_part",
transform=identity,
source_path=leaf_script.resolve(),
path=leaf_step_rel,
children=(),
)
compound = AssemblyNodeSpec(
instance_id="leg_FR",
name="leg_FR",
transform=identity,
source_path=None,
path=None,
children=(leaf_node,),
)
fake_spec = AssemblySpec(
assembly_path=assembly_path.resolve(),
instances=(leaf_instance,),
children=(compound,),
)
entry = cad_generation.EntrySpec(
source_ref=self._cad_ref("compound_root"),
cad_ref=self._cad_ref("compound_root"),
kind="assembly",
source_path=assembly_path.resolve(),
display_name="compound_root",
source="generator",
)
with mock.patch.object(
cad_generation, "read_assembly_spec", return_value=fake_spec
):
result = cad_generation._expand_specs_with_file_dependencies([entry])
discovered_paths = {spec.source_path for spec in result}
self.assertIn(assembly_path.resolve(), discovered_paths)
self.assertIn(leaf_script.resolve(), discovered_paths)
if __name__ == "__main__":
unittest.main()