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:
@@ -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())},
|
||||
}
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user