feat: integrate SimpleCADAPI 2.0.2 CAD workflows

This commit is contained in:
Jerry
2026-08-03 11:17:05 +08:00
parent b5738e9109
commit c3a0f269b7
481 changed files with 110229 additions and 12826 deletions
@@ -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())