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()
