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': ''}\n", encoding="utf-8", ) (self._isolated_roots.cad_root / "sample_sdf.py").write_text( "def gen_sdf():\n" " return {'xml': ''}\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': '',", *([f" 'urdf_output': {urdf_output!r},"] if urdf_output is not None else []), " }", "", ] sdf_block = [ "def gen_sdf():", " _record('gen_sdf')", " return {", " 'xml': '',", *([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': '',", *([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': '',", *([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 ''", "", "def gen_sdf():", " return ''", "", ] ), 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()