461 lines
18 KiB
Python
461 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import io
|
|
import tempfile
|
|
import unittest
|
|
import warnings
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest import mock
|
|
|
|
from tests.python.support.paths import add_repo_path
|
|
|
|
add_repo_path("packages/cadpy/src")
|
|
|
|
from cadpy import generation
|
|
from cadpy.assembly_spec import (
|
|
IDENTITY_TRANSFORM,
|
|
AssemblyInstanceSpec,
|
|
AssemblyNodeSpec,
|
|
AssemblySpec,
|
|
)
|
|
from cadpy.catalog import CadSource
|
|
from cadpy.metadata import parse_generator_metadata
|
|
from cadpy.step_export import _create_bin_xcaf_doc, export_build123d_step_scene
|
|
from cadpy.step_scene import LoadedStepScene, _bbox_from_shape, scene_leaf_occurrences, scene_occurrence_shape
|
|
|
|
|
|
def _rounded_color(color: tuple[float, ...]) -> tuple[float, ...]:
|
|
return tuple(round(component, 3) for component in color)
|
|
|
|
|
|
def _srgb_to_linear(component: float) -> float:
|
|
if component <= 0.04045:
|
|
return component / 12.92
|
|
return ((component + 0.055) / 1.055) ** 2.4
|
|
|
|
|
|
class CompoundAssemblyGenerationTests(unittest.TestCase):
|
|
def test_step_payload_rejects_legacy_output_field(self) -> None:
|
|
with self.assertRaisesRegex(TypeError, "unsupported field\\(s\\): step_output"):
|
|
generation._normalize_step_payload(
|
|
{"shape": object(), "step_output": "legacy.step"},
|
|
script_path=Path("part.py"),
|
|
)
|
|
|
|
def test_step_payload_preserves_instance_assembly_mates(self) -> None:
|
|
payload = generation._normalize_step_payload(
|
|
{
|
|
"instances": [
|
|
{
|
|
"path": "servo.step",
|
|
"name": "servo",
|
|
}
|
|
],
|
|
"assembly_mates": [
|
|
{
|
|
"sourceLabel": "servo_to_bracket",
|
|
"relation": "rigid",
|
|
"fixed": "servo_mount",
|
|
"moving": "bracket_foot",
|
|
}
|
|
],
|
|
},
|
|
script_path=Path("assembly.py"),
|
|
)
|
|
|
|
self.assertEqual("servo_to_bracket", payload["assembly_mates"][0]["sourceLabel"])
|
|
|
|
def test_attach_envelope_assembly_mates_renumbers_for_scene(self) -> None:
|
|
scene = LoadedStepScene(step_path=Path("assembly.step"), roots=[], prototype_shapes={})
|
|
|
|
generation._attach_envelope_assembly_mates(
|
|
scene,
|
|
{
|
|
"assembly_mates": [
|
|
{
|
|
"sourceLabel": "servo_to_bracket",
|
|
"relation": "rigid",
|
|
"fixed": "servo_mount",
|
|
"moving": "bracket_foot",
|
|
}
|
|
]
|
|
},
|
|
script_path=Path("assembly.py"),
|
|
)
|
|
|
|
self.assertEqual(
|
|
[
|
|
{
|
|
"id": "m1",
|
|
"label": "m1",
|
|
"sourceLabel": "servo_to_bracket",
|
|
"relation": "rigid",
|
|
"fixed": "servo_mount",
|
|
"moving": "bracket_foot",
|
|
}
|
|
],
|
|
scene.assembly_mates,
|
|
)
|
|
|
|
def test_dxf_payload_rejects_legacy_output_field(self) -> None:
|
|
with self.assertRaisesRegex(TypeError, "unsupported field\\(s\\): dxf_output"):
|
|
generation._normalize_dxf_payload(
|
|
{"document": object(), "dxf_output": "legacy.dxf"},
|
|
script_path=Path("part.py"),
|
|
)
|
|
|
|
def test_metadata_rejects_legacy_output_fields(self) -> None:
|
|
cases = [
|
|
("gen_step", "return {'shape': object(), 'step_output': 'legacy.step'}", "step_output"),
|
|
("gen_dxf", "return {'document': object(), 'dxf_output': 'legacy.dxf'}", "dxf_output"),
|
|
("gen_urdf", "return {'xml': '<robot />', 'urdf_output': 'legacy.urdf'}", "urdf_output"),
|
|
("gen_sdf", "return {'xml': '<sdf version=\"1.12\" />', 'sdf_output': 'legacy.sdf'}", "sdf_output"),
|
|
]
|
|
for function_name, return_line, field_name in cases:
|
|
with self.subTest(function_name=function_name), tempfile.TemporaryDirectory(prefix="cadpy-output-field-") as tempdir:
|
|
script_path = Path(tempdir) / "part.py"
|
|
script_path.write_text(
|
|
"\n".join(
|
|
[
|
|
"def gen_step():",
|
|
" return {'shape': object()}",
|
|
"",
|
|
f"def {function_name}():",
|
|
f" {return_line}",
|
|
"",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with self.assertRaisesRegex(ValueError, f"unsupported field\\(s\\): {field_name}"):
|
|
parse_generator_metadata(script_path)
|
|
|
|
def test_run_selected_specs_preserves_action_stdout(self) -> None:
|
|
spec = SimpleNamespace(source_ref="part.py")
|
|
stdout = io.StringIO()
|
|
|
|
with contextlib.redirect_stdout(stdout):
|
|
generation._run_selected_specs(
|
|
[spec],
|
|
action=lambda _spec: print("generator summary"),
|
|
logger=generation.CliLogger("test", stream=io.StringIO()),
|
|
success_message=None,
|
|
)
|
|
|
|
self.assertEqual("generator summary\n", stdout.getvalue())
|
|
|
|
def test_compound_with_explicit_children_is_discovered_as_assembly(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
|
|
script_path = Path(tempdir) / "robot_arm.py"
|
|
script_path.write_text(
|
|
"\n".join(
|
|
[
|
|
"from build123d import Compound",
|
|
"",
|
|
"def gen_step():",
|
|
" parts = []",
|
|
" assembly = Compound(",
|
|
" obj=parts,",
|
|
" children=parts,",
|
|
" label='robot_arm_static_display_pose',",
|
|
" )",
|
|
" return assembly",
|
|
"",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
metadata = parse_generator_metadata(script_path)
|
|
|
|
self.assertIsNotNone(metadata)
|
|
self.assertEqual("assembly", metadata.kind)
|
|
|
|
def test_compound_with_literal_obj_sequence_is_discovered_as_assembly(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
|
|
script_path = Path(tempdir) / "compound_arm.py"
|
|
script_path.write_text(
|
|
"\n".join(
|
|
[
|
|
"from build123d import Box, Compound",
|
|
"",
|
|
"def gen_step():",
|
|
" left = Box(1, 1, 1)",
|
|
" right = Box(1, 1, 1)",
|
|
" return Compound(obj=[left, right], label='compound_arm')",
|
|
"",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
metadata = parse_generator_metadata(script_path)
|
|
|
|
self.assertIsNotNone(metadata)
|
|
self.assertEqual("assembly", metadata.kind)
|
|
|
|
def test_childless_compound_obj_sequence_is_runtime_assembly(self) -> None:
|
|
import build123d
|
|
|
|
left = build123d.Box(1, 1, 1)
|
|
right = build123d.Box(1, 1, 1)
|
|
shape = build123d.Compound(obj=[left, right], label="compound_arm")
|
|
|
|
self.assertEqual("assembly", generation._shape_payload_entry_kind(shape, fallback="part"))
|
|
|
|
def test_labeled_childless_compound_does_not_warn_without_color(self) -> None:
|
|
import build123d
|
|
|
|
left = build123d.Box(1, 1, 1)
|
|
right = build123d.Box(1, 1, 1)
|
|
shape = build123d.Compound(obj=[left, right], label="compound_arm")
|
|
|
|
with warnings.catch_warnings(record=True) as caught:
|
|
warnings.simplefilter("always")
|
|
_create_bin_xcaf_doc(shape)
|
|
|
|
messages = [str(item.message) for item in caught]
|
|
self.assertNotIn("Unknown Compound type, color not set", messages)
|
|
|
|
def test_colored_child_shapes_survive_compound_assembly_export(self) -> None:
|
|
import build123d
|
|
|
|
with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
|
|
left = build123d.Box(1, 1, 1)
|
|
left.label = "red_child"
|
|
left.color = build123d.Color(1, 0, 0)
|
|
right = build123d.Pos(2, 0, 0) * build123d.Box(1, 1, 1)
|
|
right.label = "blue_child"
|
|
right.color = build123d.Color(0, 0, 1)
|
|
shape = build123d.Compound(children=[left, right], label="colored_assembly")
|
|
|
|
scene = export_build123d_step_scene(
|
|
shape,
|
|
Path(tempdir) / "colored_assembly.step",
|
|
text_to_cad_entry_kind="assembly",
|
|
)
|
|
|
|
colors = {
|
|
tuple(round(component, 3) for component in color)
|
|
for color in scene.prototype_colors.values()
|
|
}
|
|
colors.update(
|
|
tuple(round(component, 3) for component in node.color)
|
|
for root in scene.roots
|
|
for node in root.children
|
|
if node.color is not None
|
|
)
|
|
|
|
self.assertEqual(1, len(scene.roots))
|
|
self.assertEqual(2, len(scene.roots[0].children))
|
|
self.assertIn((1.0, 0.0, 0.0, 1.0), colors)
|
|
self.assertIn((0.0, 0.0, 1.0, 1.0), colors)
|
|
|
|
def test_nested_colored_compound_keeps_parent_transform(self) -> None:
|
|
import build123d
|
|
|
|
with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
|
|
child = build123d.Box(1, 1, 1)
|
|
child.label = "motor_body"
|
|
child.color = build123d.Color(0.1, 0.2, 0.3)
|
|
expected_color = _rounded_color(child.color)
|
|
expected_linear_color = _rounded_color(
|
|
(
|
|
*(_srgb_to_linear(component) for component in expected_color[:3]),
|
|
expected_color[3],
|
|
)
|
|
)
|
|
nested = build123d.Compound(children=[child], label="imported_motor")
|
|
placed = build123d.Pos(20, 0, 0) * nested
|
|
placed.label = "placed_motor"
|
|
root = build123d.Compound(children=[placed], label="arm")
|
|
|
|
scene = export_build123d_step_scene(
|
|
root,
|
|
Path(tempdir) / "arm.step",
|
|
text_to_cad_entry_kind="assembly",
|
|
)
|
|
|
|
leaves = scene_leaf_occurrences(scene)
|
|
self.assertEqual(1, len(leaves))
|
|
bbox = _bbox_from_shape(scene_occurrence_shape(scene, leaves[0]))
|
|
self.assertGreater(bbox["min"][0], 19.0)
|
|
self.assertLess(bbox["max"][0], 21.0)
|
|
self.assertIn(
|
|
_rounded_color(leaves[0].color),
|
|
{expected_color, expected_linear_color},
|
|
)
|
|
|
|
def test_shape_payload_can_export_with_assembly_entry_kind(self) -> None:
|
|
import build123d
|
|
|
|
with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
|
|
script_path = Path(tempdir) / "robot_arm.py"
|
|
script_path.write_text("def gen_step():\n return None\n", encoding="utf-8")
|
|
output_path = script_path.with_suffix(".step")
|
|
scene = LoadedStepScene(step_path=output_path.resolve(), roots=[], prototype_shapes={})
|
|
left = build123d.Box(1, 1, 1)
|
|
right = build123d.Box(1, 1, 1)
|
|
shape = build123d.Compound(children=[left, right], label="robot_arm")
|
|
|
|
with (
|
|
mock.patch.object(
|
|
generation,
|
|
"python_source_hash",
|
|
return_value=SimpleNamespace(
|
|
source_path="robot_arm.py",
|
|
source_hash="hash-123",
|
|
),
|
|
),
|
|
mock.patch.object(generation, "export_build123d_step_scene", return_value=scene) as export_scene,
|
|
):
|
|
result = generation._write_shape_step_payload(
|
|
{"shape": shape},
|
|
output_path=output_path,
|
|
script_path=script_path,
|
|
logger=generation.CliLogger("test"),
|
|
entry_kind="assembly",
|
|
)
|
|
|
|
self.assertIs(result, scene)
|
|
self.assertEqual("assembly", export_scene.call_args.kwargs["text_to_cad_entry_kind"])
|
|
self.assertEqual("assembly", getattr(scene, "text_to_cad_entry_kind", None))
|
|
self.assertEqual("shape", getattr(scene, "step_payload_kind", None))
|
|
|
|
def test_effective_spec_follows_runtime_shape_entry_kind(self) -> None:
|
|
step_path = Path("/tmp/compound.step")
|
|
scene = LoadedStepScene(step_path=step_path, roots=[], prototype_shapes={})
|
|
scene.text_to_cad_entry_kind = "assembly"
|
|
spec = generation.EntrySpec(
|
|
source_ref="compound.py",
|
|
cad_ref="compound",
|
|
kind="part",
|
|
source_path=Path("/tmp/compound.py"),
|
|
display_name="compound",
|
|
source="generated",
|
|
step_path=step_path,
|
|
script_path=Path("/tmp/compound.py"),
|
|
)
|
|
|
|
effective = generation._effective_step_spec_for_scene(spec, scene)
|
|
|
|
self.assertEqual("assembly", effective.kind)
|
|
self.assertEqual("part", spec.kind)
|
|
|
|
def test_artifact_outputs_use_runtime_shape_entry_kind(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
|
|
step_path = Path(tempdir) / "compound.step"
|
|
script_path = Path(tempdir) / "compound.py"
|
|
scene = LoadedStepScene(step_path=step_path.resolve(), roots=[], prototype_shapes={})
|
|
scene.text_to_cad_entry_kind = "assembly"
|
|
scene.source_kind = "python"
|
|
scene.source_path = "compound.py"
|
|
scene.source_hash = "source-hash"
|
|
spec = generation.EntrySpec(
|
|
source_ref="compound.py",
|
|
cad_ref="compound",
|
|
kind="part",
|
|
source_path=script_path,
|
|
display_name="compound",
|
|
source="generated",
|
|
step_path=step_path,
|
|
script_path=script_path,
|
|
)
|
|
selector_bundle = generation.SelectorBundle(manifest={"stats": {}})
|
|
|
|
with (
|
|
mock.patch.object(generation, "_existing_topology_artifact_matches_spec_without_scene", return_value=False),
|
|
mock.patch.object(generation, "_existing_topology_artifact_matches_options", return_value=False),
|
|
mock.patch.object(generation, "_selector_options_for_part", return_value=generation.SelectorOptions()),
|
|
mock.patch.object(generation, "mesh_step_scene"),
|
|
mock.patch.object(generation, "scene_export_shape"),
|
|
mock.patch.object(generation, "_reset_step_artifact_dir"),
|
|
mock.patch.object(generation, "_run_artifact_jobs", return_value={"GLB/topology": selector_bundle}),
|
|
):
|
|
result = generation._generate_part_outputs(
|
|
spec,
|
|
entries_by_step_path={step_path.resolve(): spec},
|
|
preloaded_scene=scene,
|
|
require_step_file=False,
|
|
force=True,
|
|
)
|
|
|
|
self.assertEqual("assembly", result.spec.kind)
|
|
self.assertIs(result.selector_bundle, selector_bundle)
|
|
|
|
def test_dependency_expansion_walks_flattened_grouping_node_leaves(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="cadpy-dependencies-") as tempdir:
|
|
root = Path(tempdir)
|
|
assembly_path = root / "grouped_assembly.py"
|
|
assembly_step = root / "grouped_assembly.step"
|
|
leaf_step = root / "nested_part.step"
|
|
assembly_path.write_text("def gen_step():\n return None\n", encoding="utf-8")
|
|
leaf_step.write_text("ISO-10303-21; END-ISO-10303-21;\n", encoding="utf-8")
|
|
|
|
identity = tuple(IDENTITY_TRANSFORM)
|
|
leaf_instance = AssemblyInstanceSpec(
|
|
instance_id="nested_part",
|
|
source_path=leaf_step.resolve(),
|
|
path="nested_part.step",
|
|
name="nested_part",
|
|
transform=identity,
|
|
)
|
|
leaf_node = AssemblyNodeSpec(
|
|
instance_id="nested_part",
|
|
name="nested_part",
|
|
transform=identity,
|
|
source_path=leaf_step.resolve(),
|
|
path="nested_part.step",
|
|
children=(),
|
|
)
|
|
grouping_node = AssemblyNodeSpec(
|
|
instance_id="front_group",
|
|
name="front_group",
|
|
transform=identity,
|
|
source_path=None,
|
|
path=None,
|
|
children=(leaf_node,),
|
|
)
|
|
assembly_spec = AssemblySpec(
|
|
assembly_path=assembly_path.resolve(),
|
|
instances=(leaf_instance,),
|
|
children=(grouping_node,),
|
|
)
|
|
entry = generation.EntrySpec(
|
|
source_ref="grouped_assembly.py",
|
|
cad_ref="grouped_assembly",
|
|
kind="assembly",
|
|
source_path=assembly_path.resolve(),
|
|
display_name="grouped_assembly",
|
|
source="generated",
|
|
step_path=assembly_step.resolve(),
|
|
script_path=assembly_path.resolve(),
|
|
)
|
|
leaf_source = CadSource(
|
|
source_ref="nested_part.step",
|
|
cad_ref="nested_part",
|
|
kind="part",
|
|
source_path=leaf_step.resolve(),
|
|
source="imported",
|
|
origin_path=leaf_step.resolve(),
|
|
step_path=leaf_step.resolve(),
|
|
)
|
|
|
|
with (
|
|
mock.patch.object(generation, "read_assembly_spec", return_value=assembly_spec),
|
|
mock.patch.object(generation, "_source_lookup_by_path", return_value={leaf_step.resolve(): leaf_source}),
|
|
):
|
|
expanded = generation._expand_specs_with_file_dependencies([entry])
|
|
|
|
self.assertEqual(
|
|
[assembly_path.resolve(), leaf_step.resolve()],
|
|
[spec.source_path for spec in expanded],
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|