feat(cadfs): 补齐核心建模能力并建立代表性回归

- 新增 loft、双向切除、through-all/up-to-next 等 CADFS lowering 与 engine 支持
- 支持多种 reference plane、B-spline profile 和 circular pattern replay
- 保留 transform 历史,并烘焙安全的单源平移/旋转变换
- 改进 selector 绑定、拓扑快照和 pattern 变换处理
- 建立 17 个代表样本的转换、重建与比较回归工具链
- 补充 schema、author guidance、运行时和几何回归测试
This commit is contained in:
2026-09-07 18:21:07 +08:00
parent 4ad890b7b4
commit 038d38ed98
31 changed files with 2498 additions and 85 deletions
+131 -5
View File
@@ -7,7 +7,7 @@ from cadfs_to_cdsl.lowering import lower_model
from cadfs_to_cdsl.pipeline import convert_one
from cadfs_to_cdsl.dataset import Sample
from cadfs_to_cdsl.dataset import scan_dataset
from cadfs_to_cdsl.tests.test_parser import SOURCE
from cadfs_to_cdsl.tests.test_parser import SOURCE, TRANSFORM_SOURCE
class LoweringTests(unittest.TestCase):
@@ -30,12 +30,61 @@ class LoweringTests(unittest.TestCase):
self.assertIsNone(result.cdsl)
self.assertEqual(result.diagnostics[0]["code"], "unsupported_operation")
def test_symmetric_cut_is_not_disguised_as_blind_cut(self):
def test_direct_translation_transform_is_baked_into_the_source_feature(self):
result = lower_model(parse_featurescript(TRANSFORM_SOURCE, "transform"), {})
self.assertEqual(result.status, "converted_complete")
self.assertEqual([item["id"] for item in result.cdsl["features"]], ["f_F1"])
self.assertEqual(result.history[-1]["feature_id"], "F2")
self.assertFalse(result.diagnostics)
self.assertEqual(result.cdsl["geometry"]["sketches"][0]["workplane"]["origin_mm"], [10.0, 0.0, 0.0])
def test_copy_transform_remains_an_explicit_engine_capability_gap(self):
source = TRANSFORM_SOURCE.replace('"makeCopy":false', '"makeCopy":true')
result = lower_model(parse_featurescript(source, "transform-copy"), {})
self.assertEqual(result.status, "converted_partial")
self.assertEqual(result.diagnostics[-1]["code"], "unsupported_engine_capability")
self.assertEqual(result.diagnostics[-1]["capability"], "transform")
def test_symmetric_cut_lowers_to_two_sided_cut(self):
source = SOURCE.replace('"depth":120 * mm', '"operationType":NewBodyOperationType.REMOVE, "depth":120 * mm, "symmetric":true')
result = lower_model(parse_featurescript(source, "symmetric-cut"), {})
self.assertIsNone(result.cdsl)
self.assertEqual(result.diagnostics[0]["code"], "unsupported_engine_capability")
self.assertEqual(result.diagnostics[0]["capability"], "extrude_cut_two_sided")
self.assertEqual(result.status, "converted_complete")
feature = result.cdsl["features"][0]
self.assertEqual(feature["atomic_id"], "extrude_cut_two_sided")
self.assertEqual(feature["params"]["distance_mm"], 60.0)
self.assertEqual(feature["params"]["reverse_distance_mm"], 60.0)
def test_through_all_extrudes_reuse_the_engine_extent_contract(self):
for operation_type, expected_atomic in [
("NewBodyOperationType.NEW", "extrude_add_blind"),
("NewBodyOperationType.REMOVE", "extrude_cut_blind"),
]:
with self.subTest(operation_type=operation_type):
source = SOURCE.replace(
'"depth":120 * mm',
f'"operationType":{operation_type}, "depth":120 * mm, "endBound":BoundingType.THROUGH_ALL',
)
result = lower_model(parse_featurescript(source, f"through-all-{operation_type}"), {})
self.assertEqual(result.status, "converted_complete")
feature = result.cdsl["features"][0]
self.assertEqual(feature["atomic_id"], expected_atomic)
self.assertEqual(feature["params"]["end_condition"]["type"], "through_all")
def test_up_to_next_extrudes_reuse_the_engine_extent_contract(self):
for operation_type, expected_atomic in [
("NewBodyOperationType.NEW", "extrude_add_blind"),
("NewBodyOperationType.REMOVE", "extrude_cut_blind"),
]:
with self.subTest(operation_type=operation_type):
source = SOURCE.replace(
'"depth":120 * mm',
f'"operationType":{operation_type}, "depth":120 * mm, "endBound":BoundingType.UP_TO_NEXT',
)
result = lower_model(parse_featurescript(source, f"up-to-next-{operation_type}"), {})
self.assertEqual(result.status, "converted_complete")
feature = result.cdsl["features"][0]
self.assertEqual(feature["atomic_id"], expected_atomic)
self.assertEqual(feature["params"]["end_condition"]["type"], "through_next")
def test_open_nonconstruction_geometry_is_not_silently_dropped(self):
source = SOURCE.replace('skSolve(sketch);', 'skLineSegment(sketch, "open", {"start":v(0, 0) * mm, "end":v(20, 0) * mm}); skSolve(sketch);', 1)
@@ -53,6 +102,83 @@ class LoweringTests(unittest.TestCase):
self.assertEqual(gap["capability"], "revolve_surface")
self.assertIsNone(result.cdsl)
def test_fit_spline_loft_lowers_to_executable_loft_add(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0061/00612529.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00612529"), {})
self.assertIsNotNone(result.cdsl)
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F3"]["atomic_id"], "loft_add")
self.assertEqual(features["f_F3"]["params"]["profile_sketch_ids"], ["sketch_F2", "sketch_F0"])
self.assertEqual(features["f_F4"]["atomic_id"], "pattern_mirror")
self.assertEqual(features["f_F4"]["params"]["source_feature_ids"], ["f_F3"])
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
self.assertEqual(sketches["sketch_F0"]["profile"]["contours"][0]["segments"][0]["type"], "bspline")
self.assertNotIn("loft", {item.get("operation") for item in result.diagnostics})
def test_fit_spline_loft_candidate_rebuilds(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0061/00612529.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00612529"), {})
with tempfile.TemporaryDirectory() as tmp:
from cadfs_to_cdsl.rebuild import rebuild_candidate
rebuilt = rebuild_candidate(result.cdsl, Path(tmp) / "rebuilt.step")
self.assertEqual(rebuilt["status"], "rebuilt")
self.assertGreater(rebuilt["result"]["volume_mm3"], 0)
def test_direct_translation_transform_updates_the_source_revolve(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0011/00111611.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00111611"), {})
self.assertNotIn("F10", {item.get("feature_id") for item in result.diagnostics})
features = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
self.assertEqual(sketches[features["f_F9"]["sketch_id"]]["workplane"]["origin_mm"], [77.16, -11.67, -63.0])
for actual, expected in zip(features["f_F9"]["params"]["axis"]["origin_mm"], [0.13, -11.67, 56.11]):
self.assertAlmostEqual(actual, expected)
def test_circular_pattern_lowers_to_existing_engine_contract(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0042/00423838.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00423838"), {})
self.assertIsNotNone(result.cdsl)
features = {item["id"]: item for item in result.cdsl["features"]}
pattern = features["f_F4"]
self.assertEqual(pattern["atomic_id"], "pattern_circular")
self.assertEqual(pattern["params"]["source_feature_ids"], ["f_F1"])
self.assertEqual(pattern["params"]["pattern_count"], 6)
self.assertEqual(pattern["params"]["sweep_angle_deg"], 360.0)
self.assertEqual(pattern["params"]["axis"]["direction"], [0.0, 0.0, -1.0])
self.assertNotIn("F2", {item.get("feature_id") for item in result.diagnostics})
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
self.assertNotEqual(sketches["sketch_F0"]["workplane"]["normal"], [0.0, 0.0, 1.0])
self.assertNotIn("circularPattern", {item.get("operation") for item in result.diagnostics})
def test_reference_plane_variants_lower_to_explicit_frames(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
cases = {
"00159804": {"F3", "F5"},
"00192744": {"F3"},
"00212904": {"F3"},
"00542223": {"F3"},
}
for sample_id, expected in cases.items():
with self.subTest(sample_id=sample_id):
feature = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), sample_id), {})
features = {item["name"]: item for item in result.cdsl["features"]}
for feature_id in expected:
plane = features[feature_id]
self.assertEqual(plane["atomic_id"], "reference_plane")
self.assertEqual(set(plane["params"]["plane"]), {"origin_mm", "x_dir", "normal"})
diagnostics = [item for item in result.diagnostics if item.get("operation") == "cPlane"]
self.assertFalse(diagnostics)
def test_feature_face_profile_is_not_reused_as_original_sketch(self):
source = SOURCE.replace('qSketchRegion(id + "F0", true)', 'makeQuery(id+"F1.opExtrude","CAP_FACE",FACE,{"isStart":false})')
result = lower_model(parse_featurescript(source, "face-profile"), {})
+14
View File
@@ -19,6 +19,14 @@ export const f = defineFeature(function(context, id, definition) {
});
'''
TRANSFORM_SOURCE = SOURCE.replace(
'\n});\n',
'''
transform(context, id + "F2", {"entities":qCreatedBy(id + "F1", BODY), "transformType":TransformType.TRANSLATION_3D, "dx":10 * mm, "dy":0 * mm, "dz":0 * mm, "makeCopy":false});
});
''',
)
class ParserTests(unittest.TestCase):
def test_lexer_ignores_comments_and_preserves_lines(self):
@@ -32,6 +40,12 @@ class ParserTests(unittest.TestCase):
self.assertEqual(model.sketches[0].entities[0].operation, "skCircle")
self.assertIsInstance(model.features[0].params["entities"], Call)
def test_transform_is_preserved_as_a_feature(self):
model = parse_featurescript(TRANSFORM_SOURCE, "transform")
self.assertEqual([step.feature_id for step in model.steps], ["F0", "F1", "F2"])
self.assertEqual(model.features[-1].operation, "transform")
self.assertEqual(model.features[-1].params["transformType"], "TransformType.TRANSLATION_3D")
def test_query_parser(self):
query = Call("makeQuery", [Call("__binary__", ["id", "+", "F1.opExtrude"]), "CAP_EDGE", "EDGE", {"isStart": False, "x": Call("sQuery", [Call("__binary__", ["id", "+", "F0.wireOp"]), "EDGE", "E0"])}])
value = parse_query(query)
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from cadfs_to_cdsl.regression import build_regression_manifest, regression_sample_ids
class RegressionSelectionTests(unittest.TestCase):
def _sample(
self,
root: Path,
sample_id: str,
*,
operation: str,
entity: str | None = None,
atomic: str | None = None,
capability: str | None = None,
rebuilt: bool = False,
) -> None:
directory = root / "samples" / sample_id
directory.mkdir(parents=True)
history = [{"operation": operation}]
if entity:
history[0]["entities"] = [{"operation": entity}]
(directory / "history.json").write_text(json.dumps(history), encoding="utf-8")
if atomic:
(directory / "candidate.cdsl.json").write_text(json.dumps({"features": [{"atomic_id": atomic}]}), encoding="utf-8")
diagnostics = [] if not capability else [{"code": "unsupported_engine_capability", "capability": capability}]
(directory / "diagnostics.json").write_text(json.dumps(diagnostics), encoding="utf-8")
status = {"status": "rebuilt_approximate" if rebuilt else "converted_partial"}
if rebuilt:
status["rebuild_status"] = "rebuilt"
(directory / "status.json").write_text(json.dumps(status), encoding="utf-8")
def test_manifest_covers_every_observed_signal_and_keeps_engine_pool_buildable(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary)
self._sample(output, "00000001", operation="extrude", entity="skLineSegment", atomic="extrude_add_blind", rebuilt=True)
self._sample(output, "00000002", operation="loft", entity="skFitSpline", capability="loft")
self._sample(output, "00000003", operation="revolve", entity="skCircle", atomic="revolve_add", rebuilt=True)
manifest = build_regression_manifest(output)
self.assertEqual(manifest["selected_ids"], ["00000001", "00000002", "00000003"])
self.assertEqual(set(manifest["coverage"]), {
"engine_atomic:extrude_add_blind", "engine_atomic:revolve_add",
"sketch_entity:skCircle", "sketch_entity:skFitSpline", "sketch_entity:skLineSegment",
"source_operation:extrude", "source_operation:loft", "source_operation:revolve",
"unsupported_capability:loft",
})
self.assertEqual(regression_sample_ids_from_manifest(manifest, "engine"), ["00000001", "00000003"])
self.assertEqual(manifest["engine_baseline_atomic_ids"], ["extrude_add_blind", "revolve_add"])
self.assertEqual(manifest["engine_diagnostic_only_atomic_ids"], [])
def test_manifest_reader_rejects_unknown_tier(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self._sample(root, "00000001", operation="extrude", atomic="extrude_add_blind", rebuilt=True)
manifest_path = root / "manifest.json"
manifest_path.write_text(json.dumps(build_regression_manifest(root)), encoding="utf-8")
self.assertEqual(regression_sample_ids(manifest_path, "engine"), ["00000001"])
with self.assertRaisesRegex(ValueError, "Unknown regression tier"):
regression_sample_ids(manifest_path, "not-a-tier")
def regression_sample_ids_from_manifest(manifest: dict, tier: str) -> list[str]:
return sorted(
entry["sample_id"] for entry in manifest["entries"]
if tier == "all" or tier in entry["tiers"]
)
@@ -0,0 +1,24 @@
from __future__ import annotations
import unittest
from cadfs_to_cdsl.selector_binding import _score
class SelectorBindingTests(unittest.TestCase):
def test_face_normal_match_is_orientation_independent(self) -> None:
score = _score(
{"normal": [0.0, 0.0, 1.0], "plane_offset_mm": 12.0},
{"normal": [0.0, 0.0, -1.0], "plane_offset_mm": 12.0},
)
self.assertEqual(score, 1.0)
def test_axis_direction_remains_orientation_sensitive(self) -> None:
score = _score(
{"axis_direction": [0.0, 0.0, 1.0]},
{"axis_direction": [0.0, 0.0, -1.0]},
)
self.assertEqual(score, 0.0)
def test_empty_snapshot_score_is_not_treated_as_a_match(self) -> None:
self.assertEqual(_score({}, {"normal": [0.0, 0.0, 1.0]}), 0.0)