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
+1 -1
View File
@@ -73,7 +73,7 @@ class AuthorGuidanceTests(unittest.TestCase):
self.assertLessEqual(len(selection.content), 1_200)
self.assertIn("世界坐标", selection.content)
covered.update(section_id for section_id in selection.section_ids if section_id.startswith("op-"))
self.assertEqual(covered, {"op-extrude-add", "op-extrude-cut", "op-revolve", "op-hole", "op-reference", "op-pattern", "op-finish", "op-sphere"})
self.assertEqual(covered, {"op-extrude-add", "op-extrude-cut", "op-loft", "op-revolve", "op-hole", "op-reference", "op-pattern", "op-finish", "op-sphere", "op-primitives", "op-thread"})
def test_phase_repair_and_budget_selection_are_stable(self) -> None:
guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=3_600)
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from engine.cdsl_engine.runtime import rebuild_cdsl
def _spline_profile(sketch_id: str, z: float) -> dict:
return {
"id": sketch_id,
"workplane": {"origin_mm": [0, 0, z], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
"profile": {
"type": "analytic_contours",
"contours": [{
"role": "outer",
"closed": True,
"segments": [{
"type": "bspline",
"start": [0, 0],
"end": [0, 0],
"points": [[0, 0], [20, 0], [25, 10], [15, 18], [0, 10], [0, 0]],
}],
}],
},
}
class LoftGeometryTests(unittest.TestCase):
def test_open_bspline_segment_can_close_with_other_contour_edges(self):
cdsl = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.1.0",
"kind": "part",
"part_id": "open-bspline-contour",
"meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "profile",
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
"profile": {"type": "analytic_contours", "contours": [{
"role": "outer", "closed": True,
"segments": [
{"type": "line", "start": [0, 0], "end": [0, 10]},
{
"type": "bspline", "start": [0, 10], "end": [0, 0],
"points": [[0, 10], [4, 10], [4, 0], [0, 0]],
},
],
}]},
}]},
"features": [{
"id": "add", "atomic_id": "extrude_add_blind", "depends_on": [],
"sketch_id": "profile", "params": {"distance_mm": 5},
}],
}
with tempfile.TemporaryDirectory() as tmp:
rebuilt = rebuild_cdsl(cdsl, Path(tmp) / "open-bspline-contour.step")
self.assertGreater(rebuilt["volume_mm3"], 1.0)
def test_bspline_loft_and_mirror_replay_produce_step(self):
cdsl = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.1.0",
"kind": "part",
"part_id": "bspline-loft-mirror",
"meta": {"unit": "mm"},
"geometry": {"sketches": [_spline_profile("lower", 0), _spline_profile("upper", 20)]},
"features": [
{
"id": "mirror_plane",
"atomic_id": "reference_plane",
"depends_on": [],
"params": {"plane": {"origin_mm": [35, 0, 0], "x_dir": [0, 1, 0], "normal": [1, 0, 0]}},
"execution_status": "supported",
},
{
"id": "loft",
"atomic_id": "loft_add",
"depends_on": [],
"params": {"profile_sketch_ids": ["lower", "upper"]},
"execution_status": "supported",
},
{
"id": "mirror",
"atomic_id": "pattern_mirror",
"depends_on": ["mirror_plane", "loft"],
"params": {
"source_feature_ids": ["loft"],
"mirror_plane": {
"kind": "plane",
"owner_feature_id": "mirror_plane",
"stable_id": "mirror-plane",
"source": "runtime_snapshot",
"confidence": 1.0,
},
},
"selectors": [{
"kind": "plane",
"owner_feature_id": "mirror_plane",
"stable_id": "mirror-plane",
"source": "runtime_snapshot",
"confidence": 1.0,
}],
"execution_status": "supported",
},
],
}
with tempfile.TemporaryDirectory() as tmp:
rebuilt = rebuild_cdsl(cdsl, Path(tmp) / "loft.step")
self.assertGreater(rebuilt["volume_mm3"], 1.0)
self.assertEqual(rebuilt["solid_count"], 2)
self.assertEqual([item["feature_id"] for item in rebuilt["feature_results"]], ["mirror_plane", "loft", "mirror.m.loft", "mirror"])
if __name__ == "__main__":
unittest.main()
@@ -316,6 +316,55 @@ class PatternTransformContractTests(unittest.TestCase):
self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 + 72 + 24, places=5)
def test_circular_pattern_rotates_analytic_arc_normals_with_the_sketch(self) -> None:
"""倾斜圆草图的环形阵列必须保持每条圆弧处于旋转后的工作平面。
``analytic_contours`` 在运行时会预先展开为世界坐标的圆弧边。环形阵列
不能只旋转边端点和圆心:Build123d 依据 edge.normal 计算三点圆弧中点,
法向遗留在源草图平面会让中点偏离 wire 平面,最终报
``Cannot build face(s): wires not planar``。该 fixture 使用绕 Z 轴的
垂直圆草图,保证每个实例都实际变换该法向。
"""
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"part_id": "circular-arc-normal", "meta": {"unit": "mm"},
"geometry": {"sketches": [
{
"id": "base", "workplane": _workplane(origin=[0, 0, 0], normal=[0, 0, 1]),
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
},
{
"id": "boss", "workplane": _workplane(origin=[0, -10, 0], normal=[0, 1, 0]),
"profile": {"type": "analytic_contours", "contours": [{
"role": "outer", "closed": True,
"segments": [{"type": "circle", "center": [0, -5], "radius_mm": 1}],
}]},
},
]},
"features": [
{
"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
"sketch_id": "base", "params": {"distance_mm": 10},
},
{
"id": "boss_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
"sketch_id": "boss", "params": {"distance_mm": 2},
},
{
"id": "repeat", "atomic_id": "pattern_circular", "depends_on": ["boss_add"],
"params": {
"source_feature_ids": ["boss_add"],
"axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]},
"pattern_count": 4, "sweep_angle_deg": 360,
},
},
],
}
with tempfile.TemporaryDirectory() as directory:
rebuilt = rebuild_cdsl(cdsl, Path(directory) / "circular-arc-normal.step")
self.assertGreater(rebuilt["volume_mm3"], math.pi * 1000)
self.assertEqual(rebuilt["solid_count"], 1)
if __name__ == "__main__":
unittest.main()
@@ -918,6 +918,25 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
analysis = analyze_cdsl(cdsl)
self.assertIn("missing_parameter", [item.code for item in analysis.feature_results[0].blockers])
def test_two_sided_cut_uses_independent_forward_and_reverse_distances(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = self._base_block()
cdsl["geometry"]["sketches"].append({
"id": "cut", "workplane": {**_workplane(), "origin_mm": [0, 0, 5]},
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 1},
})
cdsl["features"].append({
"id": "cut_1", "atomic_id": "extrude_cut_two_sided", "depends_on": ["base_add"],
"params": {
"distance_mm": 3, "reverse_distance_mm": 5,
"end_condition": {"type": "blind"}, "reverse_end_condition": {"type": "blind"},
}, "sketch_id": "cut",
})
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "two-sided-cut.step")
self.assertAlmostEqual(result["volume_mm3"], 1000.0 - 8.0 * 3.141592653589793, places=5)
def test_revolve_can_resolve_an_owner_qualified_reference_axis(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
@@ -1038,6 +1057,22 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
result = rebuild_cdsl(cdsl, root / f"{name}.step")
self.assertAlmostEqual(result["volume_mm3"], 1000 - expected_depth * 3.141592653589793, places=5)
def test_through_next_trims_a_partially_overlapping_profile_to_the_next_body_face(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
base = self._base_block()
base["geometry"]["sketches"].append({
"id": "partial", "workplane": {"origin_mm": [0, 0, 12], "x_dir": [1, 0, 0], "normal": [0, 0, -1]},
"profile": {"type": "polygon", "vertices": [[3, -3], [9, -3], [9, 3], [3, 3]]},
})
base["features"].append({
"id": "partial_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
"sketch_id": "partial", "params": {"distance_mm": 0, "end_condition": {"type": "through_next"}},
})
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(base, Path(directory) / "partial-through-next.step")
self.assertAlmostEqual(result["volume_mm3"], 1000 + 24, places=5)
def test_up_to_body_extent_uses_a_uniquely_resolved_body_record(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl