468 lines
19 KiB
Python
468 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import importlib.util
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SCRIPT = ROOT / "scripts" / "designir_pipeline.py"
|
|
SPEC = importlib.util.spec_from_file_location("designir_pipeline", SCRIPT)
|
|
MODULE = importlib.util.module_from_spec(SPEC)
|
|
assert SPEC.loader
|
|
SPEC.loader.exec_module(MODULE)
|
|
|
|
FEATURE_TREE_SCRIPT = ROOT / "scripts" / "feature_tree.py"
|
|
FEATURE_TREE_SPEC = importlib.util.spec_from_file_location(
|
|
"feature_tree", FEATURE_TREE_SCRIPT
|
|
)
|
|
FEATURE_TREE = importlib.util.module_from_spec(FEATURE_TREE_SPEC)
|
|
assert FEATURE_TREE_SPEC.loader
|
|
FEATURE_TREE_SPEC.loader.exec_module(FEATURE_TREE)
|
|
|
|
|
|
class DesignIRPipelineTests(unittest.TestCase):
|
|
def payload(self) -> dict:
|
|
return json.loads(
|
|
(ROOT / "examples" / "raised_hub_flange.designir.json").read_text()
|
|
)
|
|
|
|
def test_example_is_valid_and_compiles(self) -> None:
|
|
payload = MODULE.validate_designir(self.payload())
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
output = Path(temporary) / "flange.step"
|
|
result = MODULE.compile_designir(payload, output)
|
|
self.assertTrue(output.is_file())
|
|
self.assertEqual(1, result["facts"]["solid_count"])
|
|
self.assertGreater(result["facts"]["volume_mm3"], 30_000)
|
|
|
|
def test_legacy_model_migrates_to_semantic_designir_3(self) -> None:
|
|
migrated = MODULE.migrate_designir_2_to_3(self.payload())
|
|
self.assertEqual("3.0", migrated["schema_version"])
|
|
self.assertEqual(
|
|
"fully_semantic_parametric", migrated["reconstruction_mode"]
|
|
)
|
|
self.assertIn(
|
|
"outer_radius", migrated["edit_interface"]["semantic_parameters"]
|
|
)
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
output = Path(temporary) / "flange.step"
|
|
result = MODULE.compile_designir(migrated, output)
|
|
self.assertEqual(1, result["facts"]["solid_count"])
|
|
|
|
def test_generated_step_materializes_hybrid_surface_snapshot(self) -> None:
|
|
migrated = MODULE.migrate_designir_2_to_3(self.payload())
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
output = Path(temporary) / "flange.step"
|
|
MODULE.compile_designir(migrated, output)
|
|
hybrid = MODULE.materialize_hybrid_designir(migrated, output)
|
|
self.assertEqual(
|
|
"hybrid_semantic_surface_parametric",
|
|
hybrid["reconstruction_mode"],
|
|
)
|
|
self.assertTrue(hybrid["surface_layer"]["solids"])
|
|
self.assertTrue(
|
|
hybrid["compiled_surface_provenance"][
|
|
"semantic_layer_authoritative"
|
|
]
|
|
)
|
|
self.assertTrue(MODULE.anti_cheat(hybrid)["valid"])
|
|
rebuilt = Path(temporary) / "hybrid-rebuilt.step"
|
|
MODULE.compile_designir(hybrid, rebuilt)
|
|
self.assertTrue(rebuilt.is_file())
|
|
|
|
def test_feature_tree_is_derived_from_semantic_designir(self) -> None:
|
|
migrated = MODULE.migrate_designir_2_to_3(self.payload())
|
|
tree = FEATURE_TREE.generate_feature_tree(
|
|
migrated,
|
|
designir_path="flange.designir.json",
|
|
step_path="flange.step",
|
|
backend="build123d",
|
|
compiled=True,
|
|
validated=True,
|
|
)
|
|
self.assertEqual("solidworks_feature_manager", tree["tree_kind"])
|
|
self.assertEqual("1.0", tree["schema_version"])
|
|
serialized = json.dumps(tree)
|
|
self.assertNotIn('"designir_3"', serialized)
|
|
self.assertNotIn('"source_designir_feature"', serialized)
|
|
self.assertNotIn('"manufacturing"', serialized)
|
|
self.assertEqual(
|
|
"authored_semantic",
|
|
tree["source"]["history_kind"],
|
|
)
|
|
self.assertEqual(
|
|
"valid",
|
|
tree["validation"]["structure"]["status"],
|
|
)
|
|
nodes = {
|
|
node["id"]: node
|
|
for node in tree["feature_manager"]["nodes"]
|
|
}
|
|
self.assertIn("sw:front_plane", nodes)
|
|
self.assertIn("sketch:base_flange", nodes)
|
|
self.assertIn("feature:base_flange", nodes)
|
|
self.assertIn("feature:central_bore", nodes)
|
|
self.assertEqual(
|
|
"BossExtrude",
|
|
nodes["feature:base_flange"]["solidworks_type"],
|
|
)
|
|
self.assertEqual(
|
|
"BossExtrude",
|
|
nodes["feature:base_flange"]["definition"]["feature_type"],
|
|
)
|
|
self.assertTrue(nodes["feature:base_flange"]["dimensions"])
|
|
self.assertIn(
|
|
"feature:central_bore",
|
|
nodes["feature:base_flange"]["references"]["child_features"],
|
|
)
|
|
self.assertEqual(
|
|
"CutExtrude",
|
|
nodes["feature:central_bore"]["solidworks_type"],
|
|
)
|
|
self.assertEqual(
|
|
"ThroughAll",
|
|
nodes["feature:central_bore"]["definition"]["end_condition"],
|
|
)
|
|
self.assertEqual(
|
|
[],
|
|
nodes["feature:central_bore"]["selection_sets"]["selected_edges"],
|
|
)
|
|
self.assertFalse(nodes["feature:central_bore"]["rebuild"]["suppressed"])
|
|
self.assertIn(
|
|
"凸台-拉伸",
|
|
nodes["feature:base_flange"]["display_name"],
|
|
)
|
|
self.assertEqual(
|
|
["bore_diameter"],
|
|
[
|
|
parameter["binding_id"]
|
|
for parameter in nodes["feature:central_bore"]["parameters"]
|
|
],
|
|
)
|
|
replay_designir = FEATURE_TREE.designir_from_feature_tree(tree)
|
|
self.assertEqual("3.0", replay_designir["schema_version"])
|
|
self.assertEqual(
|
|
"feature_manager_tree_replay",
|
|
replay_designir["authoring_mode"],
|
|
)
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
original_step = Path(temporary) / "original.step"
|
|
replay_step = Path(temporary) / "replayed.step"
|
|
original = MODULE.compile_designir(migrated, original_step)
|
|
result = FEATURE_TREE.compile_feature_tree(tree, replay_step)
|
|
self.assertTrue(replay_step.is_file())
|
|
self.assertEqual("feature_manager_native", result["replay_kind"])
|
|
for key in ("solid_count", "face_count", "edge_count"):
|
|
self.assertEqual(original["facts"][key], result["facts"][key])
|
|
self.assertEqual(original["facts"]["size_mm"], result["facts"]["size_mm"])
|
|
self.assertAlmostEqual(
|
|
original["facts"]["volume_mm3"],
|
|
result["facts"]["volume_mm3"],
|
|
places=7,
|
|
)
|
|
|
|
def test_llm_flange_variant_normalizes_and_compiles(self) -> None:
|
|
payload = {
|
|
"designir_kind": "independent_parametric_cad",
|
|
"reconstruction_mode": "fully_semantic_parametric",
|
|
"authoring_mode": "semantic_feature_program",
|
|
"semantic_layer": {
|
|
"reconstruction_status": "ready",
|
|
"coordinate_system": {
|
|
"origin": [0, 0, 0],
|
|
"x_axis": [1, 0, 0],
|
|
"y_axis": [0, 1, 0],
|
|
"z_axis": [0, 0, 1],
|
|
},
|
|
"datums": [
|
|
{
|
|
"id": "datum_xy",
|
|
"plane": {"normal": [0, 0, 1], "offset": 0},
|
|
}
|
|
],
|
|
"parameters": [
|
|
{
|
|
"name": "flange_outer_diameter",
|
|
"value": 120,
|
|
"unit": "mm",
|
|
"editable": True,
|
|
},
|
|
{
|
|
"name": "flange_thickness",
|
|
"value": 16,
|
|
"unit": "mm",
|
|
"editable": True,
|
|
},
|
|
{
|
|
"name": "bore_diameter",
|
|
"value": 40,
|
|
"unit": "mm",
|
|
"editable": True,
|
|
},
|
|
{
|
|
"name": "bolt_circle_diameter",
|
|
"value": 90,
|
|
"unit": "mm",
|
|
"editable": True,
|
|
},
|
|
{
|
|
"name": "bolt_hole_diameter",
|
|
"value": 11,
|
|
"unit": "mm",
|
|
"editable": True,
|
|
},
|
|
{
|
|
"name": "bolt_count",
|
|
"value": 4,
|
|
"unit": "count",
|
|
"editable": True,
|
|
},
|
|
],
|
|
"expressions": [
|
|
{
|
|
"name": "flange_outer_radius",
|
|
"expression": "flange_outer_diameter / 2",
|
|
}
|
|
],
|
|
"sketches": [],
|
|
"constraints": [],
|
|
"features": [
|
|
{
|
|
"id": "feat_flange_body",
|
|
"operation": "extrude_circle",
|
|
"radius": {
|
|
"expression": "flange_outer_diameter / 2"
|
|
},
|
|
"height": {"parameter": "flange_thickness"},
|
|
},
|
|
{
|
|
"id": "feat_central_bore",
|
|
"operation": "through_hole",
|
|
"diameter": {"parameter": "bore_diameter"},
|
|
"reference": {"feature": "feat_flange_body"},
|
|
},
|
|
{
|
|
"id": "feat_bolt_holes",
|
|
"operation": "polar_hole_pattern",
|
|
"count": {"parameter": "bolt_count"},
|
|
"pitch_diameter": {
|
|
"parameter": "bolt_circle_diameter"
|
|
},
|
|
"hole_diameter": {
|
|
"parameter": "bolt_hole_diameter"
|
|
},
|
|
"reference": {"feature": "feat_flange_body"},
|
|
},
|
|
],
|
|
"patterns": [],
|
|
"attachments": [],
|
|
"construction_stages": [],
|
|
},
|
|
"edit_interface": {
|
|
"semantic_parameters": [
|
|
{"name": "flange_outer_diameter", "label": "法兰外径"},
|
|
{"name": "flange_thickness", "label": "法兰厚度"},
|
|
{"name": "bore_diameter", "label": "中心孔径"},
|
|
{"name": "bolt_circle_diameter", "label": "分布圆直径"},
|
|
{"name": "bolt_hole_diameter", "label": "螺栓孔径"},
|
|
{"name": "bolt_count", "label": "螺栓孔数量"},
|
|
],
|
|
"surface_parameter_groups": [],
|
|
"modification_levels": ["semantic_feature"],
|
|
"preserved_interfaces": [],
|
|
},
|
|
"validation_contract": {
|
|
"source_independence": True,
|
|
"geometry_checks": [],
|
|
"edit_checks": [],
|
|
"invariants": [],
|
|
"perturbations": [
|
|
{"parameter": "flange_outer_diameter", "delta": 10}
|
|
],
|
|
"thresholds": {},
|
|
},
|
|
"backend_hint": "build123d",
|
|
}
|
|
normalized = MODULE._normalize_tool_authored_designir(payload)
|
|
self.assertEqual("3.0", normalized["schema_version"])
|
|
self.assertEqual("mm", normalized["units"])
|
|
self.assertIsInstance(
|
|
normalized["semantic_layer"]["parameters"], dict
|
|
)
|
|
self.assertEqual(
|
|
["flange_outer_diameter", "flange_thickness", "bore_diameter",
|
|
"bolt_circle_diameter", "bolt_hole_diameter", "bolt_count"],
|
|
normalized["edit_interface"]["semantic_parameters"],
|
|
)
|
|
compiled = MODULE.validate_designir(payload)
|
|
self.assertEqual(
|
|
{"parameter": "bolt_hole_diameter"},
|
|
compiled["features"][2]["diameter"],
|
|
)
|
|
self.assertEqual("feat_flange_body", compiled["features"][1]["host"])
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
output = Path(temporary) / "flange.step"
|
|
result = MODULE.compile_designir(payload, output)
|
|
self.assertEqual(1, result["facts"]["solid_count"])
|
|
self.assertEqual([120.0, 120.0, 16.0], result["facts"]["size_mm"])
|
|
tree = FEATURE_TREE.generate_feature_tree(
|
|
normalized,
|
|
designir_path="flange.designir.json",
|
|
step_path="flange.step",
|
|
backend="build123d",
|
|
compiled=True,
|
|
validated=True,
|
|
)
|
|
serialized = json.dumps(tree)
|
|
self.assertNotIn('"designir_3"', serialized)
|
|
self.assertNotIn('"source_designir_feature"', serialized)
|
|
replay_step = Path(temporary) / "flange-replayed.step"
|
|
replay = FEATURE_TREE.compile_feature_tree(tree, replay_step)
|
|
self.assertEqual("feature_manager_native", replay["replay_kind"])
|
|
self.assertEqual(result["facts"]["size_mm"], replay["facts"]["size_mm"])
|
|
|
|
def test_teacher_dependency_is_rejected(self) -> None:
|
|
payload = self.payload()
|
|
payload["source_step"] = "teacher.step"
|
|
with self.assertRaisesRegex(MODULE.DesignIRError, "Teacher geometry"):
|
|
MODULE.validate_designir(payload)
|
|
|
|
def test_example_compiles_with_simplecadapi_and_exports_graph(self) -> None:
|
|
payload = self.payload()
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
output = Path(temporary) / "flange.step"
|
|
result = MODULE.compile_designir(
|
|
payload, output, backend="simplecadapi"
|
|
)
|
|
self.assertEqual("simplecadapi", result["backend"])
|
|
self.assertGreater(result["facts"]["volume_mm3"], 30_000)
|
|
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()
|
|
payload["family"] = "axis_aligned_cylinder"
|
|
payload["parameters"] = {
|
|
"outer_radius": {"value": 10, "unit": "mm", "editable": True},
|
|
"overall_length": {"value": 400, "unit": "mm", "editable": True},
|
|
}
|
|
payload["expressions"] = {}
|
|
payload["features"] = [
|
|
{
|
|
"id": "main_body",
|
|
"operation": "extrude_circle",
|
|
"radius": {"parameter": "outer_radius"},
|
|
"height": {"parameter": "overall_length"},
|
|
"axis": [0, -1, 0],
|
|
"center": [7, 11, 13],
|
|
"anchor": "geometric_center",
|
|
}
|
|
]
|
|
payload["edit_interface"] = {
|
|
"editable_parameters": ["outer_radius", "overall_length"],
|
|
"preserved_interfaces": ["part_center", "primary_axis"],
|
|
}
|
|
payload["validation_contract"]["perturbations"] = [
|
|
{
|
|
"parameter": "outer_radius",
|
|
"scale": 1.1,
|
|
"expected_change": "radius",
|
|
"preserve": ["overall_length", "part_center"],
|
|
},
|
|
{
|
|
"parameter": "overall_length",
|
|
"scale": 1.1,
|
|
"expected_change": "length",
|
|
"preserve": ["outer_radius", "part_center"],
|
|
},
|
|
]
|
|
shape = MODULE.build_shape(payload)
|
|
bounds = shape.bounding_box()
|
|
self.assertAlmostEqual(-189, bounds.min.Y, places=6)
|
|
self.assertAlmostEqual(211, bounds.max.Y, places=6)
|
|
self.assertAlmostEqual(7, shape.center(MODULE.CenterOf.MASS).X, places=6)
|
|
self.assertAlmostEqual(11, shape.center(MODULE.CenterOf.MASS).Y, places=6)
|
|
self.assertAlmostEqual(13, shape.center(MODULE.CenterOf.MASS).Z, places=6)
|
|
|
|
def test_embedded_mesh_is_rejected(self) -> None:
|
|
payload = self.payload()
|
|
payload["features"][0]["mesh"] = {"vertices": [[0, 0, 0]]}
|
|
with self.assertRaisesRegex(MODULE.DesignIRError, "Teacher geometry"):
|
|
MODULE.validate_designir(payload)
|
|
|
|
def test_isolated_rebuild_has_no_teacher(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
designir = root / "flange.designir.json"
|
|
designir.write_text(json.dumps(self.payload()), encoding="utf-8")
|
|
output = root / "rebuilt.step"
|
|
result = MODULE.isolated_rebuild(designir, output)
|
|
self.assertTrue(result["valid"])
|
|
self.assertFalse(result["teacher_step_available"])
|
|
self.assertTrue(output.is_file())
|
|
|
|
def test_acceptance_passes_identical_designir_rebuild(self) -> None:
|
|
payload = self.payload()
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
teacher = root / "teacher.step"
|
|
rebuilt = root / "rebuilt.step"
|
|
MODULE.compile_designir(payload, teacher)
|
|
MODULE.compile_designir(payload, rebuilt)
|
|
report = MODULE.acceptance_report(teacher, rebuilt, payload)
|
|
self.assertEqual("pass", report["verdict"])
|
|
self.assertEqual("replay_validated", report["next_state"])
|
|
self.assertEqual(
|
|
1.0, report["editability"]["parameter_perturbation_success_rate"]
|
|
)
|
|
|
|
def test_unsupported_vocabulary_is_honest_failure(self) -> None:
|
|
payload = self.payload()
|
|
payload["reconstruction_status"] = "unsupported_feature_vocabulary"
|
|
payload["missing_capabilities"] = ["multi_rail_sweep"]
|
|
with self.assertRaisesRegex(MODULE.DesignIRError, "multi_rail_sweep"):
|
|
MODULE.build_shape(payload)
|
|
|
|
def test_promotion_requires_independent_replay_validated_cases(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
reports = []
|
|
for index in range(3):
|
|
report = root / f"case-{index}.json"
|
|
report.write_text(
|
|
json.dumps(
|
|
{
|
|
"model_id": f"case-{index}",
|
|
"teacher_sha256": f"teacher-{index}",
|
|
"applied_experience_ids": [
|
|
"reconstruction.flange.primary"
|
|
],
|
|
"next_state": "replay_validated",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
reports.append(report)
|
|
result = MODULE.promotion_check(
|
|
reports, ROOT / "config" / "promotion-policy.json"
|
|
)
|
|
self.assertTrue(result["eligible"])
|
|
self.assertEqual(
|
|
["reconstruction.flange.primary"],
|
|
result["validated_experience_ids"],
|
|
)
|
|
self.assertEqual("promoted", result["next_state"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|