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
+83 -16
View File
@@ -19,21 +19,22 @@ from .operation_contracts import materialized_feature_contracts
_SELECTOR_REQUIRED = frozenset({"fillet", "chamfer"})
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_")
_PRIMARY_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through",
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
})
_HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"})
_ACTIVE_BODY_REQUIRED = frozenset({
"extrude_cut_blind", "extrude_cut_through", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer",
"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through",
"revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer",
# thread_cut 是 cut 型特征:必须在已有主体(宿主)上做布尔差,不能凭空
# 造实体;无宿主时按 active_body 前置阻止而非让 executor 在 None 上崩溃。
"thread_cut",
})
_BODY_MUTATING_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"extrude_cut_through",
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add", "thread_cut", *_HOLE_ATOMICS, "fillet", "chamfer",
})
@@ -145,27 +146,32 @@ def _schema_contract() -> dict[str, dict[str, Any]]:
def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]:
"""Return only sketches consumed by a feature with a sketch contract.
"""Return only sketches consumed by executable feature contracts.
CAD documents commonly preserve construction or abandoned sketches whose
contours are incomplete. They are semantic data, but must not make an
otherwise independent feature history ineligible for execution.
otherwise independent feature history ineligible for execution. Most
operations declare ``sketch_id``; loft declares its ordered section set
in ``params.profile_sketch_ids``.
"""
contracts = _schema_contract()
return frozenset(
str(feature["sketch_id"])
for feature in cdsl.get("features") or ()
if feature.get("sketch_id") is not None
and (contracts.get(str(feature.get("atomic_id") or "")) or {}).get("requires_sketch")
)
required: set[str] = set()
for feature in cdsl.get("features") or ():
atomic_id = str(feature.get("atomic_id") or "")
if feature.get("sketch_id") is not None and (contracts.get(atomic_id) or {}).get("requires_sketch"):
required.add(str(feature["sketch_id"]))
if atomic_id == "loft_add":
for sketch_id in (feature.get("params") or {}).get("profile_sketch_ids") or ():
required.add(str(sketch_id))
return frozenset(required)
def _has_closed_region(sketch: dict[str, Any]) -> bool:
"""Mirror the adapter's input contract without importing the geometry kernel."""
regions = sketch.get("contour_regions_mm") or []
if any(len(region.get("outer") or []) >= 2 for region in regions if isinstance(region, dict)):
if any(len(region.get("outer") or []) >= 1 for region in regions if isinstance(region, dict)):
return True
if len(sketch.get("contour_edges_mm") or []) >= 2:
if len(sketch.get("contour_edges_mm") or []) >= 1:
return True
return any(
entity.get("type") == "circle" and not entity.get("construction")
@@ -175,6 +181,18 @@ def _has_closed_region(sketch: dict[str, Any]) -> bool:
)
def _has_single_loft_region(sketch: dict[str, Any]) -> bool:
"""Whether a resolved profile maps exactly to one solid loft section."""
regions = sketch.get("contour_regions_mm") or []
if regions:
return len(regions) == 1 and not (regions[0].get("holes") or [])
circles = [
entity for entity in sketch.get("entities") or []
if isinstance(entity, dict) and entity.get("type") == "circle" and not entity.get("construction")
]
return len(circles) == 1
@dataclass(frozen=True)
class CapabilityAnalysis:
plan: tuple[FeaturePlanNode, ...]
@@ -281,6 +299,54 @@ class CapabilityAnalyzer:
"The resolved sketch contains no closed profile region",
sketch_id=node.sketch_id,
))
if node.atomic_id == "loft_add":
profile_ids = params.get("profile_sketch_ids")
if not isinstance(profile_ids, list) or len(profile_ids) < 2:
blockers.append(self._blocker(
node.feature_id, "invalid_loft_profiles",
"Loft requires at least two profile sketch ids",
))
elif len({str(sketch_id) for sketch_id in profile_ids}) != len(profile_ids):
blockers.append(self._blocker(
node.feature_id, "invalid_loft_profiles",
"Loft profile sketch ids must be distinct",
))
else:
for sketch_id in profile_ids:
sketch_id = str(sketch_id)
profile = sketches.get(sketch_id)
if profile is None:
blockers.append(self._blocker(
node.feature_id, "missing_loft_profile",
"Loft profile sketch does not exist", sketch_id=sketch_id,
))
continue
profile_type = str((profile.get("profile") or {}).get("type") or "")
required.append(f"loft_profile:{profile_type}")
resolution_error = sketch_errors.get(sketch_id)
if resolution_error:
blockers.append(self._blocker(
node.feature_id, "profile_resolution_failed",
"A loft profile sketch could not be resolved into executable regions",
sketch_id=sketch_id, reason=resolution_error,
))
elif profile_type not in self.profile_types:
blockers.append(self._blocker(
node.feature_id, "unsupported_profile",
"The current runtime cannot resolve a loft profile",
sketch_id=sketch_id, profile_type=profile_type,
))
elif not _has_closed_region(profile):
blockers.append(self._blocker(
node.feature_id, "profile_no_closed_region",
"A loft profile contains no closed region", sketch_id=sketch_id,
))
elif not _has_single_loft_region(profile):
blockers.append(self._blocker(
node.feature_id, "unsupported_loft_profile_regions",
"Loft currently requires exactly one outer profile without holes",
sketch_id=sketch_id,
))
if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES):
# #2 draft:extrudeParams.draft 在 cdsl_schema.json 中被允许,
# 但 runtime 的拉伸执行器(build123d Solid.extrude)没有锥形
@@ -318,7 +384,7 @@ class CapabilityAnalyzer:
node.feature_id, "missing_offset_distance",
"Offset-from-surface requires a non-zero captured offset distance",
))
if node.atomic_id == "extrude_add_two_sided":
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}:
reverse_condition = params.get("reverse_end_condition") or {"type": "blind"}
reverse_type = reverse_condition.get("type")
required.append(f"extent:reverse:{reverse_type}")
@@ -449,7 +515,8 @@ class CapabilityAnalyzer:
if node.atomic_id in _BODY_MUTATING_ATOMICS:
body_available = True
body_producers = {
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add",
# thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由