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:
2026-09-08 11:47:10 +08:00
parent e7dc521ece
commit 738934416e
28 changed files with 5532 additions and 339 deletions
+71 -19
View File
@@ -16,21 +16,20 @@ def _point(value: Any) -> tuple[float, float, float]:
def _surface_points(shape: Any, tolerance_mm: float) -> list[tuple[float, float, float]]:
"""Deterministically sample vertices and triangle centroids on every face."""
"""Deterministically sample tessellation vertices on every B-rep face."""
points: dict[tuple[int, int, int], tuple[float, float, float]] = {}
def add(point: tuple[float, float, float]) -> None:
points.setdefault(tuple(round(value / 1e-8) for value in point), point)
for face in shape.faces():
try:
vertices, triangles = face.tessellate(tolerance_mm)
rendered = [_point(vertex) for vertex in vertices]
for vertex in rendered:
add(vertex)
for triangle in triangles:
indices = list(triangle)
if len(indices) >= 3:
first, second, third = (rendered[int(index)] for index in indices[:3])
add(tuple((first[axis] + second[axis] + third[axis]) / 3.0 for axis in range(3)))
vertices, _triangles = face.tessellate(tolerance_mm)
# Tessellation vertices are evaluated on the source B-rep. A
# triangle centroid is generally inside a curved face's chord,
# not on its surface, so including it can reject geometrically
# identical STEP shapes whose face partitions differ.
for vertex in vertices:
rendered = _point(vertex)
add(rendered)
except Exception:
# A malformed individual face should not make the comparison pass.
for vertex in face.vertices():
@@ -65,22 +64,76 @@ def _percentile(values: list[float], percentile: float) -> float:
return ordered[index]
def _bbox(shape: Any) -> list[float]:
box = shape.bounding_box()
return [float(box.min.X), float(box.min.Y), float(box.min.Z), float(box.max.X), float(box.max.Y), float(box.max.Z)]
def _bbox(points: list[tuple[float, float, float]]) -> list[float]:
"""Return B-rep sampled bounds instead of OCC's cached shape bounds."""
if not points:
raise ValueError("cannot calculate a bounding box without surface points")
return [
min(point[0] for point in points), min(point[1] for point in points), min(point[2] for point in points),
max(point[0] for point in points), max(point[1] for point in points), max(point[2] for point in points),
]
def strict_compare(gold_step: Path, rebuilt_step: Path, *, surface_tolerance_mm: float = 0.05) -> dict[str, Any]:
def strict_compare(
gold_step: Path,
rebuilt_step: Path,
*,
surface_tolerance_mm: float = 0.05,
fast_reject_relative_error: float | None = None,
) -> dict[str, Any]:
"""Compare source and rebuilt B-reps without the engine's permissive rules."""
from build123d import import_step
gold, rebuilt = import_step(str(gold_step)), import_step(str(rebuilt_step))
gold_volume, rebuilt_volume = float(gold.volume), float(rebuilt.volume)
gold_area, rebuilt_area = float(gold.area), float(rebuilt.area)
volume_error = abs(gold_volume - rebuilt_volume) / max(abs(gold_volume), 1e-12)
area_error = abs(gold_area - rebuilt_area) / max(abs(gold_area), 1e-12)
gold_solids, rebuilt_solids = len(gold.solids()), len(rebuilt.solids())
metrics = {
"gold_bbox_mm": None,
"rebuilt_bbox_mm": None,
"bbox_max_delta_mm": None,
"gold_volume_mm3": gold_volume,
"rebuilt_volume_mm3": rebuilt_volume,
"volume_relative_error": volume_error,
"gold_surface_area_mm2": gold_area,
"rebuilt_surface_area_mm2": rebuilt_area,
"surface_area_relative_error": area_error,
"gold_solid_count": gold_solids,
"rebuilt_solid_count": rebuilt_solids,
}
exact_mismatch = fast_reject_relative_error is not None and (
gold_solids != rebuilt_solids
or volume_error > fast_reject_relative_error
or area_error > fast_reject_relative_error
)
if exact_mismatch:
# 体积、面积和实体数都是 B-rep 精确量。它们已超过调用方的最宽松
# 验收界限时,无需执行可能耗时数分钟的逐点曲面距离计算;该路径只会
# 产生确定的拒绝,绝不能把未采样曲面判为通过。
reasons = []
if volume_error > RELATIVE_TOLERANCE: reasons.append("volume_relative_error_exceeds_1e-5")
if area_error > RELATIVE_TOLERANCE: reasons.append("surface_area_relative_error_exceeds_1e-5")
if gold_solids != rebuilt_solids: reasons.append("solid_count_mismatch")
surface = {
"status": "skipped_after_exact_mismatch",
"gold_to_rebuilt": {"sample_count": 0, "max_mm": None, "p95_mm": None, "p99_mm": None, "over_tolerance_count": None},
"rebuilt_to_gold": {"sample_count": 0, "max_mm": None, "p95_mm": None, "p99_mm": None, "over_tolerance_count": None},
}
return {
"schema": "onshape_to_cdsl.strict_step_compare.v1", "tolerance_mm": TOLERANCE_MM, "surface_tessellation_tolerance_mm": surface_tolerance_mm,
"gold_step": str(gold_step), "rebuilt_step": str(rebuilt_step), "passed": False, "failure_reasons": reasons,
"surface": surface, "metrics": metrics,
"diagnostic_topology": {"gold_faces": len(gold.faces()), "rebuilt_faces": len(rebuilt.faces()), "gold_edges": len(gold.edges()), "rebuilt_edges": len(rebuilt.edges()), "gold_surface_types": sorted(str(face.geom_type) for face in gold.faces()), "rebuilt_surface_types": sorted(str(face.geom_type) for face in rebuilt.faces())},
}
gold_samples = _surface_points(gold, surface_tolerance_mm)
rebuilt_samples = _surface_points(rebuilt, surface_tolerance_mm)
forward, reverse = _distances(gold_samples, rebuilt), _distances(rebuilt_samples, gold)
gold_box, rebuilt_box = _bbox(gold), _bbox(rebuilt)
# OCC 的 Shape.bounding_box() 对部分 STEP B-spline 曲面会漏掉裁剪面实际极值;
# 严格比较与表面距离共用同一批 B-rep 曲面采样点,避免比较基础设施本身产生伪差异。
gold_box, rebuilt_box = _bbox(gold_samples), _bbox(rebuilt_samples)
bbox_delta = max(abs(a - b) for a, b in zip(gold_box, rebuilt_box))
volume_error = abs(float(gold.volume) - float(rebuilt.volume)) / max(abs(float(gold.volume)), 1e-12)
area_error = abs(float(gold.area) - float(rebuilt.area)) / max(abs(float(gold.area)), 1e-12)
metrics.update({"gold_bbox_mm": gold_box, "rebuilt_bbox_mm": rebuilt_box, "bbox_max_delta_mm": bbox_delta})
forward_max, reverse_max = max(forward), max(reverse)
forward_p99, reverse_p99 = _percentile(forward, 0.99), _percentile(reverse, 0.99)
reasons = []
@@ -94,13 +147,12 @@ def strict_compare(gold_step: Path, rebuilt_step: Path, *, surface_tolerance_mm:
reasons.append("volume_relative_error_exceeds_1e-5")
if area_error > RELATIVE_TOLERANCE:
reasons.append("surface_area_relative_error_exceeds_1e-5")
gold_solids, rebuilt_solids = len(gold.solids()), len(rebuilt.solids())
if gold_solids != rebuilt_solids:
reasons.append("solid_count_mismatch")
return {
"schema": "onshape_to_cdsl.strict_step_compare.v1", "tolerance_mm": TOLERANCE_MM, "surface_tessellation_tolerance_mm": surface_tolerance_mm,
"gold_step": str(gold_step), "rebuilt_step": str(rebuilt_step), "passed": not reasons, "failure_reasons": reasons,
"surface": {"gold_to_rebuilt": {"sample_count": len(forward), "max_mm": forward_max, "p95_mm": _percentile(forward, .95), "p99_mm": forward_p99, "over_tolerance_count": sum(value > TOLERANCE_MM for value in forward)}, "rebuilt_to_gold": {"sample_count": len(reverse), "max_mm": reverse_max, "p95_mm": _percentile(reverse, .95), "p99_mm": reverse_p99, "over_tolerance_count": sum(value > TOLERANCE_MM for value in reverse)}},
"metrics": {"gold_bbox_mm": gold_box, "rebuilt_bbox_mm": rebuilt_box, "bbox_max_delta_mm": bbox_delta, "gold_volume_mm3": float(gold.volume), "rebuilt_volume_mm3": float(rebuilt.volume), "volume_relative_error": volume_error, "gold_surface_area_mm2": float(gold.area), "rebuilt_surface_area_mm2": float(rebuilt.area), "surface_area_relative_error": area_error, "gold_solid_count": gold_solids, "rebuilt_solid_count": rebuilt_solids},
"metrics": metrics,
"diagnostic_topology": {"gold_faces": len(gold.faces()), "rebuilt_faces": len(rebuilt.faces()), "gold_edges": len(gold.edges()), "rebuilt_edges": len(rebuilt.edges()), "gold_surface_types": sorted(str(face.geom_type) for face in gold.faces()), "rebuilt_surface_types": sorted(str(face.geom_type) for face in rebuilt.faces())},
}
+26 -1
View File
@@ -6,7 +6,7 @@ import shutil
import tempfile
import unittest
from onshape_to_cdsl.compare import strict_compare
from onshape_to_cdsl.compare import _bbox, _distances, _surface_points, strict_compare
from onshape_to_cdsl.convert import convert_one
from onshape_to_cdsl.issues import write_issue_register
from onshape_to_cdsl.merge_scans import merge_scans
@@ -100,6 +100,31 @@ class PipelineTests(unittest.TestCase):
self.assertFalse(rejection["passed"])
self.assertIn("bbox_exceeds_0.01mm", rejection["failure_reasons"])
def test_strict_comparator_can_fast_reject_an_exact_mismatch(self) -> None:
from build123d import Box, export_step
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source, changed = root / "source.step", root / "changed.step"
export_step(Box(10, 10, 10), source)
export_step(Box(20, 10, 10), changed)
rejection = strict_compare(source, changed, fast_reject_relative_error=.005)
self.assertFalse(rejection["passed"])
self.assertEqual(rejection["surface"]["status"], "skipped_after_exact_mismatch")
self.assertIsNone(rejection["metrics"]["bbox_max_delta_mm"])
def test_strict_comparator_samples_points_on_the_source_brep(self) -> None:
from build123d import Cylinder
cylinder = Cylinder(21.3, 50)
samples = _surface_points(cylinder, 0.05)
self.assertGreater(len(samples), 0)
self.assertLess(max(_distances(samples, cylinder)), 1e-6)
def test_strict_comparator_bounds_use_the_sampled_brep_surface(self) -> None:
points = [(-1.0, -2.0, -3.0), (4.0, 5.0, 6.0), (0.0, 1.0, 2.0)]
self.assertEqual(_bbox(points), [-1.0, -2.0, -3.0, 4.0, 5.0, 6.0])
def test_00000352_offline_end_to_end_when_fixture_is_available(self) -> None:
fixture = Path.cwd() / "json_to_cdsl/input/onshape_complete/00000352"
if not (fixture / "model.step").exists():