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