feat: integrate SimpleCADAPI 2.0.2 CAD workflows
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
MODEL_DIR = REPO / "models" / "lcd25-reducer" / "semantic-assembly"
|
||||
SCHEMA_PATH = (
|
||||
REPO
|
||||
/ "designir-pipeline"
|
||||
/ "contracts"
|
||||
/ "assembly-designir-1.0.schema.json"
|
||||
)
|
||||
VALIDATOR_PATH = MODEL_DIR / "validate_assembly_design.py"
|
||||
|
||||
|
||||
def load_validator():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"assembly_design_validator", VALIDATOR_PATH
|
||||
)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def load_json(path: Path):
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_lcd25_coordinate_free_base_model_is_valid():
|
||||
validator = load_validator()
|
||||
schema = load_json(SCHEMA_PATH)
|
||||
payload = load_json(
|
||||
MODEL_DIR / "lcd25-harmonic-reducer.assembly-designir.json"
|
||||
)
|
||||
report = validator.validate(payload, schema)
|
||||
assert report["status"] == "pass"
|
||||
assert report["coordinate_free"] is True
|
||||
assert report["component_count"] == 10
|
||||
assert report["relation_count"] == 15
|
||||
|
||||
|
||||
def test_output_shaft_patch_preserves_coordinate_free_contract():
|
||||
validator = load_validator()
|
||||
schema = load_json(SCHEMA_PATH)
|
||||
payload = load_json(
|
||||
MODEL_DIR / "lcd25-harmonic-reducer.assembly-designir.json"
|
||||
)
|
||||
patch = load_json(MODEL_DIR / "variants" / "add-output-shaft.patch.json")
|
||||
resolved = validator.apply_patch(payload, patch)
|
||||
report = validator.validate(resolved, schema)
|
||||
assert report["status"] == "pass"
|
||||
assert report["coordinate_free"] is True
|
||||
assert report["component_count"] == 11
|
||||
assert "output_shaft" in {
|
||||
component["id"] for component in resolved["components"]
|
||||
}
|
||||
assert "output_shaft" in next(
|
||||
group["members"]
|
||||
for group in resolved["kinematics"]["rigid_groups"]
|
||||
if group["id"] == "output_group"
|
||||
)
|
||||
|
||||
|
||||
def test_authored_transform_is_rejected():
|
||||
validator = load_validator()
|
||||
schema = load_json(SCHEMA_PATH)
|
||||
payload = load_json(
|
||||
MODEL_DIR / "lcd25-harmonic-reducer.assembly-designir.json"
|
||||
)
|
||||
invalid = copy.deepcopy(payload)
|
||||
invalid["components"][0]["transform"] = [
|
||||
[1, 0, 0, 0],
|
||||
[0, 1, 0, 0],
|
||||
[0, 0, 1, 0],
|
||||
]
|
||||
report = validator.validate(invalid, schema)
|
||||
assert report["status"] == "fail"
|
||||
assert report["coordinate_free"] is False
|
||||
assert any(
|
||||
"coordinate placement key is forbidden" in failure
|
||||
for failure in report["failures"]
|
||||
)
|
||||
@@ -81,7 +81,13 @@ class DesignIRPipelineTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual("simplecadapi", result["backend"])
|
||||
self.assertGreater(result["facts"]["volume_mm3"], 30_000)
|
||||
self.assertTrue(Path(result["model_json"]).is_file())
|
||||
model_json_path = Path(result["model_json"])
|
||||
self.assertTrue(model_json_path.is_file())
|
||||
model_json = json.loads(model_json_path.read_text(encoding="utf-8"))
|
||||
serialized = json.dumps(model_json)
|
||||
self.assertIn("feature.base.flange", serialized)
|
||||
self.assertIn("feature.central.bore", serialized)
|
||||
self.assertTrue(model_json.get("leaf_ids"))
|
||||
|
||||
def test_arbitrary_axis_geometric_center_preserves_pose(self) -> None:
|
||||
payload = self.payload()
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -226,6 +227,63 @@ class CadExperienceTest(unittest.TestCase):
|
||||
self.assertFalse(normalized["reconstruction_eligible"])
|
||||
self.assertIn("no_closed_solid", normalized["rejection_reasons"])
|
||||
|
||||
def test_spur_gear_flanks_are_not_mislabeled_as_hole_pattern(self):
|
||||
surfaces = [
|
||||
{
|
||||
"type": "cylinder",
|
||||
"axis": [1.0, 0.0, 0.0],
|
||||
"location": [0.0, 0.0, 0.0],
|
||||
"radius": radius,
|
||||
"area": 100.0,
|
||||
}
|
||||
for radius in (3.0, 8.8)
|
||||
]
|
||||
for index in range(40):
|
||||
angle = 2.0 * math.pi * index / 40.0
|
||||
surfaces.append(
|
||||
{
|
||||
"type": "cylinder",
|
||||
"axis": [1.0, 0.0, 0.0],
|
||||
"location": [
|
||||
0.0,
|
||||
7.5 * math.cos(angle),
|
||||
7.5 * math.sin(angle),
|
||||
],
|
||||
"radius": 3.2,
|
||||
"area": 10.0,
|
||||
}
|
||||
)
|
||||
surfaces.extend(
|
||||
{"type": "plane", "area": 250.0} for _ in range(2)
|
||||
)
|
||||
|
||||
family, features, constraints, summary = STEP_MODULE._semantic_summary(
|
||||
surfaces, [4.0, 0.0, 0.0], [8.0, 17.6, 17.6]
|
||||
)
|
||||
feature_roles = {item["type"] for item in features}
|
||||
self.assertEqual("external_spur_gear", family)
|
||||
self.assertEqual(20, summary["inferred_tooth_count"])
|
||||
self.assertIn("external_spur_gear", feature_roles)
|
||||
self.assertIn("equal_angular_tooth_pattern", feature_roles)
|
||||
self.assertNotIn("repeated_axial_hole_pattern", feature_roles)
|
||||
self.assertIn(
|
||||
"equal_angular_tooth_pattern",
|
||||
{item["id"] for item in constraints},
|
||||
)
|
||||
|
||||
parameters = STEP_MODULE._semantic_parameter_candidates(
|
||||
surfaces,
|
||||
[4.0, 0.0, 0.0],
|
||||
[8.0, 17.6, 17.6],
|
||||
features,
|
||||
summary,
|
||||
)
|
||||
values = {item["name"]: item["value"] for item in parameters}
|
||||
self.assertEqual(20, values["tooth_count"])
|
||||
self.assertAlmostEqual(0.8, values["gear_module"], places=6)
|
||||
self.assertEqual(8.0, values["gear_width"])
|
||||
self.assertNotIn("repeated_feature_count", values)
|
||||
|
||||
def test_sha256_identity_deduplicates_renamed_sources(self):
|
||||
digest = "0" * 64
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
|
||||
from build123d import Box, export_step
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_module(name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
SURFACEIR = load_module(
|
||||
"surfaceir_pipeline_robot_export_test",
|
||||
ROOT / "scripts" / "surfaceir_pipeline.py",
|
||||
)
|
||||
ROBOT_EXPORT = load_module(
|
||||
"robot_description_export_test",
|
||||
ROOT / "scripts" / "robot_description_export.py",
|
||||
)
|
||||
|
||||
|
||||
class RobotDescriptionExportTests(unittest.TestCase):
|
||||
def test_portable_urdf_and_mjcf_packages_rebuild_from_designir(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
teacher = root / "teacher.step"
|
||||
designir = root / "box.designir.json"
|
||||
export_step(Box(10, 20, 30), teacher)
|
||||
SURFACEIR.write_json(
|
||||
designir,
|
||||
SURFACEIR.extract_surfaceir(teacher),
|
||||
)
|
||||
teacher.unlink()
|
||||
|
||||
for output_format, description_suffix, xml_root in (
|
||||
("urdf", ".urdf", "robot"),
|
||||
("mjcf", ".mjcf.xml", "mujoco"),
|
||||
):
|
||||
result = ROBOT_EXPORT.export_robot_package(
|
||||
designir,
|
||||
root / output_format,
|
||||
output_format,
|
||||
)
|
||||
package = Path(result["package"])
|
||||
self.assertTrue(package.is_file())
|
||||
with zipfile.ZipFile(package) as archive:
|
||||
names = archive.namelist()
|
||||
description = next(
|
||||
name
|
||||
for name in names
|
||||
if name.endswith(description_suffix)
|
||||
)
|
||||
self.assertTrue(
|
||||
any(name.startswith("meshes/") and name.endswith(".stl") for name in names)
|
||||
)
|
||||
self.assertIn("robot-export.json", names)
|
||||
root_element = ET.fromstring(archive.read(description))
|
||||
self.assertEqual(xml_root, root_element.tag)
|
||||
self.assertEqual(
|
||||
"static_single_link",
|
||||
result["robot_structure"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,7 +6,7 @@ import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from build123d import Plane, Rectangle
|
||||
from build123d import Align, Box, Plane, Rectangle, Sphere, export_step
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -29,6 +29,57 @@ DESIGNIR = load_module(
|
||||
|
||||
|
||||
class SurfaceIRPipelineTests(unittest.TestCase):
|
||||
def test_closed_single_face_sphere_roundtrip(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
teacher = directory / "sphere.step"
|
||||
rebuilt_path = directory / "sphere-rebuilt.step"
|
||||
expected = Sphere(1)
|
||||
export_step(expected, teacher)
|
||||
|
||||
payload = SURFACEIR.extract_surfaceir(teacher)
|
||||
shell = payload["surface_layer"]["solids"][0]["shells"][0]
|
||||
self.assertEqual(1, len(shell["faces"]))
|
||||
|
||||
rebuilt = SURFACEIR.build_surfaceir(
|
||||
payload, "exact_3d_pcurve"
|
||||
)
|
||||
SURFACEIR.export_step_shape(rebuilt, rebuilt_path)
|
||||
actual = DESIGNIR.import_step(rebuilt_path)
|
||||
self.assertTrue(actual.is_valid)
|
||||
self.assertEqual(1, len(actual.solids()))
|
||||
self.assertAlmostEqual(expected.volume, actual.volume, places=9)
|
||||
|
||||
def test_multi_shell_solid_preserves_periodic_inner_shell(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
teacher = directory / "cavity.step"
|
||||
rebuilt_path = directory / "cavity-rebuilt.step"
|
||||
expected = Box(
|
||||
10,
|
||||
10,
|
||||
10,
|
||||
align=(Align.CENTER, Align.CENTER, Align.CENTER),
|
||||
) - Sphere(1)
|
||||
export_step(expected, teacher)
|
||||
|
||||
payload = SURFACEIR.extract_surfaceir(teacher)
|
||||
shells = payload["surface_layer"]["solids"][0]["shells"]
|
||||
self.assertEqual(2, len(shells))
|
||||
self.assertTrue(any(len(shell["faces"]) == 1 for shell in shells))
|
||||
|
||||
rebuilt = SURFACEIR.build_surfaceir(
|
||||
payload, "exact_3d_pcurve"
|
||||
)
|
||||
SURFACEIR.export_step_shape(rebuilt, rebuilt_path)
|
||||
actual = DESIGNIR.import_step(rebuilt_path)
|
||||
self.assertTrue(actual.is_valid)
|
||||
self.assertEqual(1, len(actual.solids()))
|
||||
self.assertEqual(2, len(actual.solids()[0].shells()))
|
||||
self.assertLess(
|
||||
abs(expected.volume - actual.volume) / expected.volume, 1e-10
|
||||
)
|
||||
|
||||
def test_independent_json_roundtrip_preserves_exact_geometry(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
@@ -42,12 +93,27 @@ class SurfaceIRPipelineTests(unittest.TestCase):
|
||||
teacher.unlink()
|
||||
|
||||
serialized = portable_json.read_text(encoding="utf-8").lower()
|
||||
stored = json.loads(serialized)
|
||||
self.assertEqual(
|
||||
"gzip+base64+json",
|
||||
stored["surface_layer"]["encoding"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"surfaceir-3.0",
|
||||
stored["surface_layer"]["format"],
|
||||
)
|
||||
self.assertNotIn("teacher.step", serialized)
|
||||
self.assertNotIn("source_path", serialized)
|
||||
self.assertNotIn("import_step", serialized)
|
||||
self.assertNotIn('"mesh"', serialized)
|
||||
|
||||
rebuilt_shape = SURFACEIR.build_surfaceir(surfaceir)
|
||||
decoded = SURFACEIR.read_designir(portable_json)
|
||||
self.assertEqual(surfaceir["surface_layer"], decoded["surface_layer"])
|
||||
self.assertIn(
|
||||
"overall_scale",
|
||||
decoded["edit_interface"]["semantic_parameters"],
|
||||
)
|
||||
rebuilt_shape = SURFACEIR.build_surfaceir(decoded)
|
||||
SURFACEIR.export_step_shape(rebuilt_shape, rebuilt)
|
||||
self.assertTrue(rebuilt.is_file())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user