038d38ed98
- 新增 loft、双向切除、through-all/up-to-next 等 CADFS lowering 与 engine 支持 - 支持多种 reference plane、B-spline profile 和 circular pattern replay - 保留 transform 历史,并烘焙安全的单源平移/旋转变换 - 改进 selector 绑定、拓扑快照和 pattern 变换处理 - 建立 17 个代表样本的转换、重建与比较回归工具链 - 补充 schema、author guidance、运行时和几何回归测试
130 lines
8.3 KiB
Python
130 lines
8.3 KiB
Python
from __future__ import annotations
|
|
|
|
import math
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
import tempfile
|
|
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"):
|
|
if key in expected:
|
|
left, right = expected[key], actual.get(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":
|
|
# 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))))
|
|
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]))
|
|
except Exception: return None
|
|
scores.append(max(0.0, 1.0 - delta / 0.05))
|
|
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
|
|
delta = max(abs(float(a) - float(b)) for a, b in zip(expected["bbox_mm"], actual_box))
|
|
scores.append(max(0.0, 1.0 - delta / 0.05))
|
|
return sum(scores) / len(scores) if scores else 0.0
|
|
|
|
|
|
def bind_selector(kind: str, owner_feature_id: str, geometry: dict[str, Any], records: list[dict[str, Any]], *, minimum_score: float = 0.8) -> dict[str, Any]:
|
|
candidates = []
|
|
for record in records:
|
|
owners = record.get("owner_feature_ids") or [record.get("feature_id")]
|
|
if record.get("kind") != kind or owner_feature_id not in owners: continue
|
|
score = _score(geometry, record.get("geometry") or {})
|
|
if score is not None and score >= minimum_score: candidates.append((score, record))
|
|
candidates.sort(key=lambda item: (-item[0], str(item[1].get("record_id"))))
|
|
if not candidates: raise ValueError("selector_not_found")
|
|
if len(candidates) > 1 and abs(candidates[0][0] - candidates[1][0]) <= 1e-9: raise ValueError("selector_ambiguous")
|
|
score, record = candidates[0]
|
|
return {"kind": kind, "owner_feature_id": owner_feature_id, "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "runtime_snapshot", "confidence": round(score, 6), "geometry": record.get("geometry") or geometry}
|
|
|
|
|
|
def _dot(left: list[float], right: list[float]) -> float: return sum(float(a)*float(b) for a, b in zip(left, right))
|
|
|
|
|
|
def _circle_records(expected: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
center = expected.get("source_circle_center_mm"); normal = expected.get("source_plane_normal"); radius = float(expected.get("source_circle_radius_mm") or 0)
|
|
if not isinstance(center, list) or not isinstance(normal, list) or radius <= 0: return []
|
|
plane_offset = _dot(center, normal); matches = []
|
|
for record in records:
|
|
geometry = record.get("geometry") or {}
|
|
if record.get("kind") != "edge" or geometry.get("curve_type") != "circle": continue
|
|
points = [geometry.get("start_mm"), geometry.get("end_mm")]
|
|
if not all(isinstance(point, list) and len(point) == 3 for point in points): continue
|
|
if any(abs(_dot(point, normal) - plane_offset) > 0.05 for point in points): continue
|
|
radial = []
|
|
for point in points:
|
|
delta = [float(point[i])-float(center[i]) for i in range(3)]; axial = _dot(delta, normal)
|
|
radial.append(math.sqrt(max(0.0, sum(value*value for value in delta)-axial*axial)))
|
|
if all(abs(value-radius) <= max(0.05, radius*1e-4) for value in radial): matches.append(record)
|
|
return matches
|
|
|
|
|
|
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}:")
|
|
]
|
|
resolved = []
|
|
for placeholder in placeholders:
|
|
geometry = placeholder.get("geometry") or {}
|
|
candidates = _circle_records(geometry, records) if geometry.get("source_circle_radius_mm") else []
|
|
if not candidates:
|
|
same_kind = [record for record in records if record.get("kind") == placeholder.get("kind")]
|
|
owner = placeholder.get("owner_feature_id")
|
|
owner_matches = [record for record in same_kind if owner in (record.get("owner_feature_ids") or [record.get("feature_id")])]
|
|
pool = owner_matches or same_kind
|
|
if not geometry:
|
|
# Context selectors (notably a generated mirror plane)
|
|
# may have no geometric snapshot. Their owner-qualified
|
|
# singleton identity is sufficient and must not be scored
|
|
# as a zero-information geometric match.
|
|
if len(pool) != 1:
|
|
raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild")
|
|
candidates = [pool[0]]
|
|
else:
|
|
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:
|
|
raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild")
|
|
candidates = [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())
|
|
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"]})
|
|
return bound, evidence
|