feat(cadfs): 扩展重建引擎能力并固化代表性模型回归
- 扩展 CDSL engine 的 shell、sweep、loft、reference plane、pattern 等运行时能力, 支持新的实体结果模式、双向拉伸、曲线扫掠、镜像/圆周阵列及相关 selector 解析。 - 完善 Build123d 适配层的拓扑快照、Compound/ShapeList 兼容处理和旋转曲面识别, 兼容 Python 3.12 / 当前 Build123d 缺少 axis_of_rotation 的合法曲面场景。 - 扩展 CDSL schema、profile schema、capability analysis、semantic validation 和 sketch solver,使新增建模操作能够被校验、执行并保留可诊断的部分结果。 - 完善 CADFS FeatureScript lowering: 支持 shell、sweep、surface/实体 loft、圆周阵列副本、镜像副本、删除阵列实例、 新 body 操作、更多拉伸终止条件和 reference plane 变体。 - 补齐椭圆、B-spline、环形区域、imprint、SWEPT_FACE、CAP_FACE、OFFSET_FACE 等 草图和拓扑引用的转换逻辑,改善后续特征的工作平面、轴线和 profile 定位精度。 - 改进 selector binding:支持 pattern 前缀复合 B-rep 快照、交集顶点引用、 多面 match_mode=all、圆柱轴线/半径和面积下限等稳定匹配条件。 - 修复 MID_PLANE 法向统一后交线方向未同步的问题,恢复 00287955 基准面的正确位置; 修复 00542223 sweep 路径反转后的切线契约和 00423838 的拓扑面数不稳定测试假设。 - 修正 CADFS 比较模块 import 路径,补充重建报告、批量重建脚本、目标文档和 README。 - 新增并扩展 engine、lowering、parser、selector binding、reports、integration 和 Onshape pipeline 回归测试,覆盖代表性 CADFS 特征链及运行时兼容性。
This commit is contained in:
@@ -9,23 +9,40 @@ from typing import Any
|
||||
|
||||
def _score(expected: dict[str, Any], actual: dict[str, Any]) -> float | None:
|
||||
scores: list[float] = []
|
||||
for key in ("center_mm", "start_mm", "end_mm", "normal", "axis_direction"):
|
||||
reversed_plane_normal = False
|
||||
for key in ("center_mm", "start_mm", "end_mm", "normal", "axis_origin_mm", "axis_direction"):
|
||||
if key in expected:
|
||||
left, right = expected[key], actual.get(key)
|
||||
# plane_offset_mm 与平面方程绑定,必须使用记录平面方程时采用的
|
||||
# plane_normal。face 的局部采样 normal 在 OCC 中可能与其相反。
|
||||
use_plane_normal = key == "normal" and "plane_offset_mm" in expected and actual.get("plane_normal") is not None
|
||||
right = actual.get("plane_normal") if use_plane_normal else actual.get(key)
|
||||
left = expected[key]
|
||||
if not isinstance(right, (list, tuple)) or len(left) != len(right): return None
|
||||
delta = math.sqrt(sum((float(a) - float(b)) ** 2 for a, b in zip(left, right)))
|
||||
if key == "normal":
|
||||
if key in {"normal", "axis_direction"}:
|
||||
# A source CAP_FACE identifies a geometric plane, not the OCC
|
||||
# orientation of the resulting face. The two kernels may
|
||||
# report the same cap with inverse normals, especially for a
|
||||
# start cap. Keep axis direction orientation-sensitive.
|
||||
delta = min(delta, math.sqrt(sum((float(a) + float(b)) ** 2 for a, b in zip(left, right))))
|
||||
# start cap. A rotational-face axis is likewise a geometric
|
||||
# line, whose direction may be reversed by OCC.
|
||||
reversed_delta = math.sqrt(sum((float(a) + float(b)) ** 2 for a, b in zip(left, right)))
|
||||
if key == "normal" and use_plane_normal and reversed_delta < delta: reversed_plane_normal = True
|
||||
delta = min(delta, reversed_delta)
|
||||
scores.append(max(0.0, 1.0 - delta / 0.05))
|
||||
for key in ("radius_mm", "plane_offset_mm"):
|
||||
if key in expected:
|
||||
try: delta = abs(float(expected[key]) - float(actual[key]))
|
||||
try:
|
||||
value = float(actual[key])
|
||||
# 平面偏移是 normal · point。面法向反向时,同一几何平面的
|
||||
# 有符号偏移也必须同步反号,不能只放宽 normal 的比较。
|
||||
if key == "plane_offset_mm" and reversed_plane_normal: value = -value
|
||||
delta = abs(float(expected[key]) - value)
|
||||
except Exception: return None
|
||||
scores.append(max(0.0, 1.0 - delta / 0.05))
|
||||
if "minimum_area_mm2" in expected:
|
||||
try:
|
||||
if float(actual["area_mm2"]) + 1e-6 < float(expected["minimum_area_mm2"]): return None
|
||||
except Exception: return None
|
||||
if "bbox_mm" in expected:
|
||||
actual_box = actual.get("bbox_mm")
|
||||
if not isinstance(actual_box, (list, tuple)) or len(actual_box) != 6: return None
|
||||
@@ -69,30 +86,70 @@ def _circle_records(expected: dict[str, Any], records: list[dict[str, Any]]) ->
|
||||
return matches
|
||||
|
||||
|
||||
def _binding_targets(selector: dict[str, Any]):
|
||||
components = selector.get("intersection_of")
|
||||
if isinstance(components, list):
|
||||
for component in components:
|
||||
if isinstance(component, dict): yield from _binding_targets(component)
|
||||
return
|
||||
yield selector
|
||||
|
||||
|
||||
def _bound_selector(placeholder: dict[str, Any], records: list[dict[str, Any]]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
bound = []
|
||||
for record in records:
|
||||
owners = record.get("owner_feature_ids") or [record.get("feature_id")]
|
||||
selector = {"kind": placeholder["kind"], "owner_feature_id": str(owners[0]), "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "runtime_snapshot", "confidence": 1.0, "geometry": record.get("geometry") or {}}
|
||||
if placeholder.get("binding_feature_id") is not None: selector["binding_feature_id"] = placeholder["binding_feature_id"]
|
||||
bound.append(selector)
|
||||
if placeholder.get("match_mode") == "all":
|
||||
selector = dict(placeholder); selector["matched_selectors"] = bound
|
||||
return selector, bound
|
||||
return bound[0], bound
|
||||
|
||||
|
||||
def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
"""Rebuild every selector-bearing prefix and bind against its active body."""
|
||||
from engine.cdsl_engine.runtime import rebuild_cdsl
|
||||
bound = deepcopy(cdsl); evidence = []
|
||||
with tempfile.TemporaryDirectory(prefix="cadfs-bind-") as temporary:
|
||||
for index, feature in enumerate(bound.get("features") or []):
|
||||
placeholders = list(feature.get("selectors") or [])
|
||||
if not placeholders: continue
|
||||
prefix = deepcopy(bound); prefix["features"] = bound["features"][:index]
|
||||
if not prefix["features"]: raise ValueError(f"{feature['id']}: selector has no executable prefix")
|
||||
report = rebuild_cdsl(prefix, Path(temporary) / f"prefix-{index}.step", strict=True)
|
||||
body_id = next((item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")), None)
|
||||
records = [
|
||||
item for item in report.get("topology_records") or []
|
||||
# Reference planes and axes are session context, not body
|
||||
# topology. They must remain available while binding a mirror
|
||||
# or extent selector against a body-bearing prefix.
|
||||
if item.get("kind") in {"plane", "axis"}
|
||||
or not body_id
|
||||
or item.get("body_id") == body_id
|
||||
or str(item.get("body_id") or "").startswith(f"{body_id}:")
|
||||
]
|
||||
roots = list(feature.get("selectors") or [])
|
||||
for name in ("end_condition", "reverse_end_condition"):
|
||||
condition = (feature.get("params") or {}).get(name)
|
||||
reference = condition.get("reference") if isinstance(condition, dict) else None
|
||||
if isinstance(reference, dict): roots.append(reference)
|
||||
targets = [target for root in roots for target in _binding_targets(root)]
|
||||
if not targets: continue
|
||||
prefix_cache: dict[int, tuple[list[dict[str, Any]], str | None]] = {}
|
||||
|
||||
def prefix_records(binding_feature_id: str | None, owner_feature_id: str | None) -> list[dict[str, Any]]:
|
||||
prefix_count = index
|
||||
if binding_feature_id is not None:
|
||||
binding_index = next((item_index for item_index, item in enumerate(bound["features"][:index]) if item["id"] == binding_feature_id), None)
|
||||
if binding_index is None: raise ValueError(f"{feature['id']}: selector binding feature is missing or forward")
|
||||
prefix_count = binding_index + 1
|
||||
if prefix_count not in prefix_cache:
|
||||
prefix = deepcopy(bound); prefix["features"] = bound["features"][:prefix_count]
|
||||
if not prefix["features"]: raise ValueError(f"{feature['id']}: selector has no executable prefix")
|
||||
report = rebuild_cdsl(prefix, Path(temporary) / f"prefix-{index}-{prefix_count}.step", strict=True)
|
||||
body_id = next((item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")), None)
|
||||
prefix_cache[prefix_count] = (list(report.get("topology_records") or []), body_id)
|
||||
all_records, body_id = prefix_cache[prefix_count]
|
||||
return [
|
||||
item for item in all_records
|
||||
# Reference planes and axes are session context, not body
|
||||
# topology. They must remain available while binding a mirror
|
||||
# or extent selector against a body-bearing prefix.
|
||||
if item.get("kind") in {"plane", "axis"}
|
||||
or not body_id
|
||||
or item.get("body_id") == body_id
|
||||
or str(item.get("body_id") or "").startswith(f"{body_id}:")
|
||||
]
|
||||
|
||||
resolved = []
|
||||
for placeholder in placeholders:
|
||||
for placeholder in targets:
|
||||
records = prefix_records(placeholder.get("binding_feature_id"), placeholder.get("owner_feature_id"))
|
||||
geometry = placeholder.get("geometry") or {}
|
||||
candidates = _circle_records(geometry, records) if geometry.get("source_circle_radius_mm") else []
|
||||
if not candidates:
|
||||
@@ -112,18 +169,19 @@ def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list
|
||||
scored = [(score, record) for record in pool if (score := _score(geometry, record.get("geometry") or {})) is not None and score >= 0.8]
|
||||
scored.sort(key=lambda value: (-value[0], str(value[1].get("record_id"))))
|
||||
if scored:
|
||||
if len(scored) > 1 and abs(scored[0][0] - scored[1][0]) <= 1e-9:
|
||||
if placeholder.get("match_mode") != "all" and len(scored) > 1 and abs(scored[0][0] - scored[1][0]) <= 1e-9:
|
||||
raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild")
|
||||
candidates = [scored[0][1]]
|
||||
candidates = [item[1] for item in scored] if placeholder.get("match_mode") == "all" else [scored[0][1]]
|
||||
if not candidates: raise ValueError(f"{feature['id']}: selector_not_found after prefix rebuild")
|
||||
for record in candidates:
|
||||
owners = record.get("owner_feature_ids") or [record.get("feature_id")]
|
||||
resolved.append({"kind": placeholder["kind"], "owner_feature_id": str(owners[0]), "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "runtime_snapshot", "confidence": 1.0, "geometry": record.get("geometry") or {}})
|
||||
unique = {selector["stable_id"]: selector for selector in resolved}; feature["selectors"] = list(unique.values())
|
||||
selector, bound_selectors = _bound_selector(placeholder, candidates)
|
||||
placeholder.clear(); placeholder.update(selector)
|
||||
resolved.extend(bound_selectors)
|
||||
feature_selectors = feature.get("selectors") or []
|
||||
unique = {selector["stable_id"]: selector for selector in feature_selectors}; feature["selectors"] = list(unique.values())
|
||||
if feature.get("atomic_id") == "pattern_mirror":
|
||||
planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"]
|
||||
if len(planes) != 1:
|
||||
raise ValueError(f"{feature['id']}: mirror plane binding is not unique")
|
||||
feature.setdefault("params", {})["mirror_plane"] = planes[0]
|
||||
evidence.append({"feature_id": feature["id"], "prefix_feature_count": index, "selectors": feature["selectors"]})
|
||||
evidence.append({"feature_id": feature["id"], "prefix_feature_count": index, "selectors": feature["selectors"], "resolved": resolved})
|
||||
return bound, evidence
|
||||
|
||||
Reference in New Issue
Block a user