From 738934416e4b742ece03a5c018a8efd5d43d0fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BA=B7?= Date: Tue, 8 Sep 2026 11:47:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(cadfs):=20=E6=89=A9=E5=B1=95=E9=87=8D?= =?UTF-8?q?=E5=BB=BA=E5=BC=95=E6=93=8E=E8=83=BD=E5=8A=9B=E5=B9=B6=E5=9B=BA?= =?UTF-8?q?=E5=8C=96=E4=BB=A3=E8=A1=A8=E6=80=A7=E6=A8=A1=E5=9E=8B=E5=9B=9E?= =?UTF-8?q?=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 扩展 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 特征链及运行时兼容性。 --- .../engine/cdsl_engine/build123d_adapter.py | 654 ++++- backend/engine/cdsl_engine/capabilities.py | 131 +- backend/engine/cdsl_engine/cdsl_schema.json | 112 +- .../engine/cdsl_engine/profile_schema.json | 21 +- backend/engine/cdsl_engine/runtime.py | 576 ++++- backend/engine/cdsl_engine/runtime_types.py | 48 +- .../engine/cdsl_engine/semantic_validation.py | 3 +- backend/engine/cdsl_engine/sketch_solver.py | 130 +- backend/tests/test_engine_loft_geometry.py | 43 + .../tests/test_engine_runtime_foundation.py | 554 +++++ cadfs_to_cdsl/.gitignore | 1 + cadfs_to_cdsl/CADFS_RECONSTRUCTION_TARGET.md | 195 ++ cadfs_to_cdsl/README.md | 15 + cadfs_to_cdsl/compare.py | 19 +- cadfs_to_cdsl/featurescript_parser.py | 37 +- cadfs_to_cdsl/lowering.py | 2142 ++++++++++++++++- cadfs_to_cdsl/pipeline.py | 4 +- cadfs_to_cdsl/query_parser.py | 3 +- cadfs_to_cdsl/reports.py | 15 +- cadfs_to_cdsl/run_full_rebuild.sh | 135 ++ cadfs_to_cdsl/selector_binding.py | 118 +- cadfs_to_cdsl/tests/test_integration.py | 164 +- cadfs_to_cdsl/tests/test_lowering.py | 543 ++++- cadfs_to_cdsl/tests/test_parser.py | 13 + cadfs_to_cdsl/tests/test_reports.py | 21 + cadfs_to_cdsl/tests/test_selector_binding.py | 57 +- .../src/onshape_to_cdsl/compare.py | 90 +- onshape_to_cdsl/tests/test_pipeline.py | 27 +- 28 files changed, 5532 insertions(+), 339 deletions(-) create mode 100644 cadfs_to_cdsl/CADFS_RECONSTRUCTION_TARGET.md create mode 100755 cadfs_to_cdsl/run_full_rebuild.sh create mode 100644 cadfs_to_cdsl/tests/test_reports.py diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index e555705e..235836e3 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -5,7 +5,21 @@ from __future__ import annotations import math from typing import Any, Iterable -from build123d import Axis, Compound, Edge, Face, Location, Plane, ShapeList, Solid, Vector, Wire, export_step +from build123d import Axis, Compound, Edge, Face, Location, Plane, ShapeList, Shell, Solid, Vector, Wire, export_step +from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse +from OCP.BRep import BRep_Tool +from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer +from OCP.BRepOffset import BRepOffset_Skin +from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid +from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol +from OCP.Geom import Geom_SurfaceOfRevolution +from OCP.GeomAbs import GeomAbs_Arc +from OCP.LocOpe import LocOpe_DPrism +from OCP.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle +from OCP.TopAbs import TopAbs_SHELL +from OCP.TopTools import TopTools_ListOfShape +from OCP.TopoDS import TopoDS +from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Vec from .parametric_thread import build_thread_solid from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, ThreadSpec, TopologyRecord, Vector3, canonical_plane_signature @@ -65,14 +79,34 @@ class Build123dGeometryAdapter: @staticmethod def _wire(edges: list[dict[str, Any]]) -> Wire: - # 将边字典列表(直线/圆弧/插值 B 样条)组装成 build123d 的 Wire 线框。 + # 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。 built: list[Edge] = [] for edge in edges: if edge.get("type") == "bspline": points = [_vector(point) for point in edge.get("points_mm") or []] if len(points) < 3: raise ValueError("bspline contour edge needs at least 3 points") - built.append(Edge.make_spline(points, periodic=False)) + parameters = edge.get("parameters") + start_tangent = edge.get("start_tangent_mm") + end_tangent = edge.get("end_tangent_mm") + if (start_tangent is None) != (end_tangent is None): + raise ValueError("bspline contour edge requires both endpoint tangents") + built.append(Edge.make_spline( + points, + tangents=[_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None, + periodic=bool(edge.get("periodic")), + parameters=[float(value) for value in parameters] if parameters is not None else None, + scale=False, + )) + continue + if edge.get("type") == "ellipse": + center = _vector(edge["center_mm"]) + major_axis = _vector(edge["major_axis_mm"]) + normal = _vector(edge.get("normal") or [0, 0, 1]) + if major_axis.length <= 1e-9 or normal.length <= 1e-9: + raise ValueError("ellipse contour edge has a degenerate frame") + plane = Plane(origin=center, x_dir=major_axis, z_dir=normal) + built.append(Edge.make_ellipse(float(edge["major_radius_mm"]), float(edge["minor_radius_mm"]), plane=plane)) continue start = _vector(edge["start_mm"]) end = _vector(edge["end_mm"]) @@ -133,6 +167,30 @@ class Build123dGeometryAdapter: def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Face]: # 从草图数据解析出可拉伸/旋转的轮廓面,按三种数据来源依次回退。 + # 1. 单圆 contour 不应先被 sketch_solver 展开成四段圆弧。圆弧分段会 + # 改变拉伸后的圆柱面拓扑:同一圆柱侧面被拆成四块,后续来自 + # FeatureScript 的 SWEPT_FACE 无法再以圆心/半径唯一定位。对于 + # 只含闭合整圆的轮廓,保留每个圆一条原生 circle edge;同心圆仍由 + # _faces_from_circles 的包含关系生成带孔面。 + profile = sketch.get("profile") or {} + contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None + if isinstance(contours, list) and contours and all( + isinstance(contour, dict) + and bool(contour.get("closed")) + and len(contour.get("segments") or []) == 1 + and (contour.get("segments") or [{}])[0].get("type") == "circle" + for contour in contours + ): + circles = [ + { + "type": "circle", + "center": segment.get("center"), + "radius_mm": segment.get("radius_mm"), + } + for contour in contours + for segment in contour.get("segments") or [] + ] + return self._faces_from_circles(circles, PlaneSpec.from_mapping(sketch.get("workplane") or {})) # 1. 优先使用预计算的轮廓区域 contour_regions_mm(外轮廓 + 孔洞列表)。 regions = sketch.get("contour_regions_mm") or [] if regions: @@ -154,6 +212,20 @@ class Build123dGeometryAdapter: plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) return self._faces_from_circles(sketch.get("entities") or [], plane) + @staticmethod + def face_with_holes(outer: Face, holes: Iterable[Face]) -> Face: + """Build one planar profile from a sketch outer wire and cap-face holes.""" + if outer.inner_wires(): + raise ValueError("profile outer face must not already contain holes") + wires = [] + for hole in holes: + if hole.inner_wires(): + raise ValueError("profile hole face must have exactly one outer wire") + wires.append(hole.outer_wire()) + if not wires: + raise ValueError("profile hole feature requires at least one cap face") + return Face(outer.outer_wire()).make_holes(wires) + def loft(self, sketches: list[dict[str, Any]]) -> Solid: """由多条简单闭合草图轮廓生成实体放样。""" wires: list[Wire] = [] @@ -171,6 +243,20 @@ class Build123dGeometryAdapter: raise ValueError("loft requires at least two profile sketches") return Solid.make_loft(wires) + def loft_with_cap_face(self, cap_face: Face, sketches: list[dict[str, Any]]) -> Solid: + """Loft from one cap face's outer wire to one closed sketch profile.""" + if not sketches: + raise ValueError("cap-face loft requires at least one profile sketch") + wires = [cap_face.outer_wire()] + for index, sketch in enumerate(sketches): + faces = self.faces_for_sketch(sketch) + if len(faces) != 1: + raise ValueError(f"loft profile {index} must resolve to exactly one closed region") + if faces[0].inner_wires(): + raise ValueError(f"loft profile {index} must not contain inner loops") + wires.append(faces[0].outer_wire()) + return Solid.make_loft(wires) + @staticmethod def _coerce_single_or_compound(result: Any, *, empty_error: str | None = None) -> Any: """规整一次布尔结果:None/空视为失败(可选报错),多成员合并为 Compound。""" @@ -187,17 +273,44 @@ class Build123dGeometryAdapter: return None if len(members) == 1: return members[0] - # build123d 0.11 的有效运行时合并 API 是 Compound.make_composite(多体域 - # 合并为 Compound/Part);Compound.make_compound 在类型桩与运行时均不存在, - # 此前的调用在多成员 ShapeList 下会 AttributeError。类型桩未声明 - # make_composite,保留宽泛类型桩噪音抑制。 - return Compound.make_composite(members) + # ``Compound`` constructor accepts an iterable on both supported + # Build123d runtimes. ``make_composite`` is not available in every + # deployed version, so using it here breaks valid multi-solid cuts. + return Compound(members) @staticmethod def extrude(face: Face, direction: Vector3) -> Solid: # 沿给定方向向量拉伸一个面,生成实体。 return Solid.extrude(face, _vector(direction)) + @staticmethod + def extrude_taper(face: Face, direction: Vector3, taper_deg: float) -> Solid: + # 沿给定方向以锥角拉伸一个面。build123d 正角收缩外轮廓,负角扩张; + # CADFS 的 draftPullDirection 已由 lowering 映射到该符号。 + # build123d 对负锥角回退为 offset wire loft;椭圆等解析曲线在该 + # 路径会产生仅能留在内存、STEP round-trip 后退化为 Shell 的 B-rep。 + # LocOpe_DPrism 同时支持正负拔模角,且保留一张解析侧面,因此在 + # 无内环、拉伸方向与 face normal 同向时始终优先使用它。 + vector = _vector(direction) + normal = face.normal_at() + if ( + vector.length > 1e-9 + and normal.length > 1e-9 + and vector.normalized().dot(normal.normalized()) >= 1.0 - 1e-9 + and not face.inner_wires() + ): + angle_rad = math.radians(taper_deg) + prism = LocOpe_DPrism( + face.wrapped, + vector.length / math.cos(angle_rad), + angle_rad, + ) + if prism.IsDone(): + result = Solid(TopoDS.Solid_s(prism.Shape())) + if result.is_valid: + return result + return Solid.extrude_taper(face, _vector(direction), taper_deg) + @staticmethod def extrude_trimmed(face: Face, target: Any, direction: Vector3) -> Any: """Extrude the profile to the target face, trimming unreached regions. @@ -215,6 +328,7 @@ class Build123dGeometryAdapter: # through_next 的 target 是当前主体,必须先从其面中选出实际命中的 # 下一终止面,不能把整个 body 当作待拉伸的 Face。 unit = _vector(direction).normalized() + target_body = target if not isinstance(target, Face) else None points = Build123dGeometryAdapter.profile_sample_points(face) targets = [target] if isinstance(target, Face) else list(target.faces()) candidates = [] @@ -229,15 +343,28 @@ class Build123dGeometryAdapter: # 时取最近的正向交点,确保相邻面交界处的选择稳定。 _, _, target, distances = max(candidates, key=lambda item: (item[0], -item[1])) margin = max(distances) + 2.0 - # 2. 穿透拉伸 profile,同时把目标面向回推生成体层,二者求交即裁剪体。 + if target_body is not None and len(distances) == len(points): + # through_next 的 target 是当前实体,不是孤立的终止面。先让工具体 + # 轻微穿过首个命中面,再切掉既有实体,留下从 profile 到该面的一侧。 + # 将圆柱面拉成 slab 会保留实体内部的短段,丢失外部新增材料。 + pierced = Solid.extrude(face, unit * margin) + return Build123dGeometryAdapter._coerce_single_or_compound( + pierced.cut(target_body), empty_error="extent target leaves no leading material", + ) + # 2. 穿透拉伸 profile,再从目标面两侧各构造一个体层。曲面 Face 的 + # OCC 朝向不保证与拉伸方向一致(特别是 cut 后的内圆柱面),不能 + # 固定假定 -unit 一定朝向 profile;选择与 profile 相接的交集侧。 # build123d 的布尔交方法名是 intersect(不是 OCC 的 common), # 且多实体结果返回 ShapeList,需要规整为单个 Solid / Compound。 pierced = Solid.extrude(face, unit * margin) - slab = Solid.extrude(target, -unit * margin) - trimmed = pierced.intersect(slab) - return Build123dGeometryAdapter._coerce_single_or_compound( - trimmed, empty_error="extent target produced an empty trimmed solid", - ) + candidates = [] + for slab_direction in (-unit * margin, unit * margin): + trimmed = Build123dGeometryAdapter._coerce_single_or_compound(pierced.intersect(Solid.extrude(target, slab_direction))) + if trimmed is not None: + candidates.append((trimmed.distance_to(face), trimmed)) + if not candidates: + raise ValueError("extent target produced an empty trimmed solid") + return min(candidates, key=lambda item: item[0])[1] @staticmethod def body_center(body: Any) -> Vector3: @@ -263,6 +390,20 @@ class Build123dGeometryAdapter: # 提取顶点的三维坐标元组。 return (float(vertex.X), float(vertex.Y), float(vertex.Z)) + @staticmethod + def intersection_vertex(body: Any, face_sets: list[list[Any]]) -> Any: + """Resolve one current-body vertex shared by every selected face set.""" + if not face_sets or any(not faces for faces in face_sets): + raise ValueError("intersection selector has an empty face set") + matched = [] + for vertex in body.vertices(): + if all(any(face.distance_to(vertex) <= 1e-6 for face in faces) for faces in face_sets): + point = (round(float(vertex.X), 6), round(float(vertex.Y), 6), round(float(vertex.Z), 6)) + if not any(point == existing[0] for existing in matched): matched.append((point, vertex)) + if len(matched) != 1: + raise ValueError(f"intersection selector resolved {len(matched)} current-body vertices") + return matched[0][1] + @staticmethod def profile_sample_points(face: Face) -> list[Vector]: """Sample a profile face before a selector-dependent termination. @@ -285,6 +426,51 @@ class Build123dGeometryAdapter: unique.append(point) return unique + @staticmethod + def profile_touches_target(target: Any, faces: Iterable[Face]) -> bool: + """Return whether a profile starts on the selected extent target.""" + # `up_to_surface` 允许 profile 从 selected face 出发。这时 selected + # face 不是拉伸终止,而是起始边界;交给 next_body_face_after 在当前 + # 主体中寻找真实的下一面。distance_to 是 OCC 的最短形体距离,适用于 + # 平面、圆柱和其他可用作 selector 的 B-rep face。 + for face in faces: + try: + if face.distance_to(target) <= 1e-6: + return True + except Exception as error: + raise ValueError("extent target does not support profile distance") from error + return False + + @staticmethod + def next_body_face_after(body: Any, faces: Iterable[Face], direction: Vector3, *, excluded_face: Face) -> Face: + """Find the next current-body face reached after an extent start face.""" + # 对每个候选面统计其能截获的 profile 射线。完整覆盖优先,随后选取 + # 最近正向交点,避免把同一外圆柱的远侧交点误当成穿过实体后的终止面。 + unit = _vector(direction).normalized() + samples = [point for face in faces for point in Build123dGeometryAdapter.profile_sample_points(face)] + if not samples: + raise ValueError("extent feature has no profile samples") + candidates = [] + for candidate in body.faces(): + # Selector resolution can retain the preceding B-rep snapshot, + # whose wrapper is no longer `is_same` after a later boolean. + # Geometrically coincident current faces are still the selected + # start boundary and must not win by their near-zero ray hits. + if candidate.is_same(excluded_face) or candidate.distance_to(excluded_face) <= 1e-6: + continue + distances = [ + value for point in samples + if (value := Build123dGeometryAdapter._forward_intersection_distance(candidate, point, unit)) is not None + ] + if distances: + candidates.append((len(distances), min(distances), candidate)) + if not candidates: + raise ValueError("extent target has no following body face") + coverage, _distance, target = max(candidates, key=lambda item: (item[0], -item[1])) + if coverage != len(samples): + raise ValueError("extent target does not reach every profile ray after the start face") + return target + @staticmethod def _forward_intersection_distance(target: Any, point: Vector, direction: Vector) -> float | None: # 从 point 沿 direction 发一条射线,求与目标的第一个正向交点距离。 @@ -323,16 +509,120 @@ class Build123dGeometryAdapter: # 绕给定轴将面旋转指定角度,生成回转实体。 return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis)) + @staticmethod + def revolve_surface(wire: Wire, angle_deg: float, axis: AxisSpec) -> Shell: + # 表面回转必须以 profile wire 而非 Face 输入。Face 回转会由 OCC 封闭为 + # Solid,错误改变 CADFS NewSurfaceOperation 的实体结果和体积。 + operation = BRepPrimAPI_MakeRevol( + wire.wrapped, + gp_Ax1(gp_Pnt(*axis.origin_mm), gp_Dir(*axis.direction)), + math.radians(angle_deg), + True, + ) + operation.Build() + if not operation.IsDone(): + raise ValueError("surface revolve did not complete") + shape = operation.Shape() + if shape.IsNull() or shape.ShapeType() != TopAbs_SHELL: + raise ValueError("surface revolve did not produce one shell") + return Shell(shape) + + @staticmethod + def surface_wires_for_sketch(sketch: dict[str, Any]) -> list[Wire]: + # 曲面拉伸只消费 CADFS 显式选择的闭合曲线,不把同心圆转换成带孔 Face。 + # 后者适用于实体拉伸,但会丢失每条 source edge 对应的一张独立曲面。 + profile = sketch.get("profile") or {} + plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) + if profile.get("type") == "circle": + return [Build123dGeometryAdapter()._circle_wire(profile.get("center") or [0, 0], float(profile["radius_mm"]), plane)] + contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None + if not isinstance(contours, list) or not contours: + raise ValueError("surface extrude requires closed contour wires") + wires = [] + for contour in contours: + segments = contour.get("segments") or [] + if not contour.get("closed") or len(segments) != 1 or segments[0].get("type") != "circle": + raise ValueError("surface extrude currently supports selected circular wires only") + segment = segments[0] + wires.append(Build123dGeometryAdapter()._circle_wire( + segment.get("center") or [0, 0], float(segment["radius_mm"]), plane, + )) + return wires + + @staticmethod + def extrude_surface(wires: Iterable[Wire], direction: Vector3) -> Any: + # 将每条闭合 wire 沿给定方向扫成独立 shell。曲面不参与实体布尔,后续 + # selector 通过 register_surface 的独立 topology snapshot 追溯其来源。 + vector = _vector(direction) + if vector.length <= 1e-9: + raise ValueError("surface extrude direction must be non-zero") + surfaces = [] + for wire in wires: + operation = BRepPrimAPI_MakePrism(wire.wrapped, gp_Vec(vector.X, vector.Y, vector.Z), True, True) + operation.Build() + if not operation.IsDone(): + raise ValueError("surface extrude did not complete") + shape = operation.Shape() + if shape.IsNull() or shape.ShapeType() != TopAbs_SHELL: + raise ValueError("surface extrude did not produce one shell") + surfaces.append(Shell(shape)) + if not surfaces: + raise ValueError("surface extrude requires at least one wire") + return surfaces[0] if len(surfaces) == 1 else Compound(surfaces) + + @staticmethod + def combine_surfaces(*surfaces: Any) -> Any: + # 不能复用 combine:它按实体 body 生命周期调用 body_solids,会丢弃 + # Shell。曲面组合只用于导出和拓扑登记,不执行实体 boolean。 + members = [] + for surface in surfaces: + if isinstance(surface, Compound): + members.extend(surface.faces()) + else: + members.append(surface) + if not members: + raise ValueError("surface combination requires at least one shell") + return members[0] if len(members) == 1 else Compound(members) + @staticmethod def fuse(body: Any | None, solid: Any) -> Any: # 布尔并:没有既有主体时,直接以该实体作为新主体。 # 实参类型放宽为 Any:build123d 的布尔结果可能是 Solid 或 Compound。 - return solid if body is None else body.fuse(solid) + if body is None: + return solid + # build123d.Shape.fuse 未启用 OBB 加速器;镜像后的重叠实体在该路径 + # 会偶发返回反向、无效的 B-rep。直接采用 OCC 的稳定布尔配置,保留 + # 一般 add/replay 的同一 union 语义。 + arguments = TopTools_ListOfShape(); arguments.Append(body.wrapped) + tools = TopTools_ListOfShape(); tools.Append(solid.wrapped) + operation = BRepAlgoAPI_Fuse() + operation.SetRunParallel(True); operation.SetUseOBB(True) + operation.SetArguments(arguments); operation.SetTools(tools); operation.Build() + if not operation.IsDone(): + raise ValueError("OCC union operation did not complete") + result = Solid(operation.Shape()) + if result.is_valid: + return result + # 保留 build123d 的既有调用作为内核版本差异下的兼容回退;无效结果 + # 不能悄然进入后续 feature history。 + fallback = Build123dGeometryAdapter._coerce_single_or_compound(body.fuse(solid)) + if fallback is not None and fallback.is_valid: + return fallback + raise ValueError("OCC union operation produced an invalid shape") + + @staticmethod + def combine(body: Any | None, solid: Any) -> Any: + # 保留独立 result body:不得调用 fuse,否则相交实体会被内核合并。 + members = ([] if body is None else Build123dGeometryAdapter.body_solids(body)) + members.extend(Build123dGeometryAdapter.body_solids(solid)) + return members[0] if len(members) == 1 else Compound(members) @staticmethod def cut(body: Any, tool: Any) -> Any: # 从主体上减去工具实体。 - return body.cut(tool) + return Build123dGeometryAdapter._coerce_single_or_compound( + body.cut(tool), empty_error="OCC cut operation produced no shape", + ) @staticmethod def sphere(radius_mm: float, center_mm: Vector3) -> Solid: @@ -470,7 +760,179 @@ class Build123dGeometryAdapter: @staticmethod def chamfer(body: Any, distance_mm: float, distance_2_mm: float | None, edges: Iterable[Edge], face: Face | None = None) -> Any: # 对指定边做倒角;distance_2_mm 提供时形成非对称倒角。 - return body.chamfer(distance_mm, distance_2_mm, list(edges), face=face) + # OCC 的单距离 Add 重载会按内核的等距倒角语义处理两侧相邻面。build123d + # 的通用实现会先任选一张邻接面再调用双距离重载,复杂实体上该选择会改变 + # 倒角结果,因此仅等距倒角优先走原生重载。 + selected = list(edges) + if distance_2_mm is None and face is None and len(Build123dGeometryAdapter.body_solids(body)) == 1: + builder = BRepFilletAPI_MakeChamfer(body.wrapped) + for edge in selected: + builder.Add(distance_mm, edge.wrapped) + builder.Build() + if builder.IsDone(): + result = Solid(builder.Shape()) + if result.is_valid: + return result + return body.chamfer(distance_mm, distance_2_mm, selected, face=face) + + @staticmethod + def _surface_limited_chamfer_tool(body: Any, edge: Edge, distance_mm: float, surfaces: Iterable[Any]) -> Solid: + """Build the removable material for one surface-supported circular chamfer. + + A regular equal-offset chamfer is first attempted by ``chamfer``. This + helper only handles the narrow CADFS case where that operation reaches + a concentric surface split before the requested second offset. The + explicit shell must contain the limiting circle at the selected plane; + without that evidence this method deliberately rejects the fallback. + """ + if str(edge.geom_type).split(".")[-1].lower() != "circle": + raise ValueError("surface-limited chamfer requires circular edges") + try: + radius = float(edge.radius) + center = edge.arc_center + except (TypeError, ValueError) as error: + raise ValueError("surface-limited chamfer edge has no circle radius") from error + if radius <= 0: + raise ValueError("surface-limited chamfer edge radius must be positive") + + adjacent = [ + face for face in body.faces() + if any(face_edge.is_same(edge) for face_edge in face.edges()) + ] + planes = [face for face in adjacent if str(face.geom_type).split(".")[-1].lower() == "plane"] + cylinders = [face for face in adjacent if str(face.geom_type).split(".")[-1].lower() == "cylinder"] + if len(planes) != 1 or len(cylinders) != 1: + raise ValueError("surface-limited chamfer requires one planar and one cylindrical adjacent face") + plane_face, cylinder_face = planes[0], cylinders[0] + axis = cylinder_face.axis_of_rotation + if axis is None: + raise ValueError("surface-limited chamfer cylinder has no axis") + axis_direction = axis.direction.normalized() + if abs(plane_face.normal_at().normalized().dot(axis_direction)) < 1.0 - 1e-6: + raise ValueError("surface-limited chamfer faces are not perpendicular") + + boundaries = [ + candidate for candidate in plane_face.edges() + if str(candidate.geom_type).split(".")[-1].lower() == "circle" + and not candidate.is_same(edge) + and (candidate.arc_center - center).length <= 1e-6 + ] + if len(boundaries) != 1: + raise ValueError("surface-limited chamfer plane has no unique concentric support") + support_edge = boundaries[0] + support_radius = float(support_edge.radius) + radial_span = radius - support_radius + if radial_span <= 1e-6 or distance_mm <= radial_span + 1e-6: + raise ValueError("surface-limited chamfer does not need a constrained outer transition") + + cylinder_ends = [ + candidate for candidate in cylinder_face.edges() + if str(candidate.geom_type).split(".")[-1].lower() == "circle" + and not candidate.is_same(edge) + and abs(float(candidate.radius) - radius) <= 1e-6 + and (candidate.arc_center - center).length > 1e-6 + ] + if len(cylinder_ends) != 1: + raise ValueError("surface-limited chamfer cylinder has no unique opposite cap") + axial_span = cylinder_ends[0].arc_center - center + if axial_span.length <= distance_mm + 1e-6: + raise ValueError("surface-limited chamfer exceeds the selected cylindrical face") + direction = axial_span.normalized() + + supported = False + for surface in surfaces: + for face in surface.faces(): + if str(face.geom_type).split(".")[-1].lower() != "cylinder": + continue + surface_axis = face.axis_of_rotation + if surface_axis is None or abs(surface_axis.direction.normalized().dot(axis_direction)) < 1.0 - 1e-6: + continue + if abs(float(face.radius) - support_radius) > 1e-6: + continue + if any( + str(boundary.geom_type).split(".")[-1].lower() == "circle" + and abs(float(boundary.radius) - support_radius) <= 1e-6 + and (boundary.arc_center - center).length <= 1e-6 + for boundary in face.edges() + ): + supported = True + break + if supported: + break + if not supported: + raise ValueError("surface-limited chamfer has no explicit surface support") + + frame_x = edge.tangent_at(0.0).normalized() + first_length = distance_mm - radial_span + outer_radius = radius + max(distance_mm, 1.0) + start_plane = Plane(origin=center, x_dir=frame_x, z_dir=direction) + cone_plane = Plane(origin=center + direction * first_length, x_dir=frame_x, z_dir=direction) + outer = Solid.make_cylinder(outer_radius, distance_mm, start_plane) + core = Solid.make_cylinder(support_radius, first_length, start_plane).fuse( + Solid.make_cone(support_radius, radius, radial_span, cone_plane), + ) + return outer.cut(core) + + @staticmethod + def surface_limited_chamfer(body: Any, distance_mm: float, edges: Iterable[Edge], surfaces: Iterable[Any]) -> Any: + # 显式 surface shell 只在内核正常倒角失败后作为截断证据使用。每条边 + # 都先从同一原 body 推导工具体,随后依序切除,避免已变形拓扑反向影响 + # 另一条 source selector。 + if len(Build123dGeometryAdapter.body_solids(body)) != 1: + raise ValueError("surface-limited chamfer requires one solid body") + selected = list(edges) + if not selected: + raise ValueError("surface-limited chamfer requires at least one edge") + surface_members = list(surfaces) + tools = [ + Build123dGeometryAdapter._surface_limited_chamfer_tool(body, edge, distance_mm, surface_members) + for edge in selected + ] + result = body + for tool in tools: + result = result.cut(tool) + if not isinstance(result, Solid) or not result.is_valid: + raise ValueError("surface-limited chamfer produced an invalid shape") + if result.volume >= body.volume - 1e-6: + raise ValueError("surface-limited chamfer removed no material") + return result + + @staticmethod + def shell(body: Any, faces: Iterable[Face], thickness_mm: float, *, inward: bool = True) -> Any: + # 对单个实体移除指定面并偏置其余面,生成薄壁实体。多 body 的目标 + # 选择与结果合并由 runtime 处理;OCC 的 MakeThickSolidByJoin 只接受 + # 一个 Solid,不能把 Compound 直接交给内核并猜测其 body 生命周期。 + selected = list(faces) + if not selected: + raise ValueError("shell requires at least one face to remove") + thickness = float(thickness_mm) + if thickness <= 0: + raise ValueError("shell thickness_mm must be > 0") + solids = Build123dGeometryAdapter.body_solids(body) + if len(solids) != 1: + raise ValueError("shell adapter requires exactly one target solid") + closing_faces = TopTools_ListOfShape() + for face in selected: + closing_faces.Append(face.wrapped) + builder = BRepOffsetAPI_MakeThickSolid() + builder.MakeThickSolidByJoin( + solids[0].wrapped, + closing_faces, + -thickness if inward else thickness, + 1e-6, + BRepOffset_Skin, + False, + False, + GeomAbs_Arc, + False, + ) + builder.Build() + if not builder.IsDone(): + raise ValueError("OCC shell operation did not complete") + result = Solid(builder.Shape()) + if not result.is_valid: + raise ValueError("OCC shell operation produced an invalid shape") + return result @staticmethod def sweep( @@ -493,15 +955,36 @@ class Build123dGeometryAdapter: } if transition is not None: sweep_options["transition"] = transition - return Solid.sweep(section, spine, **sweep_options) + result = Solid.sweep(section, spine, **sweep_options) + if make_solid and (not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9): + # OCC 在截面与路径不构成有效实体 sweep 时可能返回零体积形状, + # 而不报告 Build() 失败。该结果不能作为 CADFS 的 solid body 继续传播。 + raise ValueError("OCC sweep operation did not produce a solid") + return result @staticmethod - def sweep_path(points: Iterable[Vector3]) -> Wire: - # 把三维点列连成折线 Wire,作为扫掠路径的通用构造入口。 + def sweep_path( + points: Iterable[Vector3], + *, + start_tangent: Vector3 | None = None, + end_tangent: Vector3 | None = None, + parameters: list[float] | None = None, + ) -> Edge | Wire: + # 两点路径保持直线;三个及以上插值点构造单段 B-spline。端切线是 + # FeatureScript skFitSpline 的约束,缺失时不能伪造,交给内核自动求解。 vertices = [_vector(point) for point in points] if len(vertices) < 2: raise ValueError("sweep path needs at least two points") - return Wire([Edge.make_line(vertices[index], vertices[index + 1]) for index in range(len(vertices) - 1)]) + if len(vertices) == 2: + if start_tangent is not None or end_tangent is not None or parameters is not None: + raise ValueError("line sweep path does not accept B-spline tangents") + return Edge.make_line(vertices[0], vertices[1]) + if (start_tangent is None) != (end_tangent is None): + raise ValueError("sweep B-spline path requires both endpoint tangents") + tangents = [_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None + if parameters is not None and len(parameters) != len(vertices): + raise ValueError("sweep B-spline path parameters must match point count") + return Edge.make_spline(vertices, tangents=tangents, parameters=parameters, scale=False) @staticmethod def helix_path( @@ -581,8 +1064,30 @@ class Build123dGeometryAdapter: @staticmethod def export(body: Any, path: str) -> None: - # 将主体导出为 STEP 文件。 - export_step(body, path) + # OCCT 对大于 90 度的 SURFACE_OF_REVOLUTION 在 STEP round-trip 时会 + # 丢失部分参数域,导入后该侧面退化为一条母线。只对包含这类曲面的 + # 独立实体按 45 度分段,保留原始解析曲面与实体几何,避免影响其余 + # B-rep 的拓扑和导出体积。 + segments = [] + for solid in Build123dGeometryAdapter.body_solids(body): + if not isinstance(solid, Solid): + # Shell 或只含曲面的 Compound 没有实体体积分割语义。直接交给 + # STEP exporter,才能保留 pure-surface feature history 的面。 + segments.append(solid); continue + has_revolution = any( + BRep_Tool.Surface_s(face.wrapped).IsKind(Geom_SurfaceOfRevolution.get_type_descriptor_s()) + for face in solid.faces() + ) + if not has_revolution: + segments.append(solid); continue + divider = ShapeUpgrade_ShapeDivideAngle(math.radians(45.0), solid.wrapped) + divider.SetPrecision(1e-7); divider.SetMaxTolerance(1e-5) + if not divider.Perform(): raise ValueError("STEP revolution surface segmentation failed") + segmented = Solid(divider.Result()) + if not segmented.is_valid: + raise ValueError("STEP revolution surface segmentation produced an invalid solid") + segments.append(segmented) + export_step(segments[0] if len(segments) == 1 else Compound(segments), path) @staticmethod def body_solids(body: Any) -> list[Any]: @@ -608,6 +1113,16 @@ class Build123dGeometryAdapter: "solid_count": len(solids), } + @staticmethod + def surface_geometry(surface: Any) -> dict[str, Any]: + # 曲面结果不参与实体 body 聚合;只保存后续 selector 所需的独立拓扑摘要。 + bbox = surface.bounding_box() + return { + "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], + "area_mm2": float(surface.area), + "face_count": len(surface.faces()), + } + @staticmethod def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: # 从主体导出全部面/边/顶点拓扑记录,供后续特征选择与引用。 @@ -694,51 +1209,56 @@ class Build123dGeometryAdapter: elif geometry["surface_type"] in {"cylinder", "cone"}: axis = face.axis_of_rotation if axis is None: - raise ValueError("rotational face is missing an axis") - direction = axis.direction - origin = axis.position - geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z] - geometry["axis_direction"] = [direction.X, direction.Y, direction.Z] - raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None - if geometry["surface_type"] == "cone": - boundary_radii: list[float] = [] - for edge in face.edges(): - if str(edge.geom_type).split(".")[-1].lower() != "circle": - continue - try: - boundary_radii.append(float(edge.radius)) - except ValueError: - continue - geometry["boundary_radii_mm"] = sorted(boundary_radii) - geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None - # ``through`` alone describes a cylinder spanning two opposed - # planar faces. That applies to both a through bore and the - # outside wall of a cylindrical extrusion. Classify the B-rep - # face by its oriented normal so downstream acceptance claims - # can prove holes without mistaking an exterior wall for one. - unit_axis = (direction.X, direction.Y, direction.Z) - radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z) - axial_projection = sum(radial[component] * unit_axis[component] for component in range(3)) - radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3)) - radial_length = sum(component * component for component in radial) ** 0.5 - if geometry["surface_type"] == "cylinder": - # OCC can report a cylinder surface with ``radius=None`` - # after a non-planar-side Boolean cut. The face centre is - # still on that cylinder, so its perpendicular distance to - # the rotation axis is an equivalent measured radius. Do - # not fail an otherwise valid build merely because that - # optional OCC convenience property is absent. - if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)): - geometry["radius_mm"] = float(raw_cylinder_radius) - elif radial_length > 1e-9: - geometry["radius_mm"] = radial_length - if radial_length > 1e-9: - normal_components = (normal.X, normal.Y, normal.Z) - alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length - geometry["radial_normal_alignment"] = alignment - geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown" + # Build123d can omit this optional OCC property for valid + # swept rotational faces. Keep their generic B-rep record + # so a selector-free workflow remains executable; do not + # invent axis/radius evidence for an axis-based selector. + pass else: - geometry["cylinder_role"] = "unknown" + direction = axis.direction + origin = axis.position + geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z] + geometry["axis_direction"] = [direction.X, direction.Y, direction.Z] + raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None + if geometry["surface_type"] == "cone": + boundary_radii: list[float] = [] + for edge in face.edges(): + if str(edge.geom_type).split(".")[-1].lower() != "circle": + continue + try: + boundary_radii.append(float(edge.radius)) + except ValueError: + continue + geometry["boundary_radii_mm"] = sorted(boundary_radii) + geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None + # ``through`` alone describes a cylinder spanning two opposed + # planar faces. That applies to both a through bore and the + # outside wall of a cylindrical extrusion. Classify the B-rep + # face by its oriented normal so downstream acceptance claims + # can prove holes without mistaking an exterior wall for one. + unit_axis = (direction.X, direction.Y, direction.Z) + radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z) + axial_projection = sum(radial[component] * unit_axis[component] for component in range(3)) + radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3)) + radial_length = sum(component * component for component in radial) ** 0.5 + if geometry["surface_type"] == "cylinder": + # OCC can report a cylinder surface with ``radius=None`` + # after a non-planar-side Boolean cut. The face centre is + # still on that cylinder, so its perpendicular distance to + # the rotation axis is an equivalent measured radius. Do + # not fail an otherwise valid build merely because that + # optional OCC convenience property is absent. + if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)): + geometry["radius_mm"] = float(raw_cylinder_radius) + elif radial_length > 1e-9: + geometry["radius_mm"] = radial_length + if radial_length > 1e-9: + normal_components = (normal.X, normal.Y, normal.Z) + alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length + geometry["radial_normal_alignment"] = alignment + geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown" + else: + geometry["cylinder_role"] = "unknown" face_edge_indexes.append(set(boundary_edge_indexes)) face_geometries.append(geometry) records.append(TopologyRecord( diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index 6ad669aa..c5a9f34e 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -16,30 +16,30 @@ from .runtime_types import CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeD from .operation_contracts import materialized_feature_contracts -_SELECTOR_REQUIRED = frozenset({"fillet", "chamfer"}) -_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_") +_SELECTOR_REQUIRED = frozenset({"extrude_add_blind_with_hole", "loft_add_with_cap_face", "fillet", "chamfer", "shell"}) +_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_") # 开放轮廓(closed=false / role=open)只有"刀具截面补槽口边闭合后作切除"的 # 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。 _OPEN_PROFILE_ATOMICS = frozenset({"extrude_cut_blind", "extrude_cut_through"}) _PRIMARY_ATOMICS = frozenset({ - "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", + "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface", "extrude_cut_through", - "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", + "revolve_add", "revolve_cut", "revolve_surface", "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_two_sided", "extrude_cut_through", - "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer", + "loft_add_with_cap_face", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer", "shell", # 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_two_sided", - "extrude_cut_through", "loft_add", + "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", + "extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add", - "thread_add", "thread_cut", *_HOLE_ATOMICS, "fillet", "chamfer", + "thread_add", "thread_cut", *_HOLE_ATOMICS, "fillet", "chamfer", "shell", "boolean_bodies", }) # A pattern may replay a previous pattern as well as a direct body mutation. # Context-only features have no geometry definition to instance. thread_add @@ -47,7 +47,7 @@ _BODY_MUTATING_ATOMICS = frozenset({ # parametric axis, so a replayed thread would silently re-run at the original # location. _REPLAYABLE_ATOMICS = ( - _BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut"}) + _BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut", "shell"}) ) | frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"}) _SUPPORTED_EXTENTS = frozenset({ "blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind", @@ -163,7 +163,7 @@ def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]: 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": + if atomic_id in {"loft_add", "loft_add_with_cap_face"}: for sketch_id in (feature.get("params") or {}).get("profile_sketch_ids") or (): required.add(str(sketch_id)) return frozenset(required) @@ -313,12 +313,13 @@ class CapabilityAnalyzer: sketch_id=node.sketch_id, atomic_id=node.atomic_id, )) - if node.atomic_id == "loft_add": + if node.atomic_id in {"loft_add", "loft_add_with_cap_face"}: profile_ids = params.get("profile_sketch_ids") - if not isinstance(profile_ids, list) or len(profile_ids) < 2: + minimum_profiles = 1 if node.atomic_id == "loft_add_with_cap_face" else 2 + if not isinstance(profile_ids, list) or len(profile_ids) < minimum_profiles: blockers.append(self._blocker( node.feature_id, "invalid_loft_profiles", - "Loft requires at least two profile sketch ids", + f"Loft requires at least {minimum_profiles} profile sketch ids", )) elif len({str(sketch_id) for sketch_id in profile_ids}) != len(profile_ids): blockers.append(self._blocker( @@ -361,20 +362,90 @@ class CapabilityAnalyzer: "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)没有锥形 - # 拉伸能力,人读契约 profile_schema.json 也未声明该参数。 - # 若 importer 把 SolidWorks 的 draft_angle_rad 写进 CDSL, - # 当前 runtime 会静默产出无拔模角的直壁实体。这里把它从 - # "静默忽略"改为"显式拒绝"(与 unsupported_extent 同模式)。 - if params.get("draft"): + if node.atomic_id == "sweep_add": + path = params.get("path") + segment = path.get("segment") if isinstance(path, dict) else None + kind = segment.get("type") if isinstance(segment, dict) else None + if kind not in {"line", "bspline"}: blockers.append(self._blocker( - node.feature_id, "unsupported_draft", - "Extrude draft/taper is not implemented; the runtime would silently ignore it", + node.feature_id, "unsupported_sweep_path", + "Sweep requires one captured line or B-spline path", )) + elif kind == "line" and not all( + isinstance(segment.get(key), list) and len(segment[key]) == 2 + for key in ("start", "end") + ): + blockers.append(self._blocker( + node.feature_id, "invalid_sweep_path", + "Sweep line path requires two-dimensional start and end points", + )) + elif kind == "bspline" and ( + not isinstance(segment.get("points"), list) + or len(segment.get("points") or []) < 3 + ): + blockers.append(self._blocker( + node.feature_id, "invalid_sweep_path", + "Sweep B-spline path requires at least three interpolation points", + )) + if node.atomic_id == "boolean_bodies": + target_ids = params.get("target_feature_ids") + tool_ids = params.get("tool_feature_ids") + operation = params.get("operation") + if operation not in {"union", "subtract", "intersect"}: + blockers.append(self._blocker( + node.feature_id, "unsupported_boolean_operation", + "booleanBodies requires union, subtract or intersect", + )) + for parameter, feature_ids in (("target_feature_ids", target_ids), ("tool_feature_ids", tool_ids)): + if not isinstance(feature_ids, list) or not feature_ids: + blockers.append(self._blocker( + node.feature_id, "missing_boolean_bodies", + "booleanBodies requires explicit target and tool feature ids", parameter=parameter, + )) + continue + for feature_id in feature_ids: + source = nodes_by_id.get(str(feature_id)) + if source is None: + blockers.append(self._blocker( + node.feature_id, "boolean_body_unavailable", + "booleanBodies source feature does not exist", feature_id=feature_id, + )) + elif source.feature_id not in completed: + blockers.append(self._blocker( + node.feature_id, "boolean_body_unavailable", + "booleanBodies source feature did not become executable", feature_id=feature_id, + )) + if isinstance(target_ids, list) and isinstance(tool_ids, list) and set(target_ids) & set(tool_ids): + blockers.append(self._blocker( + node.feature_id, "boolean_body_overlap", + "booleanBodies targets and tools must be disjoint", + )) + if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES): end_condition = params.get("end_condition") or {"type": "blind"} end_type = end_condition.get("type") + draft = params.get("draft") + if draft is not None: + # 目前只将 CADFS 单侧盲向拔模映射到 build123d + # Solid.extrude_taper。双向、到面和非实体 profile 的中性面 + # 语义尚无 CDSL 表达,必须保留为明确能力缺口。 + if ( + node.atomic_id not in {"extrude_add_blind", "extrude_cut_blind"} + or end_type != "blind" + ): + blockers.append(self._blocker( + node.feature_id, "unsupported_draft_extent", + "Draft currently supports only one-sided blind extrusions", + )) + elif not ( + isinstance(draft, dict) + and isinstance(draft.get("angle_deg"), (int, float)) + and 0 < float(draft["angle_deg"]) < 90 + and isinstance(draft.get("pull_direction"), bool) + ): + blockers.append(self._blocker( + node.feature_id, "invalid_draft", + "Draft requires angle_deg in (0, 90) and boolean pull_direction", + )) required.append(f"extent:{end_type}") if end_type not in _SUPPORTED_EXTENTS: blockers.append(self._blocker(node.feature_id, "unsupported_extent", "The extent needs a resolved topology selector or is not implemented", extent=end_type)) @@ -430,6 +501,13 @@ class CapabilityAnalyzer: )) if node.atomic_id in _SELECTOR_REQUIRED and not node.selectors: blockers.append(self._blocker(node.feature_id, "missing_selector", "Dress-up features require an explicit selector")) + if node.atomic_id in {"extrude_add_blind_with_hole", "loft_add_with_cap_face"}: + face_selectors = [selector for selector in node.selectors if selector.get("kind") == "face"] + if len(face_selectors) != 1 or len(node.selectors) != 1: + blockers.append(self._blocker( + node.feature_id, "invalid_profile_hole_selector", + "Derived profile features require exactly one cap-face selector", + )) if node.atomic_id in _HOLE_ATOMICS: required.append("selector:host_face") if not params.get("host_face"): @@ -529,16 +607,17 @@ 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_cut_two_sided", - "extrude_cut_through", "loft_add", + "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", + "extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "boolean_bodies", "revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add", "thread_add", # thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由 # active_body 前置阻止,文档含该类特征即视为携带可执行几何。 "thread_cut", } + surface_producers = {"extrude_surface", "revolve_surface"} document_blockers: list[RuntimeDiagnostic] = [] - if not any(node.atomic_id in body_producers for node in plan): + if not any(node.atomic_id in body_producers | surface_producers for node in plan): document_blockers.append(RuntimeDiagnostic( "no_solid_feature", "CDSL contains no feature capable of creating a solid body", )) diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index c8774d56..cfb8fc8a 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -85,7 +85,8 @@ "properties": { "type": {"type": "string", "minLength": 1}, "solidworks_code": {"type": "integer"}, - "reference": {"$ref": "#/$defs/selectorRef"} + "reference": {"$ref": "#/$defs/selectorRef"}, + "offset_mm": {"type": "number", "minimum": 0} }, "required": ["type", "solidworks_code"], "additionalProperties": false @@ -98,10 +99,16 @@ }, "extrudeParams": { "type": "object", - "properties": {"distance_mm": {"$ref": "#/$defs/number"}, "reverse": {"type": "boolean"}, "reverse_distance_mm": {"$ref": "#/$defs/number"}, "end_condition": {"$ref": "#/$defs/endCondition"}, "reverse_end_condition": {"$ref": "#/$defs/endCondition"}, "draft": {"type": "object"}}, + "properties": {"distance_mm": {"$ref": "#/$defs/number"}, "reverse": {"type": "boolean"}, "reverse_distance_mm": {"$ref": "#/$defs/number"}, "end_condition": {"$ref": "#/$defs/endCondition"}, "reverse_end_condition": {"$ref": "#/$defs/endCondition"}, "draft": {"$ref": "#/$defs/extrudeDraft"}, "result_mode": {"enum": ["fuse", "new_body"]}}, "required": ["distance_mm"], "additionalProperties": false }, + "extrudeDraft": { + "type": "object", + "properties": {"angle_deg": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 90}, "pull_direction": {"type": "boolean"}}, + "required": ["angle_deg", "pull_direction"], + "additionalProperties": false + }, "extrudeCutThroughParams": { "type": "object", "properties": {"reverse": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}}, @@ -122,12 +129,57 @@ "required": ["profile_sketch_ids"], "additionalProperties": false }, + "loftCapFaceParams": { + "type": "object", + "properties": { + "profile_sketch_ids": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "uniqueItems": true, + "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"} + } + }, + "required": ["profile_sketch_ids"], + "additionalProperties": false + }, + "sweepPath": { + "type": "object", + "properties": { + "workplane": {"$ref": "#/$defs/workplane"}, + "segment": {"$ref": "#/$defs/analyticSegment"} + }, + "required": ["workplane", "segment"], + "additionalProperties": false + }, + "sweepParams": { + "type": "object", + "properties": { + "path": {"$ref": "#/$defs/sweepPath"}, + "is_frenet": {"type": "boolean"}, + "result_mode": {"enum": ["fuse", "new_body"]} + }, + "required": ["path"], + "additionalProperties": false + }, "revolveParams": { "type": "object", - "properties": {"angle_deg": {"type": "number", "minimum": 0, "maximum": 360}, "axis": {"$ref": "#/$defs/axis"}, "reverse": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}}, + "properties": {"angle_deg": {"type": "number", "minimum": 0, "maximum": 360}, "axis": {"$ref": "#/$defs/axis"}, "reverse": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}, "result_mode": {"enum": ["fuse", "new_body"]}}, "required": ["angle_deg", "axis"], "additionalProperties": false }, + "revolveSurfaceParams": { + "type": "object", + "properties": {"angle_deg": {"type": "number", "minimum": 0, "maximum": 360}, "axis": {"$ref": "#/$defs/axis"}, "reverse": {"type": "boolean"}}, + "required": ["angle_deg", "axis"], + "additionalProperties": false + }, + "extrudeSurfaceParams": { + "type": "object", + "properties": {"distance_mm": {"type": "number", "exclusiveMinimum": 0}, "reverse": {"type": "boolean"}, "reverse_distance_mm": {"type": "number", "exclusiveMinimum": 0}}, + "required": ["distance_mm"], + "additionalProperties": false + }, "sphereParams": { "type": "object", "properties": { @@ -256,10 +308,27 @@ }, "chamferParams": { "type": "object", - "properties": {"distance_mm": {"type": "number", "minimum": 0}, "distance_2_mm": {"type": "number", "minimum": 0}, "angle_rad": {"type": "number", "minimum": 0, "maximum": 3.141592653589793}}, + "properties": {"distance_mm": {"type": "number", "minimum": 0}, "distance_2_mm": {"type": "number", "minimum": 0}, "angle_rad": {"type": "number", "minimum": 0, "maximum": 3.141592653589793}, "tangent_propagation": {"type": "boolean"}}, "required": ["distance_mm"], "additionalProperties": false }, + "shellParams": { + "type": "object", + "properties": {"thickness_mm": {"type": "number", "exclusiveMinimum": 0}, "inward": {"type": "boolean"}}, + "required": ["thickness_mm"], + "additionalProperties": false + }, + "booleanBodiesParams": { + "type": "object", + "properties": { + "operation": {"enum": ["union", "subtract", "intersect"]}, + "target_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, + "tool_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, + "keep_tools": {"type": "boolean"} + }, + "required": ["operation", "target_feature_ids", "tool_feature_ids"], + "additionalProperties": false + }, "linearPatternParams": { "type": "object", "properties": { @@ -278,7 +347,8 @@ "type": "object", "properties": { "source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, - "mirror_plane": {"$ref": "#/$defs/selectorOrUnresolved"} + "mirror_plane": {"$ref": "#/$defs/selectorOrUnresolved"}, + "mirror_current_body": {"type": "boolean"} }, "required": ["source_feature_ids", "mirror_plane"], "additionalProperties": false @@ -289,7 +359,9 @@ "source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, "axis": {"$ref": "#/$defs/axis"}, "pattern_count": {"type": "integer", "minimum": 1}, - "sweep_angle_deg": {"type": "number", "minimum": -360, "maximum": 360} + "sweep_angle_deg": {"type": "number", "minimum": -360, "maximum": 360}, + "operation_mode": {"enum": ["add", "remove"]}, + "excluded_instance_indices": {"type": "array", "items": {"type": "integer", "minimum": 1}, "uniqueItems": true} }, "required": ["source_feature_ids", "axis", "pattern_count"], "additionalProperties": false @@ -337,10 +409,14 @@ "properties": { "kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]}, "stable_id": {"type": "string", "minLength": 1}, - "owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, + "owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_.:-]{1,160}$"}, "geometry": {"type": "object"}, "source": {"enum": ["solidworks", "inferred_from_step", "runtime_snapshot", "viewer_selection"]}, "snapshot_id": {"type": "string", "minLength": 1}, + "binding_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, + "match_mode": {"enum": ["unique", "all"]}, + "matched_selectors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorRef"}}, + "intersection_of": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}}, "confidence": {"type": "number", "minimum": 0, "maximum": 1} }, "required": ["kind", "stable_id", "source", "confidence"], @@ -349,19 +425,28 @@ "analyticSegment": { "type": "object", "properties": { - "type": {"enum": ["line", "arc", "circle", "bspline"]}, + "type": {"enum": ["line", "arc", "circle", "ellipse", "bspline"]}, "start": {"$ref": "#/$defs/point2"}, "end": {"$ref": "#/$defs/point2"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, + "major_radius_mm": {"$ref": "#/$defs/positive"}, + "minor_radius_mm": {"$ref": "#/$defs/positive"}, + "major_axis": {"$ref": "#/$defs/point2"}, "points": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}, - "clockwise": {"type": "boolean"} + "parameters": {"type": "array", "minItems": 3, "items": {"type": "number"}}, + "periodic": {"type": "boolean"}, + "parameterization": {"enum": ["chord", "centripetal"]}, + "clockwise": {"type": "boolean"}, + "start_tangent": {"$ref": "#/$defs/point2"}, + "end_tangent": {"$ref": "#/$defs/point2"} }, "required": ["type"], "allOf": [ {"if": {"properties": {"type": {"const": "line"}}}, "then": {"required": ["start", "end"]}}, {"if": {"properties": {"type": {"const": "arc"}}}, "then": {"required": ["start", "end", "center", "radius_mm"]}}, {"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}}, + {"if": {"properties": {"type": {"const": "ellipse"}}}, "then": {"required": ["center", "major_radius_mm", "minor_radius_mm", "major_axis"]}}, {"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["start", "end", "points"]}} ], "additionalProperties": false @@ -405,7 +490,7 @@ "required": ["type", "contours"], "additionalProperties": false }, - "feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "loft_add", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]}, + "feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_surface", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "fillet", "chamfer", "shell", "boolean_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]}, "feature": { "type": "object", "properties": { @@ -423,13 +508,18 @@ "additionalProperties": false, "allOf": [ {"if": {"properties": {"atomic_id": {"const": "extrude_add_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "extrude_add_blind_with_hole"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "extrude_add_two_sided"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "extrude_cut_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "extrude_cut_through"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeCutThroughParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "extrude_cut_two_sided"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "loft_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "loft_add_with_cap_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftCapFaceParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "sweep_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sweepParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "revolve_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "revolve_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "revolve_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveSurfaceParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "extrude_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeSurfaceParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "sphere_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sphereParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "box_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/boxParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "cylinder_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/cylinderParams"}}}}, @@ -440,6 +530,8 @@ {"if": {"properties": {"atomic_id": {"const": "hole_counterbore"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeCounterboreParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "fillet"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/filletParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "chamfer"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/chamferParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "shell"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/shellParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "boolean_bodies"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/booleanBodiesParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "pattern_linear"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/linearPatternParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "pattern_mirror"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/mirrorPatternParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "pattern_circular"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/circularPatternParams"}}}}, diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index 6afb7565..3e270881 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -6,14 +6,19 @@ "coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.", "runtime_supported_profiles": ["circle", "polygon", "analytic_contours"], "operation_contracts": { - "extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]}, + "extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]}, + "extrude_add_blind_with_hole": {"atomic_id":"extrude_add_blind_with_hole","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","profile_hole_face"],"candidate_verifiers":["single_connected_body"]}, + "extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":[]}, "loft_add": {"atomic_id":"loft_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":16,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"]}, - "extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]}, + "loft_add_with_cap_face": {"atomic_id":"loft_add_with_cap_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":1,"maxItems":1,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["loft_cap_face","loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"]}, + "sweep_add": {"atomic_id":"sweep_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body"]}, + "extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]}, "extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, "extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, "extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, - "revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"]}, + "revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"]}, "revolve_cut": {"atomic_id":"revolve_cut","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, + "revolve_surface": {"atomic_id":"revolve_surface","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch","single_closed_profile_without_holes"],"candidate_verifiers":["surface_shell"]}, "hole_blind": {"atomic_id":"hole_blind","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore","through_cylindrical_bore"]}, "hole_countersink": {"atomic_id":"hole_countersink","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"countersink_diameter_mm":{"type":"number","exclusiveMinimum":0},"countersink_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","countersink_diameter_mm","countersink_angle_rad"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]}, "hole_counterbore": {"atomic_id":"hole_counterbore","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"counterbore_diameter_mm":{"type":"number","exclusiveMinimum":0},"counterbore_depth_mm":{"type":"number","exclusiveMinimum":0},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","counterbore_diameter_mm","counterbore_depth_mm"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]}, @@ -55,10 +60,12 @@ "candidate_verifiers": ["cylindrical_bore"] }, "fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]}, - "chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]}, + "chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"tangent_propagation":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]}, + "shell": {"atomic_id":"shell","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"inward":{"type":"boolean"}},"required":["thickness_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","selected_faces_exist"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, + "boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"operation":{"enum":["union","subtract","intersect"]},"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"}},"required":["operation","target_feature_ids","tool_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[]}, "pattern_linear": {"atomic_id":"pattern_linear","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"direction_1":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_1_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_1":{"type":"integer","minimum":1,"maximum":128},"direction_2":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_2_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_2":{"type":"integer","minimum":1,"maximum":128}},"required":["source_feature_ids","direction_1","spacing_1_mm","pattern_count_1"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]}, - "pattern_mirror": {"atomic_id":"pattern_mirror","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true}},"required":["source_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":"params.mirror_plane","token_kind":"plane","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.mirror_plane"],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist","mirror_plane_exists"],"candidate_verifiers":[]}, - "pattern_circular": {"atomic_id":"pattern_circular","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pattern_count":{"type":"integer","minimum":1,"maximum":128},"sweep_angle_deg":{"type":"number","minimum":-360,"maximum":360}},"required":["source_feature_ids","axis","pattern_count"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]} + "pattern_mirror": {"atomic_id":"pattern_mirror","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"mirror_current_body":{"type":"boolean"}},"required":["source_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":"params.mirror_plane","token_kind":"plane","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.mirror_plane"],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist","mirror_plane_exists"],"candidate_verifiers":[]}, + "pattern_circular": {"atomic_id":"pattern_circular","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pattern_count":{"type":"integer","minimum":1,"maximum":128},"sweep_angle_deg":{"type":"number","minimum":-360,"maximum":360},"operation_mode":{"enum":["add","remove"]},"excluded_instance_indices":{"type":"array","items":{"type":"integer","minimum":1,"maximum":127},"uniqueItems":true}},"required":["source_feature_ids","axis","pattern_count"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]} }, "profiles": { "circle": { @@ -73,7 +80,7 @@ "constraints": ["vertices contains at least three [u, v] points"] }, "analytic_contours": { - "summary": "Closed executable line, arc, circle and interpolation B-spline contours. Executable B-splines preserve ordered interpolation points and must be closed; imported construction B-splines remain non-executable audit geometry." + "summary": "Closed executable line, arc, circle, ellipse and interpolation B-spline contours. An ellipse retains its local center, radii and major-axis direction. Executable B-splines preserve ordered interpolation points; periodic contours repeat their first point only to state closure and are interpolated from unique points. B-splines may explicitly use chord or centripetal parameterization; CADFS closed skFitSpline uses centripetal. Imported construction B-splines remain non-executable audit geometry." } } } diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index 3118a5a6..a7e8d9ab 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -20,13 +20,13 @@ from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches ALL_ATOMIC_IDS = frozenset({ - "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", - "extrude_cut_through", "loft_add", - "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", + "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface", + "extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", + "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "reference_plane", "reference_axis", - "hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror", - "pattern_circular", + "hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror", + "pattern_circular", "boolean_bodies", "thread_add", "thread_cut", }) @@ -81,12 +81,24 @@ class GeometryAdapter(Protocol): def topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ... def body_solids(self, body: Any) -> list[Any]: ... def body_geometry(self, body: Any) -> dict[str, Any]: ... + def surface_geometry(self, surface: Any) -> dict[str, Any]: ... def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ... + def face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ... def loft(self, sketches: list[dict[str, Any]]) -> Any: ... + def loft_with_cap_face(self, cap_face: Any, sketches: list[dict[str, Any]]) -> Any: ... + def sweep(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> Any: ... + def sweep_path(self, points: list[Vector3], *, start_tangent: Vector3 | None = None, end_tangent: Vector3 | None = None, parameters: list[float] | None = None) -> Any: ... def extrude(self, face: Any, direction: Vector3) -> Any: ... + def extrude_taper(self, face: Any, direction: Vector3, taper_deg: float) -> Any: ... def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ... + def surface_wires_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ... + def extrude_surface(self, wires: list[Any], direction: Vector3) -> Any: ... + def combine_surfaces(self, *surfaces: Any) -> Any: ... def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ... + def revolve_surface(self, wire: Any, angle_deg: float, axis: AxisSpec) -> Any: ... + def intersect(self, left: Any, right: Any) -> Any: ... def fuse(self, body: Any | None, solid: Any) -> Any: ... + def combine(self, body: Any | None, solid: Any) -> Any: ... def cut(self, body: Any, tool: Any) -> Any: ... def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ... def thread_solid(self, spec: ThreadSpec) -> Any: ... @@ -94,11 +106,16 @@ class GeometryAdapter(Protocol): def body_center(self, body: Any) -> Vector3: ... def body_span(self, body: Any, direction: Vector3) -> float: ... def vertex_coordinates(self, vertex: Any) -> Vector3: ... + def intersection_vertex(self, body: Any, face_sets: list[list[Any]]) -> Any: ... def profile_sample_points(self, face: Any) -> list[Any]: ... + def profile_touches_target(self, target: Any, faces: list[Any]) -> bool: ... + def next_body_face_after(self, body: Any, faces: list[Any], direction: Vector3, *, excluded_face: Any) -> Any: ... def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ... def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ... def tangent_edges(self, body: Any, seeds: list[Any]) -> list[Any]: ... def chamfer(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> Any: ... + def surface_limited_chamfer(self, body: Any, distance_mm: float, edges: list[Any], surfaces: list[Any]) -> Any: ... + def shell(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> Any: ... def export(self, body: Any, path: str) -> None: ... @@ -112,27 +129,39 @@ class ExecutionSession: body_id: str | None = None results: dict[str, FeatureResult] = field(default_factory=dict) replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict) + body_members: dict[str, Any] = field(default_factory=dict) + surface_members: dict[str, Any] = field(default_factory=dict) selector_resolutions: list[dict[str, Any]] = field(default_factory=list) active_feature_id: str = "" - def register_body(self, feature_id: str, body: Any, *, replay_node: FeaturePlanNode | None = None) -> None: + def register_body( + self, + feature_id: str, + body: Any, + *, + replay_node: FeaturePlanNode | None = None, + body_members: dict[str, Any] | None = None, + ) -> None: # #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的 # 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id": # 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标 # 实体;单体保持 body:{feature}(与历史行为完全一致)。 self.body = body self.body_id = f"body:{feature_id}" + self.body_members = dict(body_members) if body_members is not None else {feature_id: body} solids = self.adapter.body_solids(body) if len(solids) <= 1: self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id)) else: - for index, solid in enumerate(solids): - member_id = f"{self.body_id}:{index}" - self.topology.replace_body_topology( - feature_id, member_id, - self.adapter.topology_records(solid, feature_id, member_id), - active_body_id=self.body_id, - ) + # 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让 + # 已登记的本轮成员成为下一个成员的 predecessor,进而把 pattern copy + # 的 owner 错误转移到相邻实例。必须原子替换整个多 body 拓扑快照。 + members = [ + (member_id, self.adapter.topology_records(solid, feature_id, member_id)) + for index, solid in enumerate(solids) + for member_id in [f"{self.body_id}:{index}"] + ] + self.topology.replace_body_topologies(feature_id, members, active_body_id=self.body_id) self.topology.register(TopologyRecord( record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id, geometry=self.adapter.body_geometry(body), value=body, owner_feature_ids=(feature_id,), @@ -140,16 +169,99 @@ class ExecutionSession: if replay_node is not None: self.replay_definitions[feature_id] = replay_node - def resolve(self, selector: dict[str, Any]) -> SelectorResolution: - resolution = self.topology.resolve(selector, active_body_id=self.body_id) + def register_surface(self, feature_id: str, surface: Any) -> str: + # 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body, + # 否则 surface 会覆盖 active solid 并改变最终 STEP 的实体结果。 + surface_id = f"surface:{feature_id}" + self.surface_members[feature_id] = surface + for record in self.adapter.topology_records(surface, feature_id, surface_id): + self.topology.register(record) + self.topology.register(TopologyRecord( + record_id=surface_id, kind="surface", feature_id=feature_id, body_id=surface_id, + geometry=self.adapter.surface_geometry(surface), value=surface, owner_feature_ids=(feature_id,), + )) + return surface_id + + def _record_selector_resolution(self, resolution: SelectorResolution) -> SelectorResolution: evidence = resolution.as_dict() evidence["feature_id"] = self.active_feature_id self.selector_resolutions.append(evidence) return resolution - def result(self, node: FeaturePlanNode, *, context: PlaneSpec | AxisSpec | None = None, diagnostics: list[RuntimeDiagnostic] | None = None) -> FeatureResult: + def _intersection_component_records(self, selector: dict[str, Any]) -> list[TopologyRecord]: + matched = selector.get("matched_selectors") if selector.get("match_mode") == "all" else None + if matched is not None: + if not isinstance(matched, list) or not matched: + raise FeatureExecutionError("intersection_selector_unbound", "Intersection selector has no bound face matches") + resolved = [self._record_selector_resolution(self.topology.resolve(item, active_body_id=self.body_id)) for item in matched] + else: + binding_feature_id = selector.get("binding_feature_id") + active_body_id = None if binding_feature_id and self.body_id != f"body:{binding_feature_id}" else self.body_id + resolved = [self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))] + failures = [item for item in resolved if item.status != "resolved" or item.record is None] + if failures: + detail = failures[0].diagnostic.message if failures[0].diagnostic else "intersection selector component was not resolved" + raise FeatureExecutionError("intersection_selector_component_unresolved", detail) + return [item.record for item in resolved if item.record is not None] + + def _resolve_intersection_vertex(self, selector: dict[str, Any]) -> SelectorResolution: + components = selector.get("intersection_of") + if self.body is None: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic("missing_extent_body", "Intersection selector requires an existing body"), + ) + if not isinstance(components, list) or len(components) < 2: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic("intersection_selector_incomplete", "Intersection selector requires at least two face components"), + ) + try: + face_sets = [self._intersection_component_records(component) for component in components] + if any(record.kind != "face" for records in face_sets for record in records): + raise FeatureExecutionError("intersection_selector_kind", "Intersection selector components must resolve to faces") + vertex = self.adapter.intersection_vertex(self.body, [[record.value for record in records] for records in face_sets]) + except FeatureExecutionError as error: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic(error.code, str(error), detail=error.detail), + ) + except ValueError as error: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic("intersection_vertex_unresolved", str(error)), + ) + point = self.adapter.vertex_coordinates(vertex) + record = TopologyRecord( + record_id=str(selector.get("stable_id") or f"intersection:{id(vertex)}"), + kind="vertex", feature_id=self.active_feature_id, body_id=self.body_id, + geometry={"center_mm": list(point)}, value=vertex, + owner_feature_ids=tuple(filter(None, [str(selector.get("owner_feature_id") or "")])), + ) + return SelectorResolution( + selector=selector, status="resolved", record=record, + candidates=({"score": 1.0, **record.public_dict()},), + ) + + def resolve(self, selector: dict[str, Any]) -> SelectorResolution: + if selector.get("intersection_of") is not None: + return self._record_selector_resolution(self._resolve_intersection_vertex(selector)) + owner = str(selector.get("owner_feature_id") or "") + active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id + return self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id)) + + def result( + self, + node: FeaturePlanNode, + *, + context: PlaneSpec | AxisSpec | None = None, + diagnostics: list[RuntimeDiagnostic] | None = None, + include_body: bool = True, + surface_id: str | None = None, + ) -> FeatureResult: result = FeatureResult( - feature_id=node.feature_id, atomic_id=node.atomic_id, status="executed", body_id=self.body_id, + feature_id=node.feature_id, atomic_id=node.atomic_id, status="executed", + body_id=self.body_id if include_body else None, surface_id=surface_id, context=context, replay_definition={"atomic_id": node.atomic_id, "params": deepcopy(node.params), "sketch_id": node.sketch_id}, diagnostics=diagnostics or [], ) @@ -232,6 +344,17 @@ def _targeted_extent_vector( raise FeatureExecutionError("non_uniform_extent_target", "The target vertex does not define one extrusion distance", extent=condition) distance = sum(projections) / len(projections) else: + if condition == "up_to_surface" and session.adapter.profile_touches_target(target, faces): + # 草图轮廓本身就在所选终止面上时,selected face 只是拉伸的起始 + # 边界。应沿实际拉伸方向穿过当前 body,取下一张完整截获 profile + # 的边界面作为终止面;直接裁剪到 selected face 会生成零厚度工具体。 + try: + next_face = session.adapter.next_body_face_after( + session.body, faces, direction, excluded_face=target, + ) + except ValueError as error: + raise FeatureExecutionError("extent_target_not_reached", str(error), extent=condition) from error + return ExtentVector(vector_scale(direction, 1.0), trim_to=next_face) try: distance = session.adapter.uniform_intersection_distance(target, faces, direction) except ValueError as error: @@ -250,13 +373,15 @@ def _targeted_extent_vector( # 裁剪体层,仍保持显式拒绝。 return ExtentVector(vector_scale(direction, 1.0), trim_to=target) raise FeatureExecutionError(code, message, extent=condition) from error + offset = abs(float((end_condition or {}).get("offset_mm") or 0.0)) if condition == "offset_from_surface": - offset = abs(float(offset_mm if offset_mm is not None else node.params.get("distance_mm") or 0.0)) + offset = abs(float(offset_mm if offset_mm is not None else offset or node.params.get("distance_mm") or 0.0)) + if offset: distance -= offset if distance <= 1e-6: raise FeatureExecutionError( "invalid_extent_offset", - "Offset distance reaches or passes the target surface", + "Offset distance reaches or passes the target extent", extent=condition, offset_mm=offset, ) return ExtentVector(vector_scale(direction, distance)) @@ -410,6 +535,14 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske faces = session.adapter.faces_for_sketch(selected_sketch) if not faces: raise ValueError("sketch does not create a closed profile region") + if node.atomic_id == "extrude_add_blind_with_hole": + resolved = [session.resolve(selector) for selector in node.selectors] + failed = next((item for item in resolved if item.status != "resolved"), None) + if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face": + raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "profile hole selector is unresolved") + if len(faces) != 1: + raise ValueError("profile hole extrusion requires exactly one outer sketch region") + faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])] # 3. 按特征类型生成子实体: if node.atomic_id.startswith("extrude_"): # 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量, @@ -417,10 +550,18 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske # profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪 # 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。 extents = _extent_vectors(node, faces, selected_sketch, session) + draft = node.params.get("draft") + taper_deg = 0.0 + if isinstance(draft, dict): + taper_deg = float(draft["angle_deg"]) + if not bool(draft["pull_direction"]): + taper_deg = -taper_deg solids: list[Any] = [] for face in faces: for extent in extents: - if extent.trim_to is None: + if draft is not None: + solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg)) + elif extent.trim_to is None: solids.append(session.adapter.extrude(face, extent.vector)) else: solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector)) @@ -451,11 +592,106 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske if session.body is None: raise ValueError("cut feature has no body") body = session.adapter.cut(session.body, tool) + members = {node.feature_id: body} + elif node.params.get("result_mode") == "new_body": + # FeatureScript NEW creates an independent result body even when it + # intersects a prior body. Keep both shapes in the exported compound. + body = session.adapter.combine(session.body, tool) + members = {**session.body_members, node.feature_id: tool} else: # 添加类特征:将工具体并到当前主体上(fuse),首个特征时 body 为 None 也能直接成立。 body = session.adapter.fuse(session.body, tool) + members = {node.feature_id: body} # 6. 登记新主体(更新拓扑、记录重放定义),并返回该特征的结果对象。 - session.register_body(node.feature_id, body, replay_node=node) + session.register_body(node.feature_id, body, replay_node=node, body_members=members) + return session.result(node) + + +def _execute_revolve_surface(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: + # Surface revolve 的 profile 是单一闭合 wire。它只生成独立 shell,不能参与 + # 当前实体 body 的 fuse/cut,也不能把其结果误报为新的实体 body。 + sketch = session.sketches.get(str(node.sketch_id)) + if sketch is None: + raise ValueError("surface revolve has no resolved sketch") + faces = session.adapter.faces_for_sketch(sketch) + if len(faces) != 1 or faces[0].inner_wires(): + raise ValueError("surface revolve requires exactly one closed profile without holes") + axis = _revolve_axis(node, session) + _validate_revolve_axis_in_sketch_plane(axis, sketch) + angle = float(node.params.get("angle_deg") or 0.0) + if angle <= 0: + raise ValueError("surface revolve requires angle_deg > 0") + if bool(node.params.get("reverse")): + angle = -angle + surface_id = session.register_surface( + node.feature_id, + session.adapter.revolve_surface(faces[0].outer_wire(), angle, axis), + ) + return session.result(node, include_body=False, surface_id=surface_id) + + +def _execute_extrude_surface(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: + # surfaceEntities 的曲面拉伸沿用实体特征已 lower 的距离,但始终独立登记为 + # shell。它既不改变 active solid,也不以曲面参与实体 fuse/cut。 + sketch = session.sketches.get(str(node.sketch_id)) + if sketch is None: + raise ValueError("surface extrude has no resolved sketch") + direction = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal") + if bool(node.params.get("reverse")): + direction = vector_scale(direction, -1) + distance = float(node.params.get("distance_mm") or 0.0) + if distance <= 0: + raise ValueError("surface extrude requires distance_mm > 0") + wires = session.adapter.surface_wires_for_sketch(sketch) + surface = session.adapter.extrude_surface(wires, vector_scale(direction, distance)) + reverse_distance = float(node.params.get("reverse_distance_mm") or 0.0) + if reverse_distance > 0: + opposite = session.adapter.extrude_surface(wires, vector_scale(direction, -reverse_distance)) + surface = session.adapter.combine_surfaces(surface, opposite) + surface_id = session.register_surface(node.feature_id, surface) + return session.result(node, include_body=False, surface_id=surface_id) + + +def _combine_members(session: ExecutionSession, members: dict[str, Any]) -> Any: + body = None + for member in members.values(): + body = session.adapter.combine(body, member) + if body is None: + raise ValueError("booleanBodies produced no result bodies") + return body + + +def _execute_boolean_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: + # booleanBodies 总是作用于 source feature 的明确 body 输出,不能回退为 + # 当前聚合 body。这样相邻独立实体不会意外成为工具或目标。 + params = node.params + target_ids = [str(value) for value in params.get("target_feature_ids") or []] + tool_ids = [str(value) for value in params.get("tool_feature_ids") or []] + missing = [feature_id for feature_id in target_ids + tool_ids if feature_id not in session.body_members] + if missing: + raise ValueError("booleanBodies source bodies are unavailable: " + ", ".join(missing)) + targets = {feature_id: session.body_members[feature_id] for feature_id in target_ids} + tools = {feature_id: session.body_members[feature_id] for feature_id in tool_ids} + target = _combine_members(session, targets) + tool = _combine_members(session, tools) + operation = str(params.get("operation") or "") + if operation == "union": + result = session.adapter.fuse(target, tool) + elif operation == "subtract": + result = session.adapter.cut(target, tool) + elif operation == "intersect": + result = session.adapter.intersect(target, tool) + else: + raise ValueError(f"unsupported booleanBodies operation {operation!r}") + members = { + feature_id: body + for feature_id, body in session.body_members.items() + if feature_id not in set(target_ids + tool_ids) + } + members[node.feature_id] = result + if bool(params.get("keep_tools")): + members.update(tools) + session.register_body(node.feature_id, _combine_members(session, members), body_members=members) return session.result(node) @@ -474,6 +710,79 @@ def _execute_loft_add(node: FeaturePlanNode, session: ExecutionSession) -> Featu return session.result(node) +def _execute_loft_add_with_cap_face(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: + resolved = [session.resolve(selector) for selector in node.selectors] + failed = next((item for item in resolved if item.status != "resolved"), None) + if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face": + raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "cap-face loft selector is unresolved") + profile_ids = node.params.get("profile_sketch_ids") or [] + profiles: list[dict[str, Any]] = [] + for sketch_id in profile_ids: + sketch = session.sketches.get(str(sketch_id)) + if sketch is None: + raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved") + profiles.append(sketch) + solid = session.adapter.loft_with_cap_face(resolved[0].record.value, profiles) + session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) + return session.result(node) + + +def _sweep_path(node: FeaturePlanNode, session: ExecutionSession) -> Any: + # 路径是 self-contained CDSL 数据,避免重放时依赖临时草图或 source id。 + path = node.params.get("path") or {} + if not isinstance(path, dict): + raise ValueError("sweep path must be an object") + plane = PlaneSpec.from_mapping(path.get("workplane") or {}) + segment = path.get("segment") or {} + if not isinstance(segment, dict): + raise ValueError("sweep path segment must be an object") + kind = str(segment.get("type") or "") + if kind == "line": + local_points = [segment.get("start"), segment.get("end")] + elif kind == "bspline": + local_points = segment.get("points") or [] + else: + raise ValueError(f"unsupported sweep path segment {kind!r}") + if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points): + raise ValueError("sweep path requires two-dimensional points") + + def point(value: list[float]) -> Vector3: + return vector_add( + plane.origin_mm, + vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))), + ) + + def tangent(value: Any) -> Vector3 | None: + if value is None: + return None + if not isinstance(value, list) or len(value) != 2: + raise ValueError("sweep path tangent must contain two coordinates") + return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))) + + return session.adapter.sweep_path( + [point(value) for value in local_points], + start_tangent=tangent(segment.get("start_tangent")), + end_tangent=tangent(segment.get("end_tangent")), + parameters=[float(value) for value in segment.get("parameters") or []] or None, + ) + + +def _execute_sweep_add(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None = None) -> FeatureResult: + profile = sketch or session.sketches.get(str(node.sketch_id)) + if profile is None: + raise ValueError("sweep has no resolved profile sketch") + faces = session.adapter.faces_for_sketch(profile) + if len(faces) != 1: + raise ValueError("sweep requires exactly one closed profile region") + solid = session.adapter.sweep( + faces[0], _sweep_path(node, session), + is_frenet=bool(node.params.get("is_frenet", False)), + ) + body = session.adapter.combine(session.body, solid) if node.params.get("result_mode") == "new_body" else session.adapter.fuse(session.body, solid) + session.register_body(node.feature_id, body, replay_node=node) + return session.result(node) + + def _execute_reference_plane(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # 基准面特征(reference_plane)执行入口:从参数解析平面并登记为拓扑上下文。 @@ -708,17 +1017,93 @@ def _selector_edges(node: FeaturePlanNode, session: ExecutionSession, *, tangent failed = next((item for item in resolved if item.status != "resolved"), None) if failed: raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") + + def is_body_boundary(edge: Any) -> bool: + # 圆柱、圆锥等周期面会带一条仅属于自身的参数 seam。该线不是实体 + # 边界;FeatureScript 以 FACE 选择倒角时不应将其当作额外的待倒角边, + # 否则连续的锥面会被错误切成两段。显式 EDGE selector 仍可表达真正的 + # 单边选择,所以这里只约束由 FACE 展开的候选边。 + face_count = sum( + 1 + for face in session.body.faces() + if any(candidate.is_same(edge) for candidate in face.edges()) + ) + return face_count >= 2 + edges: list[Any] = [] for item in resolved: if item.record.kind == "edge": edges.append(item.record.value) elif item.record.kind == "face": - edges.extend(item.record.value.edges()) + edges.extend(edge for edge in item.record.value.edges() if is_body_boundary(edge)) if not edges: raise ValueError("selectors did not resolve any edges") return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges +def _shell_target(node: FeaturePlanNode, session: ExecutionSession) -> tuple[Any, list[Any]]: + # shell 的 remove-face selector 必须全部属于同一实体。CADFS 允许一个 + # Compound 中保留多个独立 body,不能将整组 body 交给 OCC 后由内核猜测 + # 应抽壳的成员。 + resolved = [session.resolve(selector) for selector in node.selectors] + failed = next((item for item in resolved if item.status != "resolved"), None) + if failed: + raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") + records = [item.record for item in resolved if item.record is not None] + if not records or any(record.kind != "face" for record in records): + raise ValueError("shell selectors must resolve to faces") + target_ids = {record.body_id for record in records} + if len(target_ids) != 1: + raise ValueError("shell faces must belong to one target body") + target_id = next(iter(target_ids)) + members = session.adapter.body_solids(session.body) + if len(members) == 1: + return members[0], [record.value for record in records] + if target_id is None or session.body_id is None: + raise ValueError("shell target body is unresolved") + prefix = f"{session.body_id}:" + if not target_id.startswith(prefix): + raise ValueError("shell target body is outside the active body set") + try: + member_index = int(target_id[len(prefix):]) + except ValueError as error: + raise ValueError("shell target body has an invalid member id") from error + if member_index < 0 or member_index >= len(members): + raise ValueError("shell target body member is unavailable") + return members[member_index], [record.value for record in records] + + +def _replace_shell_target(session: ExecutionSession, target: Any, replacement: Any) -> Any: + # 仅替换抽壳目标实体;其他独立实体保持原样和原有相对顺序。 + members = session.adapter.body_solids(session.body) + if len(members) == 1: + return replacement + replaced = False + result = None + for member in members: + if member.is_same(target): + result = session.adapter.combine(result, replacement) + replaced = True + else: + result = session.adapter.combine(result, member) + if not replaced or result is None: + raise ValueError("shell target solid is no longer part of the active body") + return result + + +def _execute_shell(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: + # 抽壳特征:移除 selector 所指面,并按 CADFS thickness 向实体内部偏置。 + if session.body is None: + raise ValueError("shell has no body") + thickness = float(node.params.get("thickness_mm") or 0) + if thickness <= 0: + raise ValueError("shell thickness_mm must be > 0") + target, faces = _shell_target(node, session) + result = session.adapter.shell(target, faces, thickness, inward=bool(node.params.get("inward", True))) + session.register_body(node.feature_id, _replace_shell_target(session, target, result), replay_node=node) + return session.result(node) + + def _execute_fillet(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。 @@ -758,13 +1143,31 @@ def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> Featur if distance_2 is None and angle_rad is not None: distance_2 = distance * math.tan(float(angle_rad)) # 4. 解析目标边(支持相切传播),执行倒角。 - body = session.adapter.chamfer( - session.body, distance, distance_2, - _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))), - ) + edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))) + diagnostics: list[RuntimeDiagnostic] = [] + try: + body = session.adapter.chamfer(session.body, distance, distance_2, edges) + except ValueError as error: + # 显式 surfaceEntities 可以在后续实体上留下曲面分区边界。若标准 + # OCC 倒角因环域宽度不足而拒绝,只允许在该 shell 给出同轴边界证据 + # 时按原始距离构造受限倒角;没有证明时仍保留原始内核失败。 + if distance_2 is not None or not session.surface_members: + raise + try: + body = session.adapter.surface_limited_chamfer( + session.body, distance, edges, list(session.surface_members.values()), + ) + except ValueError: + raise error + diagnostics.append(RuntimeDiagnostic( + "chamfer_surface_limited", + "Chamfer was limited by an explicit coaxial surface boundary", + feature_id=node.feature_id, + detail={"distance_mm": distance, "surface_count": len(session.surface_members)}, + )) # 5. 登记新主体并返回结果。 session.register_body(node.feature_id, body, replay_node=node) - return session.result(node) + return session.result(node, diagnostics=diagnostics) def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any]: @@ -1111,6 +1514,15 @@ def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) -> resolution = session.resolve(mirror) if resolution.status != "resolved" or not isinstance(resolution.record.value, PlaneSpec): raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "mirror plane was not resolved") + if node.params.get("mirror_current_body"): + # CADFS SWEPT_BODY 表示被后续 feature 持续修改的同一实体。这里复制 + # 当前 B-rep 再镜像并合并,不能重放其初始 additive feature,否则会 + # 丢失后续 cut/fillet 并生成独立错误实体。 + if session.body is None: + raise ValueError("mirror current body has no active body") + mirrored = session.adapter.mirror(session.body, resolution.record.value) + session.register_body(node.feature_id, session.adapter.fuse(session.body, mirrored), replay_node=node) + return session.result(node) sources = session.replay_sources(node.params.get("source_feature_ids") or []) if not sources: raise ValueError("mirror pattern source features have no replay definitions") @@ -1210,6 +1622,14 @@ def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle plane[key] = _rotated_point(plane[key], axis, angle_rad) else: plane[key] = list(_rotated_vector(tuple(float(v) for v in plane[key]), axis, angle_rad)) + path = params.get("path") + path_plane = path.get("workplane") if isinstance(path, dict) else None + if isinstance(path_plane, dict): + if path_plane.get("origin_mm"): + path_plane["origin_mm"] = _rotated_point(path_plane["origin_mm"], axis, angle_rad) + for key in ("x_dir", "y_dir", "normal"): + if path_plane.get(key): + path_plane[key] = list(_rotated_vector(tuple(float(v) for v in path_plane[key]), axis, angle_rad)) host = params.get("host_face") host_frame = host.get("frame") if isinstance(host, dict) else None positions_are_local = isinstance(host_frame, dict) and all( @@ -1280,6 +1700,25 @@ def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature) +def _pattern_operation_node(node: FeaturePlanNode, operation_mode: str) -> FeaturePlanNode: + # REMOVE pattern 的实例必须沿用 source 的 profile/extent,但以 cut 而不是 + # add 写入当前主体。lowering 已将初始 source 同步改写,运行时保留此处以 + # 支持完整的 CDSL replay contract。 + if operation_mode != "remove": return node + atomic_id = { + "extrude_add_blind": "extrude_cut_blind", + "extrude_add_two_sided": "extrude_cut_two_sided", + "revolve_add": "revolve_cut", + }.get(node.atomic_id, node.atomic_id) + if atomic_id == node.atomic_id and "cut" not in atomic_id: + raise ValueError("REMOVE pattern source is not a replayable cutting feature") + params = {key: value for key, value in node.params.items() if key != "result_mode"} + return FeaturePlanNode( + node.feature_id, atomic_id, node.name, node.depends_on, params, + node.selectors, node.sketch_id, node.declared_status, node.source_feature, + ) + + def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult: # 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征 # 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。 @@ -1292,10 +1731,18 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, if count < 1: raise ValueError("circular pattern pattern_count must be >= 1") sweep_angle_deg = float(params.get("sweep_angle_deg") or 360.0) + operation_mode = str(params.get("operation_mode") or "add") + if operation_mode not in {"add", "remove"}: + raise ValueError("circular pattern operation_mode must be add or remove") + excluded = {int(value) for value in params.get("excluded_instance_indices") or []} + if any(instance < 1 or instance >= count for instance in excluded): + raise ValueError("circular pattern excluded instance is outside the generated range") sources = session.replay_sources(params.get("source_feature_ids") or []) if not sources: raise ValueError("circular pattern source features have no replay definitions") for instance in range(1, count): + if instance in excluded: + continue # 实例 i 位于包角 sweep_angle_deg 的 i/count 处(i=0 即源特征本身)。 angle_deg = sweep_angle_deg * instance / count angle_rad = math.radians(angle_deg) @@ -1309,10 +1756,25 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, "by multiples of 180 degrees" ) cloned = _rotated_node(source, f"{node.feature_id}.c{instance}.{source.feature_id}", axis, angle_rad, session) + cloned = _pattern_operation_node(cloned, operation_mode) + # CADFS pattern instances are copies of the source result, not + # independent `NEW` operations. Replay them through normal add + # semantics: intersecting or face-sharing instances fuse, while + # spatially separate copies remain separate solids in the result. + if cloned.params.get("result_mode") == "new_body": + cloned = FeaturePlanNode( + cloned.feature_id, cloned.atomic_id, cloned.name, cloned.depends_on, + {key: value for key, value in cloned.params.items() if key != "result_mode"}, + cloned.selectors, cloned.sketch_id, cloned.declared_status, cloned.source_feature, + ) sketch = session.sketches.get(str(source.sketch_id)) execute(cloned, session, _rotated_sketch(sketch, axis, angle_rad) if sketch else None) - # 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放。 - session.replay_definitions[node.feature_id] = node + # 环形阵列本身是完整 B-rep 结果的 producer。每个 replay 子特征都会更新 + # active body;循环结束后必须用 pattern feature 重新登记最终快照,否则后续 + # selector binding 会只保留最后一个实例的 body id,漏掉其它 COPY 实例。 + if session.body is None: + raise ValueError("circular pattern produced no body") + session.register_body(node.feature_id, session.body, replay_node=node) return session.result(node) @@ -1351,11 +1813,30 @@ def _primary_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: return _shape_from_primary(node, session, sketch=sketch) +def _revolve_surface_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_revolve_surface(node, session) + + +def _extrude_surface_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_extrude_surface(node, session) + + def _loft_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_loft_add(node, session) +def _loft_cap_face_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_loft_add_with_cap_face(node, session) + + +def _sweep_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: + return _execute_sweep_add(node, session, sketch) + + def _reference_plane_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_reference_plane(node, session) @@ -1401,6 +1882,11 @@ def _chamfer_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: return _execute_chamfer(node, session) +def _shell_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_shell(node, session) + + def _linear_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_linear_pattern(node, session, _execute_node) @@ -1420,19 +1906,26 @@ EXECUTORS: dict[str, ExecutorFunction] = { "thread_add": _thread_executor, "thread_cut": _thread_executor, "extrude_add_blind": _primary_executor, + "extrude_add_blind_with_hole": _primary_executor, "extrude_add_two_sided": _primary_executor, "extrude_cut_blind": _primary_executor, "extrude_cut_two_sided": _primary_executor, "extrude_cut_through": _primary_executor, "loft_add": _loft_executor, + "loft_add_with_cap_face": _loft_cap_face_executor, + "sweep_add": _sweep_executor, "revolve_add": _primary_executor, "revolve_cut": _primary_executor, + "revolve_surface": _revolve_surface_executor, + "extrude_surface": _extrude_surface_executor, "hole_blind": _hole_executor, "hole_countersink": _hole_executor, "hole_counterbore": _hole_executor, "hole_wizard": _hole_wizard_executor, "fillet": _fillet_executor, "chamfer": _chamfer_executor, + "shell": _shell_executor, + "boolean_bodies": lambda node, session, sketch: _execute_boolean_bodies(node, session), "pattern_linear": _linear_pattern_executor, "pattern_mirror": _mirror_pattern_executor, "pattern_circular": _circular_pattern_executor, @@ -1497,20 +1990,33 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) - diagnostics.append(diagnostic) if strict: raise RuntimeExecutionError(diagnostic, list(session.selector_resolutions)) from error - if session.body is None: - raise ValueError("CDSL execution produced no body") + output = session.body + surface_geometry: dict[str, Any] | None = None + if output is None: + if not session.surface_members: + raise ValueError("CDSL execution produced no body") + # 纯曲面文档没有 active solid,但依然是可执行的 CAD 结果。只有在 + # 没有实体时才将 surface members 作为 STEP 输出,混合模型继续只导出 + # 实体,避免曲面意外改变既有实体比较和下游消费语义。 + output = session.adapter.combine_surfaces(*session.surface_members.values()) + surface_geometry = session.adapter.surface_geometry(output) out_step.parent.mkdir(parents=True, exist_ok=True) - session.adapter.export(session.body, str(out_step)) - geometry = session.adapter.body_geometry(session.body) + session.adapter.export(output, str(out_step)) + geometry = session.adapter.body_geometry(session.body) if session.body is not None else surface_geometry + if geometry is None: + raise ValueError("CDSL execution produced no exportable geometry") bbox = geometry["bbox_mm"] return { "engine": "cdsl_session_runtime", "out_step": str(out_step), - "volume_mm3": float(geometry["volume_mm3"]), + "volume_mm3": float(geometry.get("volume_mm3") or 0.0), "bbox_mm": {"min": bbox[:3], "max": bbox[3:]}, # #7 multi-body:重建结果里的独立实体数(Compound 成员数), # 与 batch 验证的 document_truth.geometry.solid_body_count 对齐。 - "solid_count": len(session.adapter.body_solids(session.body)), + "solid_count": len(session.adapter.body_solids(session.body)) if session.body is not None else 0, + "surface_count": len(session.surface_members), + "surface_face_count": int(surface_geometry["face_count"]) if surface_geometry is not None else 0, + "surface_area_mm2": float(surface_geometry["area_mm2"]) if surface_geometry is not None else 0.0, "feature_results": [result.as_dict() for result in session.results.values()], "runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics], "topology_records": [record.public_dict() for record in session.topology.records()], diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index a43eb976..d73b705a 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -412,6 +412,7 @@ class FeatureResult: atomic_id: str status: str body_id: str | None = None + surface_id: str | None = None context: PlaneSpec | AxisSpec | None = None replay_definition: dict[str, Any] | None = None diagnostics: list[RuntimeDiagnostic] = field(default_factory=list) @@ -425,6 +426,8 @@ class FeatureResult: } if self.body_id is not None: output["body_id"] = self.body_id + if self.surface_id is not None: + output["surface_id"] = self.surface_id if self.context is not None: output["context"] = self.context.as_dict() if self.replay_definition is not None: @@ -536,6 +539,12 @@ class TopologyRegistry: def replace_body_topology( self, feature_id: str, body_id: str, records: Iterable[TopologyRecord], *, active_body_id: str | None = None, + ) -> None: + self.replace_body_topologies(feature_id, [(body_id, records)], active_body_id=active_body_id) + + def replace_body_topologies( + self, feature_id: str, bodies: Iterable[tuple[str, Iterable[TopologyRecord]]], + *, active_body_id: str | None = None, ) -> None: """Record a fresh B-rep snapshot after a feature mutates the body. @@ -559,15 +568,31 @@ class TopologyRegistry: or record.body_id.startswith(f"{self._active_body_id}:") ) ] - records = list(records) + # 同一 source feature 的 pattern copy 可以产生完全相同的几何面。它们 + # 必须保留为多个实例,不能在跨 body 的全局 predecessor 匹配中互相消费。 + # pattern 的 Compound 成员顺序是稳定的:已有实例以同一 member index + # 延续,新增实例只会出现在末尾。按该 index 限定后继匹配。 + current = [(body_id, list(records)) for body_id, records in bodies] + previous_member_ids = { + suffix for record in previous + for suffix in [str(record.body_id).rsplit(":", 1)[-1]] + if suffix.isdigit() + } + use_member_indexes = len(current) > 1 and previous_member_ids consumed_predecessors: set[str] = set() - for record in records: - predecessor = self._unique_equivalent_predecessor(record, previous, consumed_predecessors) - owners = predecessor.owners if predecessor is not None else (feature_id,) - if predecessor is not None: - consumed_predecessors.add(predecessor.record_id) - self.register( - TopologyRecord( + registered: list[TopologyRecord] = [] + for body_id, records in current: + member_id = str(body_id).rsplit(":", 1)[-1] + local_predecessors = [ + record for record in previous + if not use_member_indexes or str(record.body_id).rsplit(":", 1)[-1] == member_id + ] + for record in records: + predecessor = self._unique_equivalent_predecessor(record, local_predecessors, consumed_predecessors) + owners = predecessor.owners if predecessor is not None else (feature_id,) + if predecessor is not None: + consumed_predecessors.add(predecessor.record_id) + registered.append(TopologyRecord( record_id=record.record_id, kind=record.kind, feature_id=feature_id, @@ -575,8 +600,9 @@ class TopologyRegistry: geometry=dict(record.geometry), value=record.value, owner_feature_ids=owners, - ) - ) + )) + for record in registered: + self.register(record) # #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续 # selector 的 stable_id 引用解析到 active body 内的新形态。多条演化 # 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段 @@ -588,7 +614,7 @@ class TopologyRegistry: candidates = sorted( ( (self._evolved_drift(prior, record), record.record_id) - for record in records if self._evolved_equivalent(prior, record) + for record in registered if self._evolved_equivalent(prior, record) ), key=lambda item: item[0], ) diff --git a/backend/engine/cdsl_engine/semantic_validation.py b/backend/engine/cdsl_engine/semantic_validation.py index 9ecfa76e..c52af824 100644 --- a/backend/engine/cdsl_engine/semantic_validation.py +++ b/backend/engine/cdsl_engine/semantic_validation.py @@ -87,7 +87,8 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: deferred.append(fid) for index, selector in enumerate(feature.get("selectors") or []): owner = selector.get("owner_feature_id") - if owner is not None and owner not in feature_ids: + binding_owner = selector.get("binding_feature_id") + if owner is not None and owner not in feature_ids and binding_owner not in feature_ids: raise ValueError(f"Feature {fid} selector {index} has a forward or missing owner_feature_id") if feature.get("unresolved"): unresolved.append({"feature_id": fid, "reasons": list(feature["unresolved"])}) diff --git a/backend/engine/cdsl_engine/sketch_solver.py b/backend/engine/cdsl_engine/sketch_solver.py index b9429afc..dca4f0e3 100644 --- a/backend/engine/cdsl_engine/sketch_solver.py +++ b/backend/engine/cdsl_engine/sketch_solver.py @@ -1,7 +1,7 @@ """Core CDSL sketch resolver. The runtime accepts only direct geometric descriptions: circles, straight-edge -polygons, and closed analytic line/arc/circle/B-spline contours. Semantic +polygons, and closed analytic line/arc/circle/ellipse/B-spline contours. Semantic shapes and historical profile macros belong to the importer compatibility layer and must be lowered before this module is invoked. """ @@ -58,6 +58,12 @@ def _to_3d(workplane: _Ctx, u: float, v: float) -> list[float]: return [origin[0] + u * x_dir[0] + v * y_dir[0], origin[1] + u * x_dir[1] + v * y_dir[1], origin[2] + u * x_dir[2] + v * y_dir[2]] +def _to_3d_vector(workplane: _Ctx, u: float, v: float) -> list[float]: + origin = _to_3d(workplane, 0.0, 0.0) + target = _to_3d(workplane, u, v) + return [target[index] - origin[index] for index in range(3)] + + def _dot(left: Iterable[float], right: Iterable[float]) -> float: return sum(a * b for a, b in zip(left, right)) @@ -81,11 +87,19 @@ def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]: if edge["type"] == "arc": output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1]) output["normal"] = list(normal) + elif edge["type"] == "ellipse": + output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1]) + output["major_axis_mm"] = _to_3d_vector(workplane, edge["major_axis_mm"][0], edge["major_axis_mm"][1]) + output["normal"] = list(normal) elif edge["type"] == "bspline": output["points_mm"] = [ _to_3d(workplane, point[0], point[1]) for point in edge["points_mm"] ] + for key in ("start_tangent_mm", "end_tangent_mm"): + if key in edge: + tangent = edge[key] + output[key] = _to_3d_vector(workplane, tangent[0], tangent[1]) transformed.append(output) return transformed @@ -96,8 +110,9 @@ def _gen_circle(profile: _Ctx, _: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: if radius <= 0: raise ValueError("circle radius must be > 0") cx, cy = float(center[0]), float(center[1]) - points = [[cx + radius, cy, 0.0], [cx, cy + radius, 0.0], [cx - radius, cy, 0.0], [cx, cy - radius, 0.0], [cx + radius, cy, 0.0]] - return [_circle([cx, cy], radius)], [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius) for index in range(4)] + # 直接圆 profile 必须保留为一条完整的圆边。若拆成四条圆弧,后续按边 + # 选择的圆角/倒角会把同一拓扑圆误解为四个独立目标。 + return [_circle([cx, cy], radius)], [] def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: @@ -118,6 +133,19 @@ def _distance(left: list[float], right: list[float]) -> float: return math.hypot(float(left[0]) - float(right[0]), float(left[1]) - float(right[1])) +def _centripetal_parameters(points: list[list[float]], periodic: bool) -> list[float]: + pairs = list(zip(points, points[1:])) + if periodic: + pairs.append((points[-1], points[0])) + parameters = [0.0] + for start, end in pairs: + distance = math.dist(start, end) + if distance <= _TOLERANCE_MM: + raise ValueError("analytic_contours: centripetal bspline has coincident interpolation points") + parameters.append(parameters[-1] + math.sqrt(distance)) + return parameters + + def _reverse(edge: _Ctx) -> _Ctx: output = deepcopy(edge) output["start_mm"], output["end_mm"] = output["end_mm"], output["start_mm"] @@ -125,6 +153,16 @@ def _reverse(edge: _Ctx) -> _Ctx: output["clockwise"] = not bool(output["clockwise"]) if output.get("type") == "bspline": output["points_mm"] = list(reversed(output["points_mm"])) + parameters = output.get("parameters") + if parameters is not None: + final_parameter = float(parameters[-1]) + output["parameters"] = [final_parameter - float(value) for value in reversed(parameters)] + start_tangent = output.pop("start_tangent_mm", None) + end_tangent = output.pop("end_tangent_mm", None) + if end_tangent is not None: + output["start_tangent_mm"] = [-float(value) for value in end_tangent] + if start_tangent is not None: + output["end_tangent_mm"] = [-float(value) for value in start_tangent] return output @@ -174,6 +212,27 @@ def _circle_edges(segment: _Ctx) -> list[_Ctx]: return [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)] +def _ellipse_edges(segment: _Ctx) -> list[_Ctx]: + center = segment.get("center") or [0.0, 0.0] + major_radius = float(segment.get("major_radius_mm") or 0.0) + minor_radius = float(segment.get("minor_radius_mm") or 0.0) + major_axis = segment.get("major_axis") or [] + if major_radius <= 0 or minor_radius <= 0: + raise ValueError("analytic_contours: ellipse radii must be > 0") + if len(major_axis) < 2: + raise ValueError("analytic_contours: ellipse major_axis must have two components") + axis_length = math.hypot(float(major_axis[0]), float(major_axis[1])) + if axis_length <= _TOLERANCE_MM: + raise ValueError("analytic_contours: ellipse major_axis is degenerate") + cx, cy = float(center[0]), float(center[1]) + ux, uy = float(major_axis[0]) / axis_length, float(major_axis[1]) / axis_length + start = [cx + major_radius * ux, cy + major_radius * uy, 0.0] + return [{ + "type": "ellipse", "start_mm": start, "end_mm": list(start), "center_mm": [cx, cy, 0.0], + "major_axis_mm": [ux, uy, 0.0], "major_radius_mm": major_radius, "minor_radius_mm": minor_radius, + }] + + def _segment_edges(segment: _Ctx) -> list[_Ctx]: kind = segment.get("type") if kind == "line": @@ -182,17 +241,55 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]: return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))] if kind == "circle": return _circle_edges(segment) + if kind == "ellipse": + return _ellipse_edges(segment) if kind == "bspline": points = segment.get("points") or [] if len(points) < 3: raise ValueError("analytic_contours: bspline needs at least 3 interpolation points") converted = [_point(point) for point in points] - return [{ + periodic = bool(segment.get("periodic")) + if periodic: + if _distance(converted[0], converted[-1]) > _TOLERANCE_MM: + raise ValueError("analytic_contours: periodic bspline endpoints do not meet") + # The duplicated closing interpolation point describes topology, + # not an additional periodic interpolation constraint. OCC's + # periodic interpolator receives each unique point exactly once. + interpolation_points = converted[:-1] + else: + interpolation_points = converted + parameterization = segment.get("parameterization") + if parameterization not in {None, "chord", "centripetal"}: + raise ValueError(f"analytic_contours: unsupported bspline parameterization {parameterization!r}") + parameters = segment.get("parameters") + if parameters is not None: + expected_count = len(interpolation_points) + int(periodic) + if len(parameters) != expected_count: + raise ValueError("analytic_contours: bspline parameter count does not match interpolation points") + parameters = [float(value) for value in parameters] + if not all(math.isfinite(value) for value in parameters): + raise ValueError("analytic_contours: bspline parameters must be finite") + if any(right - left <= _TOLERANCE_MM for left, right in zip(parameters, parameters[1:])): + raise ValueError("analytic_contours: bspline parameters must be strictly increasing") + output: _Ctx = { "type": "bspline", "start_mm": converted[0], "end_mm": converted[-1], - "points_mm": converted, - }] + "points_mm": interpolation_points, + "periodic": periodic, + **({"parameters": parameters} if parameters is not None else {}), + **({"parameters": _centripetal_parameters(interpolation_points, periodic)} if parameters is None and parameterization == "centripetal" else {}), + } + start_tangent = segment.get("start_tangent") + end_tangent = segment.get("end_tangent") + if (start_tangent is None) != (end_tangent is None): + raise ValueError("analytic_contours: bspline requires both endpoint tangents") + if start_tangent is not None: + if periodic: + raise ValueError("analytic_contours: periodic bspline does not accept endpoint tangents") + output["start_tangent_mm"] = _point(start_tangent) + output["end_tangent_mm"] = _point(end_tangent) + return [output] raise ValueError(f"analytic_contours: unsupported segment type {kind!r}") @@ -204,6 +301,25 @@ def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]: if edge.get("type") == "bspline": points.extend((float(point[0]), float(point[1])) for point in edge["points_mm"][1:-1]) continue + if edge.get("type") == "ellipse": + center, axis = edge["center_mm"], edge["major_axis_mm"] + major_radius = float(edge["major_radius_mm"]) + minor_radius = float(edge["minor_radius_mm"]) + normal = edge.get("normal") or [0.0, 0.0, 1.0] + axis_length = math.sqrt(sum(float(value) * float(value) for value in axis)) + normal_length = math.sqrt(sum(float(value) * float(value) for value in normal)) + if axis_length <= _TOLERANCE_MM or normal_length <= _TOLERANCE_MM: + raise ValueError("analytic_contours: ellipse axis is degenerate") + x_axis = [float(value) / axis_length for value in axis] + z_axis = [float(value) / normal_length for value in normal] + y_axis = [z_axis[1] * x_axis[2] - z_axis[2] * x_axis[1], z_axis[2] * x_axis[0] - z_axis[0] * x_axis[2], z_axis[0] * x_axis[1] - z_axis[1] * x_axis[0]] + for step in range(1, 8): + angle = math.tau * step / 8 + points.append(( + float(center[0]) + major_radius * math.cos(angle) * x_axis[0] + minor_radius * math.sin(angle) * y_axis[0], + float(center[1]) + major_radius * math.cos(angle) * x_axis[1] + minor_radius * math.sin(angle) * y_axis[1], + )) + continue if edge.get("type") != "arc": continue center, end = edge["center_mm"], edge["end_mm"] @@ -328,7 +444,7 @@ SHAPE_GENERATORS = CORE_SHAPE_GENERATORS SHAPE_CAPABILITIES: dict[str, _Ctx] = { "circle": {"detectable": True, "arity": "circle", "description": "single circular contour"}, "polygon": {"detectable": True, "arity": "polygon", "description": "closed straight-edge contour"}, - "analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, and circle contours"}, + "analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, circle, ellipse and B-spline contours"}, } diff --git a/backend/tests/test_engine_loft_geometry.py b/backend/tests/test_engine_loft_geometry.py index b40b92e6..7964cef8 100644 --- a/backend/tests/test_engine_loft_geometry.py +++ b/backend/tests/test_engine_loft_geometry.py @@ -5,6 +5,7 @@ import unittest from pathlib import Path from engine.cdsl_engine.runtime import rebuild_cdsl +from engine.cdsl_engine.sketch_solver import resolve_profile def _spline_profile(sketch_id: str, z: float) -> dict: @@ -28,6 +29,22 @@ def _spline_profile(sketch_id: str, z: float) -> dict: class LoftGeometryTests(unittest.TestCase): + def test_periodic_bspline_supports_centripetal_parameterization(self): + sketch = { + "id": "profile", + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "analytic_contours", "contours": [{ + "role": "outer", "closed": True, "segments": [{ + "type": "bspline", "periodic": True, "parameterization": "centripetal", + "start": [0, 0], "end": [0, 0], + "points": [[0, 0], [16, 0], [16, 9], [0, 9], [0, 0]], + }], + }]}, + } + resolved = resolve_profile(sketch) + parameters = resolved["contour_regions_mm"][0]["outer"][0]["parameters"] + self.assertEqual(parameters, [0.0, 4.0, 7.0, 11.0, 14.0]) + def test_open_bspline_segment_can_close_with_other_contour_edges(self): cdsl = { "schema": "cad.cdsl.llm.v1", @@ -58,6 +75,32 @@ class LoftGeometryTests(unittest.TestCase): rebuilt = rebuild_cdsl(cdsl, Path(tmp) / "open-bspline-contour.step") self.assertGreater(rebuilt["volume_mm3"], 1.0) + def test_reversed_bspline_transports_the_parameter_domain(self): + sketch = { + "id": "profile", + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "analytic_contours", "contours": [{ + "role": "outer", "closed": True, + "segments": [ + {"type": "line", "start": [0, 0], "end": [0, 4]}, + { + "type": "bspline", "start": [3, 0], "end": [0, 4], + "points": [[3, 0], [3, 2], [1, 4], [0, 4]], + "parameters": [0, 2, 4, 6], + "start_tangent": [2, 0], "end_tangent": [0, -3], + }, + {"type": "line", "start": [3, 0], "end": [0, 0]}, + ], + }]}, + } + resolved = resolve_profile(sketch) + spline = next(edge for edge in resolved["contour_regions_mm"][0]["outer"] if edge["type"] == "bspline") + + self.assertEqual(spline["points_mm"], [[0.0, 4.0, 0.0], [1.0, 4.0, 0.0], [3.0, 2.0, 0.0], [3.0, 0.0, 0.0]]) + self.assertEqual(spline["parameters"], [0.0, 2.0, 4.0, 6.0]) + self.assertEqual(spline["start_tangent_mm"], [0.0, 3.0, 0.0]) + self.assertEqual(spline["end_tangent_mm"], [-2.0, 0.0, 0.0]) + def test_bspline_loft_and_mirror_replay_produce_step(self): cdsl = { "schema": "cad.cdsl.llm.v1", diff --git a/backend/tests/test_engine_runtime_foundation.py b/backend/tests/test_engine_runtime_foundation.py index d31ae60f..8a76a50b 100644 --- a/backend/tests/test_engine_runtime_foundation.py +++ b/backend/tests/test_engine_runtime_foundation.py @@ -2,6 +2,7 @@ from __future__ import annotations import hashlib import json +import math import sys import tempfile import unittest @@ -129,6 +130,133 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(len(sketch["contour_regions_mm"]), 2) self.assertTrue(all(not region["holes"] for region in sketch["contour_regions_mm"])) + def test_analytic_circle_contours_preserve_single_circular_wire_edges(self) -> None: + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + cdsl = { + "schema": "cad.cdsl.llm.v1", + "geometry": {"sketches": [{ + "id": "sketch", "workplane": _workplane(), + "profile": {"type": "analytic_contours", "contours": [ + {"role": "outer", "closed": True, "segments": [ + {"type": "circle", "center": [-10, 0], "radius_mm": 2}, + ]}, + {"role": "outer", "closed": True, "segments": [ + {"type": "circle", "center": [10, 0], "radius_mm": 2}, + ]}, + ]}, + }]}, + "features": [], + } + sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0] + faces = Build123dGeometryAdapter().faces_for_sketch(sketch) + + self.assertEqual(len(faces), 2) + self.assertTrue(all(len(face.outer_wire().edges()) == 1 for face in faces)) + + def test_analytic_ellipse_preserves_its_workplane_orientation_and_volume(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = self._base_block() + cdsl["geometry"]["sketches"][0] = { + "id": "ellipse", + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [0, 1, 0], "normal": [0, 0, 1]}, + "profile": {"type": "analytic_contours", "contours": [{ + "role": "outer", "closed": True, "segments": [{ + "type": "ellipse", "center": [0, 0], "major_radius_mm": 5, + "minor_radius_mm": 2, "major_axis": [3 / 5, 4 / 5], + }], + }]}, + } + cdsl["features"][0]["sketch_id"] = "ellipse" + cdsl["features"][0]["params"] = {"distance_mm": 4} + + sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0] + edge = sketch["contour_regions_mm"][0]["outer"][0] + self.assertEqual(edge["major_axis_mm"], [-0.8, 0.6, 0.0]) + + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "ellipse.step") + + self.assertEqual(result["solid_count"], 1) + self.assertAlmostEqual(result["volume_mm3"], math.pi * 5 * 2 * 4, places=5) + + def test_bspline_profile_preserves_endpoint_tangents(self) -> None: + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + cdsl = { + "schema": "cad.cdsl.llm.v1", + "geometry": {"sketches": [{ + "id": "spline", "workplane": _workplane(), + "profile": {"type": "analytic_contours", "contours": [{ + "role": "outer", "closed": True, "segments": [ + {"type": "line", "start": [0, 0], "end": [4, 0]}, + {"type": "bspline", "start": [4, 0], "end": [0, 0], + "points": [[4, 0], [4, 3], [0, 0]], + "start_tangent": [0, 5], "end_tangent": [-5, 0]}, + ], + }]}, + }]}, + "features": [], + } + sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0] + spline = next(edge for edge in sketch["contour_regions_mm"][0]["outer"] if edge["type"] == "bspline") + self.assertEqual(spline["start_tangent_mm"], [0.0, 5.0, 0.0]) + self.assertEqual(spline["end_tangent_mm"], [-5.0, 0.0, 0.0]) + edge = Build123dGeometryAdapter()._wire([spline]).edges()[0] + self.assertAlmostEqual(edge.tangent_at(0).Y, 1.0, places=6) + self.assertAlmostEqual(edge.tangent_at(1).X, -1.0, places=6) + + def test_drafted_ellipse_exports_as_a_solid(self) -> None: + from build123d import import_step + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = self._base_block() + cdsl["geometry"]["sketches"].append({ + "id": "ellipse", "workplane": {"origin_mm": [0, 0, 10], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "analytic_contours", "contours": [{ + "role": "outer", "closed": True, "segments": [{ + "type": "ellipse", "center": [0, 0], "major_radius_mm": 3, + "minor_radius_mm": 2, "major_axis": [1, 0], + }], + }]}, + }) + cdsl["features"].append({ + "id": "drafted_ellipse", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], + "params": {"distance_mm": 4, "draft": {"angle_deg": 1, "pull_direction": False}}, "sketch_id": "ellipse", + }) + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "drafted-ellipse.step" + result = rebuild_cdsl(cdsl, output) + exported = import_step(str(output)) + + self.assertEqual(result["solid_count"], 1) + self.assertEqual(len(exported.solids()), 1) + self.assertGreater(float(exported.volume), 1000.0) + + def test_boolean_bodies_subtracts_explicit_new_body_sources(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "boolean-bodies", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [ + {"id": "outer", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 10}}, + {"id": "inner", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 4}}, + ]}, + "features": [ + {"id": "outer_body", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "outer", "params": {"distance_mm": 10, "result_mode": "new_body"}}, + {"id": "inner_body", "atomic_id": "extrude_add_blind", "depends_on": ["outer_body"], "sketch_id": "inner", "params": {"distance_mm": 10, "result_mode": "new_body"}}, + {"id": "cut", "atomic_id": "boolean_bodies", "depends_on": ["outer_body", "inner_body"], "params": {"operation": "subtract", "target_feature_ids": ["outer_body"], "tool_feature_ids": ["inner_body"], "keep_tools": False}}, + ], + } + with tempfile.TemporaryDirectory() as tmp: + rebuilt = rebuild_cdsl(cdsl, Path(tmp) / "boolean.step") + + self.assertEqual(rebuilt["solid_count"], 1) + self.assertAlmostEqual(rebuilt["volume_mm3"], math.pi * (10 ** 2 - 4 ** 2) * 10) + self.assertEqual([item["feature_id"] for item in rebuilt["feature_results"]], ["outer_body", "inner_body", "cut"]) + def test_deferred_reference_is_currently_executable_without_a_sketch(self) -> None: cdsl = { "schema": "cad.cdsl.llm.v1", @@ -598,6 +726,81 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertAlmostEqual(result["volume_mm3"], 200 - 4 * 3.141592653589793, places=5) self.assertIn("mirror", [item["feature_id"] for item in result["feature_results"]]) + def test_circular_pattern_fuses_face_sharing_new_body_instances(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "circular-fuse", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "wedge", "workplane": _workplane(), + "profile": {"type": "polygon", "vertices": [[0, 0], [10, 0], [0, 10]]}, + }]}, + "features": [ + {"id": "wedge_add", "atomic_id": "extrude_add_blind", "depends_on": [], + "params": {"distance_mm": 10, "result_mode": "new_body"}, "sketch_id": "wedge"}, + {"id": "wedge_pattern", "atomic_id": "pattern_circular", "depends_on": ["wedge_add"], "params": { + "source_feature_ids": ["wedge_add"], "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}, + "pattern_count": 4, "sweep_angle_deg": 360, + }}, + ], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "circular-fuse.step") + self.assertEqual(result["solid_count"], 1) + self.assertAlmostEqual(result["volume_mm3"], 2000.0, places=5) + + def test_circular_pattern_skips_explicitly_deleted_copy_instances(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "circular-delete-copy", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "wedge", "workplane": _workplane(), + "profile": {"type": "polygon", "vertices": [[0, 0], [10, 0], [0, 10]]}, + }]}, + "features": [ + {"id": "wedge_add", "atomic_id": "extrude_add_blind", "depends_on": [], + "params": {"distance_mm": 10, "result_mode": "new_body"}, "sketch_id": "wedge"}, + {"id": "wedge_pattern", "atomic_id": "pattern_circular", "depends_on": ["wedge_add"], "params": { + "source_feature_ids": ["wedge_add"], "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}, + "pattern_count": 4, "sweep_angle_deg": 360, "excluded_instance_indices": [2], + }}, + ], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "circular-delete-copy.step") + self.assertAlmostEqual(result["volume_mm3"], 1500.0, places=5) + + def test_mirror_pattern_can_union_the_active_body(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "mirror-active-body", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [ + {"id": "base", "workplane": _workplane(), "profile": _rectangle([-5, -5], [5, 5])}, + {"id": "half_cut", "workplane": _workplane(), "profile": _rectangle([-5, 0], [5, 5])}, + {"id": "hole", "workplane": _workplane(), "profile": {"type": "circle", "center": [0, -2], "radius_mm": 1}}, + ]}, + "features": [ + {"id": "mirror_plane", "atomic_id": "reference_plane", "depends_on": [], "params": {"plane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 1, 0]}}, "execution_status": "supported"}, + {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": ["mirror_plane"], "params": {"distance_mm": 2}, "sketch_id": "base", "execution_status": "supported"}, + {"id": "half_remove", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 2}, "sketch_id": "half_cut", "execution_status": "supported"}, + {"id": "hole_remove", "atomic_id": "extrude_cut_blind", "depends_on": ["half_remove"], "params": {"distance_mm": 2}, "sketch_id": "hole", "execution_status": "supported"}, + { + "id": "mirror", "atomic_id": "pattern_mirror", "depends_on": ["hole_remove", "mirror_plane"], + "params": {"source_feature_ids": ["base_add"], "mirror_current_body": True, "mirror_plane": {"kind": "plane", "owner_feature_id": "mirror_plane"}}, + "selectors": [{"kind": "plane", "owner_feature_id": "mirror_plane"}], "execution_status": "supported", + }, + ], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "mirror-active-body.step") + self.assertAlmostEqual(result["volume_mm3"], 200 - 4 * 3.141592653589793, places=5) + self.assertEqual(result["solid_count"], 1) + def test_mirrored_local_circle_preserves_its_reflected_world_position(self) -> None: from cdsl_engine.build123d_adapter import Build123dGeometryAdapter from cdsl_engine.runtime import _mirrored_sketch @@ -622,6 +825,120 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertAlmostEqual(center.Y, 3.0) self.assertAlmostEqual(center.Z, 0.0) + def test_circle_profile_retains_one_topological_circle_edge(self) -> None: + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + cdsl = { + "schema": "cad.cdsl.llm.v1", + "geometry": {"sketches": [{ + "id": "circle", "workplane": _workplane(), + "profile": {"type": "circle", "center": [2, 3], "radius_mm": 1}, + }]}, + "features": [], + } + sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0] + self.assertNotIn("contour_edges_mm", sketch) + face = Build123dGeometryAdapter().faces_for_sketch(sketch)[0] + self.assertEqual(len(face.outer_wire().edges()), 1) + + def test_new_body_mode_preserves_an_overlapping_result_body(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = self._base_block() + cdsl["geometry"]["sketches"].append({ + "id": "overlap", "workplane": _workplane(), + "profile": _rectangle([-2, -5], [8, 5]), + }) + cdsl["features"].append({ + "id": "second_body", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], + "params": {"distance_mm": 10, "result_mode": "new_body"}, "sketch_id": "overlap", + }) + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "two-bodies.step") + self.assertEqual(result["solid_count"], 2) + + def test_sweep_add_builds_a_solid_from_a_closed_profile_and_bspline_path(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + profile_plane = {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 1, 0]} + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "sweep-add", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "profile", "workplane": profile_plane, + "profile": {"type": "circle", "radius_mm": 2}, + }]}, + "features": [{ + "id": "sweep", "atomic_id": "sweep_add", "depends_on": [], "sketch_id": "profile", + "params": {"path": { + "workplane": _workplane(), + "segment": { + "type": "bspline", "start": [0, 0], "end": [0, 20], + "points": [[0, 0], [0, 10], [0, 20]], + "start_tangent": [0, 10], "end_tangent": [0, 10], + }, + }}, + }], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "sweep.step") + self.assertEqual(result["solid_count"], 1) + self.assertAlmostEqual(result["volume_mm3"], 80 * math.pi, delta=1e-4) + + def test_circular_pattern_rotates_a_sweep_profile_and_path(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "sweep-pattern", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "profile", "workplane": {"origin_mm": [10, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, -1]}, + "profile": {"type": "circle", "radius_mm": 1}, + }]}, + "features": [ + {"id": "sweep", "atomic_id": "sweep_add", "depends_on": [], "sketch_id": "profile", "params": {"path": { + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 1, 0]}, + "segment": {"type": "line", "start": [10, 0], "end": [10, 10]}, + }}}, + {"id": "pattern", "atomic_id": "pattern_circular", "depends_on": ["sweep"], "params": { + "source_feature_ids": ["sweep"], "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}, + "pattern_count": 3, "sweep_angle_deg": 360, + }}, + ], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "sweep-pattern.step") + self.assertEqual(result["solid_count"], 3) + self.assertAlmostEqual(result["volume_mm3"], 30 * math.pi, delta=1e-4) + + def test_shell_removes_a_selected_cap_and_offsets_inward(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + base = self._base_block() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + baseline = rebuild_cdsl(base, root / "baseline.step") + top = next( + item for item in baseline["topology_records"] + if item["kind"] == "face" + and item["feature_id"] == "base_add" + and item["geometry"].get("surface_type") == "plane" + and item["geometry"].get("normal", [0, 0, 0])[2] > 0.9 + ) + shelled = deepcopy(base) + shelled["features"].append({ + "id": "shell", "atomic_id": "shell", "depends_on": ["base_add"], + "params": {"thickness_mm": 1, "inward": True}, + "selectors": [{ + "kind": "face", "stable_id": top["record_id"], "snapshot_id": top["record_id"], + "source": "runtime_snapshot", "confidence": 1, "owner_feature_id": "base_add", + "geometry": top["geometry"], + }], + }) + result = rebuild_cdsl(shelled, root / "shell.step") + self.assertEqual(result["solid_count"], 1) + self.assertAlmostEqual(result["volume_mm3"], 424.0, places=5) + def test_fillet_and_hole_wizard_use_resolved_face_edge_selectors(self) -> None: from cdsl_engine.runtime import rebuild_cdsl @@ -983,6 +1300,131 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertLess(result["volume_mm3"], 1000.0) self.assertIn("revolve_cut", [item["feature_id"] for item in result["feature_results"]]) + def test_revolve_surface_preserves_the_active_solid_and_registers_a_shell(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = self._base_block() + cdsl["geometry"]["sketches"].append({ + "id": "surface_profile", "workplane": _workplane(), + "profile": _rectangle([2, 1], [4, 2]), + }) + cdsl["features"].append({ + "id": "surface_revolve", "atomic_id": "revolve_surface", "depends_on": ["base_add"], + "sketch_id": "surface_profile", + "params": {"angle_deg": 360, "axis": {"origin_mm": [0, 0, 0], "direction": [1, 0, 0]}}, + }) + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "surface-revolve.step") + surface = next(item for item in result["feature_results"] if item["feature_id"] == "surface_revolve") + self.assertNotIn("body_id", surface) + self.assertEqual(surface["surface_id"], "surface:surface_revolve") + self.assertEqual(result["solid_count"], 1) + self.assertAlmostEqual(result["volume_mm3"], 1000.0) + faces = [ + item for item in result["topology_records"] + if item["feature_id"] == "surface_revolve" and item["kind"] == "face" + ] + self.assertEqual(len(faces), 4) + + def test_extrude_surface_preserves_the_active_solid_and_registers_a_shell(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = self._base_block() + cdsl["geometry"]["sketches"].append({ + "id": "surface_profile", "workplane": _workplane(), + "profile": { + "type": "analytic_contours", + "contours": [{ + "role": "unknown", "closed": True, + "segments": [{"type": "circle", "center": [5, 5], "radius_mm": 2}], + }], + }, + }) + cdsl["features"].append({ + "id": "surface_extrude", "atomic_id": "extrude_surface", "depends_on": ["base_add"], + "sketch_id": "surface_profile", "params": {"distance_mm": 10}, + }) + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "surface-extrude.step") + surface = next(item for item in result["feature_results"] if item["feature_id"] == "surface_extrude") + self.assertNotIn("body_id", surface) + self.assertEqual(surface["surface_id"], "surface:surface_extrude") + self.assertEqual(result["solid_count"], 1) + self.assertAlmostEqual(result["volume_mm3"], 1000.0) + faces = [ + item for item in result["topology_records"] + if item["feature_id"] == "surface_extrude" and item["kind"] == "face" + ] + self.assertEqual(len(faces), 1) + + def test_extrude_surface_exports_a_surface_only_step(self) -> None: + from build123d import import_step + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "surface-only", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "surface_profile", "workplane": _workplane(), + "profile": { + "type": "analytic_contours", + "contours": [{ + "role": "unknown", "closed": True, + "segments": [{"type": "circle", "center": [5, 5], "radius_mm": 2}], + }], + }, + }]}, + "features": [{ + "id": "surface_extrude", "atomic_id": "extrude_surface", "depends_on": [], + "sketch_id": "surface_profile", "params": {"distance_mm": 10}, + }], + } + with tempfile.TemporaryDirectory() as directory: + out_step = Path(directory) / "surface-only.step" + result = rebuild_cdsl(cdsl, out_step) + rebuilt = import_step(str(out_step)) + self.assertEqual(result["solid_count"], 0) + self.assertEqual(result["surface_count"], 1) + self.assertEqual(result["surface_face_count"], 1) + self.assertAlmostEqual(result["volume_mm3"], 0.0) + self.assertAlmostEqual(result["surface_area_mm2"], 40 * math.pi) + self.assertEqual(len(rebuilt.solids()), 0) + self.assertEqual(len(rebuilt.faces()), 1) + + def test_surface_limited_chamfer_requires_an_explicit_shell_boundary(self) -> None: + from build123d import Plane, Solid + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + adapter = Build123dGeometryAdapter() + base = Solid.make_cylinder(19, 20, Plane(origin=(0, 0, -10))).cut( + Solid.make_cylinder(16, 20, Plane(origin=(0, 0, -10))), + ) + lower = Solid.make_cylinder(28.5, 20, Plane(origin=(0, 0, -20))).cut( + Solid.make_cylinder(16, 20, Plane(origin=(0, 0, -20))), + ) + upper = Solid.make_cylinder(25.5, 25, Plane(origin=(0, 0, 10))).cut( + Solid.make_cylinder(17.5, 25, Plane(origin=(0, 0, 10))), + ) + body = adapter.fuse(adapter.fuse(base, lower), upper) + selected = [ + edge for edge in body.edges() + if str(edge.geom_type).split(".")[-1].lower() == "circle" + and abs(float(edge.radius) - 28.5) <= 1e-6 + and abs(edge.arc_center.Z) <= 1e-6 + ] + self.assertEqual(len(selected), 1) + + with self.assertRaisesRegex(ValueError, "explicit surface support"): + adapter.surface_limited_chamfer(body, 10, selected, []) + + wire = adapter._circle_wire([0, 0], 19, PlaneSpec.from_mapping(_workplane())) + support = adapter.extrude_surface([wire], [0, 0, 10]) + rebuilt = adapter.surface_limited_chamfer(body, 10, selected, [support]) + + self.assertLess(rebuilt.volume, body.volume) + cones = [face for face in rebuilt.faces() if str(face.geom_type).split(".")[-1].lower() == "cone"] + self.assertEqual(len(cones), 1) + def test_unowned_revolve_feature_selector_is_preflight_blocked(self) -> None: from cdsl_engine.runtime import analyze_cdsl @@ -1057,6 +1499,53 @@ class EngineRuntimeFoundationTests(unittest.TestCase): result = rebuild_cdsl(cdsl, root / f"{name}.step") self.assertAlmostEqual(result["volume_mm3"], 1000 - expected_depth * 3.141592653589793, places=5) + def test_up_to_vertex_intersection_requires_one_shared_current_body_vertex(self) -> None: + from cdsl_engine.runtime import RuntimeExecutionError, rebuild_cdsl + + base = self._base_block() + base["geometry"]["sketches"].append({ + "id": "cut", "workplane": _workplane(), + "profile": {"type": "circle", "center": [0, 0], "radius_mm": 1}, + }) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + baseline = rebuild_cdsl(base, root / "intersection-baseline.step") + faces = [item for item in baseline["topology_records"] if item["kind"] == "face"] + top = next(item for item in faces if item["geometry"]["normal"][2] > 0.9) + right = next(item for item in faces if item["geometry"]["normal"][0] > 0.9) + back = next(item for item in faces if item["geometry"]["normal"][1] > 0.9) + + def face_selector(face: dict) -> dict: + return { + "kind": "face", "owner_feature_id": "base_add", + "stable_id": f"intersection-{face['record_id']}", + "source": "runtime_snapshot", "confidence": 1.0, + "geometry": face["geometry"], + } + + cdsl = deepcopy(base) + cdsl["features"].append({ + "id": "cut_to_intersection", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], + "params": {"distance_mm": 0, "end_condition": { + "type": "up_to_vertex", "reference": { + "kind": "vertex", "owner_feature_id": "base_add", + "stable_id": "top-right-back", "source": "runtime_snapshot", "confidence": 1.0, + "intersection_of": [face_selector(top), face_selector(right), face_selector(back)], + }, + }}, + "sketch_id": "cut", + }) + result = rebuild_cdsl(cdsl, root / "intersection.step") + self.assertAlmostEqual(result["volume_mm3"], 1000 - 10 * 3.141592653589793, places=5) + + ambiguous = deepcopy(cdsl) + ambiguous["features"][-1]["params"]["end_condition"]["reference"]["intersection_of"] = [ + face_selector(right), face_selector(back), + ] + with self.assertRaises(RuntimeExecutionError) as error: + rebuild_cdsl(ambiguous, root / "ambiguous-intersection.step") + self.assertEqual(error.exception.diagnostic.code, "intersection_vertex_unresolved") + def test_through_next_trims_a_partially_overlapping_profile_to_the_next_body_face(self) -> None: from cdsl_engine.runtime import rebuild_cdsl @@ -1073,6 +1562,71 @@ class EngineRuntimeFoundationTests(unittest.TestCase): result = rebuild_cdsl(base, Path(directory) / "partial-through-next.step") self.assertAlmostEqual(result["volume_mm3"], 1000 + 24, places=5) + def test_through_next_keeps_leading_material_before_a_cylindrical_body(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "leading-through-next", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [ + {"id": "base", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 10}}, + {"id": "lead", "workplane": { + "origin_mm": [0, -50, 25], "x_dir": [1, 0, 0], "normal": [0, 1, 0], + }, "profile": {"type": "circle", "radius_mm": 2}}, + ]}, + "features": [ + {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], + "params": {"distance_mm": 50}, "sketch_id": "base"}, + {"id": "lead_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], + "params": {"distance_mm": 0, "end_condition": {"type": "through_next"}, "result_mode": "new_body"}, + "sketch_id": "lead"}, + ], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "leading-through-next.step") + self.assertEqual(result["solid_count"], 2) + self.assertAlmostEqual(result["bbox_mm"]["min"][1], -50.0, places=5) + + def test_up_to_surface_from_a_cylindrical_wall_uses_the_next_body_face(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + base = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "wall-hole", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [ + {"id": "tube", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 10}}, + {"id": "tube_bore", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 7}}, + {"id": "wall_hole", "workplane": { + "origin_mm": [10, 0, 5], "x_dir": [0, 1, 0], "normal": [-1, 0, 0], + }, "profile": {"type": "circle", "radius_mm": 1}}, + ]}, + "features": [ + {"id": "tube_add", "atomic_id": "extrude_add_blind", "depends_on": [], + "params": {"distance_mm": 10}, "sketch_id": "tube"}, + {"id": "tube_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["tube_add"], + "params": {"distance_mm": 10}, "sketch_id": "tube_bore"}, + ], + } + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + baseline = rebuild_cdsl(base, root / "tube.step") + outer_face = next( + item for item in baseline["topology_records"] + if item["kind"] == "face" and item["geometry"]["surface_type"] == "cylinder" + and abs(item["geometry"]["radius_mm"] - 10) <= 1e-6 + ) + cdsl = deepcopy(base) + cdsl["features"].append({ + "id": "wall_hole_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["tube_cut"], + "params": {"distance_mm": 0, "end_condition": {"type": "up_to_surface", "reference": { + "kind": "face", "owner_feature_id": "tube_add", "geometry": outer_face["geometry"], + }}}, + "sketch_id": "wall_hole", + }) + result = rebuild_cdsl(cdsl, root / "wall-hole.step") + self.assertLess(result["volume_mm3"], baseline["volume_mm3"] - 8) + self.assertGreater(result["volume_mm3"], baseline["volume_mm3"] - 12) + def test_up_to_body_extent_uses_a_uniquely_resolved_body_record(self) -> None: from cdsl_engine.runtime import rebuild_cdsl diff --git a/cadfs_to_cdsl/.gitignore b/cadfs_to_cdsl/.gitignore index 179b4868..7166b97a 100644 --- a/cadfs_to_cdsl/.gitignore +++ b/cadfs_to_cdsl/.gitignore @@ -1,3 +1,4 @@ output/ +output-history/ __pycache__/ *.pyc diff --git a/cadfs_to_cdsl/CADFS_RECONSTRUCTION_TARGET.md b/cadfs_to_cdsl/CADFS_RECONSTRUCTION_TARGET.md new file mode 100644 index 00000000..73375943 --- /dev/null +++ b/cadfs_to_cdsl/CADFS_RECONSTRUCTION_TARGET.md @@ -0,0 +1,195 @@ +# CADFS 全量重建目标与验收计划 + +## 目标 + +将 CADFS FeatureScript 历史稳定转换为 CDSL,由 engine 重建 STEP,并与 +原始 STEP 做严格几何比较。近期交付门槛是 17 个代表性样本全部严格通过; +最终交付门槛是当前 CADFS 全量语料中的每个可比较模型都完成同一条链路。 + +这里的“重建成功”仅指 `comparison.json` 的 `strict.passed == true`,不是 +仅生成 `rebuild.step`,也不是只通过 RP 宽松阈值。 + +本文件记录目标、阶段门槛和验收口径。实现进度、能力边界和每次代码变更 +后的证据记录在本地台账 +`ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`;两者必须保持一致。 + +## 当前基线 + +- 全量输出快照:9,347 个 CADFS 样本,见 `output/summary.json`。产品目标可 + 称为“一万条全量数据”,但验收时必须以本次扫描得到的实际样本总数为分母, + 不能将 10,000 写成未验证的固定数量。 +- 代表性回归池:17 个样本,见 `regression/manifest.json`。它覆盖当前观察到的 + FeatureScript 操作、草图实体、已 lower 的 engine atomic 和未支持能力变体。 +- 本地能力台账记录该池已有 `17/17 rebuilt` 的可执行 STEP 证据;当前 checked-in + manifest 保留的是历史选择时的 `3` 个 engine baseline / `17` 个 conversion + 样本分类。因此,开始严格回归前必须重新生成并核对 manifest,不能将旧分类 + 当作当前通过结果。 +- 当前全量报告中仍有 `rebuild_failed`、`rebuild_timeout`、`comparison_timeout` 和 + `rebuilt_rejected`。这些都是待消除的问题,不可因保留了可执行 STEP 而视为完成。 + +## 验收口径 + +一个样本必须同时具备以下工件和结果,才计入严格重建成功: + +1. `candidate.cdsl.json` 通过 schema 和语义验证,且没有以跳过后续几何换取成功。 +2. `bound.cdsl.json`(若样本需要 selector 绑定)可复现产生,绑定证据可追溯到 + feature、owner 和 topology snapshot。 +3. `rebuild.step` 由完整的 CDSL feature history 生成;中间失败不能被最终可执行 + 前缀掩盖。 +4. `comparison.json` 的 `decision` 为 `strict_pass`,并同时满足: + `surface_max <= 0.01 mm`、`surface_p99 <= 0.01 mm`、 + `bbox_max_delta <= 0.01 mm`、体积和表面积相对误差均不超过 `1e-5`,以及实体数相等。 +5. `status.json`、转换诊断、重建报告和比较报告保存于该样本目录;可执行但不相似 + 的模型必须保留,而不能在失败时删除。 + +`approximate_pass` 仅用于定位接近结果,不能通过本计划的 17 样本或全量目标。 +缺失原始 STEP、源数据损坏或外部资产不可取得时,必须单独列为 source exception, +附原始证据;不得伪装成 engine 或 converter 已完成。 + +## 第一阶段:17 个代表样本 + +阶段完成条件:下面 17 个样本全部以 `--compare-mode strict` 重新执行并严格通过, +同时 manifest 的覆盖标签与当前输出一致。每次修复只针对实际失败根因扩展能力; +一个样本可能由多个能力共同阻塞。 + +| 样本 | 代表的操作或几何 | 当前需要验证或补齐的门槛 | +| --- | --- | --- | +| `00002243` | extrude、hole、chamfer | 孔宿主、端盖/边 selector、倒角参数与稳定选边。 | +| `00035682` | cPlane、extrude、revolve | `line_point` 基准面及其后续特征引用。 | +| `00054089` | extrude、revolve | `swept_edge` selector。 | +| `00111611` | extrude、revolve、fillet、chamfer | 复杂草图、回转轴、圆角和倒角的拓扑稳定性。 | +| `00129362` | cPlane、extrude、revolve | 可复用基准面 frame、回转 add/cut。 | +| `00159804` | circularPattern、through-all add | 环形阵列 replay、`line_angle` 和 through-all 加料。 | +| `00192744` | circularPattern、extrude | 已执行 `curve_point` 附着 frame、`up_to_next` 和 pattern copy deletion;仍缺多 body transform 与 source/STEP 一致性证据。 | +| `00212904` | shell、two-sided cut | shell、`three_point`、双向切除终止条件。 | +| `00287955` | loft、cPlane、revolve | 多 profile loft、`cap_edge`、`mid_plane`。 | +| `00423838` | circularPattern、extrude | `up_to_vertex` 与 two-sided cut 的非盲终止。 | +| `00542223` | shell、sweep、circularPattern | sweep path/profile、shell、`plane_point`、through-all cut。 | +| `00612529` | loft、mirror、cPlane | loft profile 对齐、镜像 replay、`mid_cap_edge`。 | +| `00694309` | extrude、fillet | `up_to_body` 和 feature 后继 selector。 | +| `00710855` | extrude、chamfer | 曲面拉伸与 surface/solid 混合拓扑。 | +| `00789939` | shell、extrude、fillet | shell、`offset_face`、`swept_face`。 | +| `00835610` | booleanBodies、revolve | 多 body boolean、`cap_face`、`intersect`、revolve surface。 | +| `00925274` | mirror、through-all cut | 镜像 replay、`up_to_surface`、`line_angle`。 | + +推荐的完整验收命令: + +```bash +PYTHONPATH=backend:. python -m cadfs_to_cdsl regression \ + --tier all --stage pipeline --compare-mode strict --timeout-seconds 60 +``` + +命令结束后应检查 17 个 `comparison.json`,而不是只检查 CLI 的进程退出码。 + +## 能力完成目标 + +能力的完成定义为同一能力的 CADFS lowering、CDSL contract、runtime/adapter、 +selector 或 body 语义(需要时)、严格比较回归均具备。仅实现其中一层时只能记为 +“部分完成”。优先级依据全量影响数和 17 样本依赖关系确定。 + +### P0:使特征历史成为完整实体 + +1. 复杂草图与 B-spline:稳定的多 wire、孔洞、闭合排序、退化诊断;开口 wire + 可作为 sweep path,闭合 wire 可作为实体 profile。 +2. 显式多 body 结果模型:body ID、选择集、复制、保留和生命周期;这是 + `booleanBodies`、transform、pattern copy 的前置条件。 +3. sweep、完整 loft、shell 和 booleanBodies:包含实体/曲面模式、导轨或 profile + 对齐、移除面、结果 body 和可诊断的失败边界。 +4. circularPattern 与 mirror:source replay、完整轴/面引用和嵌套 pattern;实例必须 + 是独立的几何结果,不能依赖隐式全局主体。 + +### P0:补齐拉伸终止语义 + +按全量影响数优先完成复杂 `extrude`,以及 through-all、two-sided、up-to-surface、 +up-to-next、up-to-body、up-to-vertex 和 surface/mixed 模式。终止距离必须由目标 body +的真实相交结果或明确的源参考决定,不能用固定默认值近似。 + +### P1:让后续特征引用稳定 + +1. 记录内核拓扑 delta:`preserved`、`modified`、`generated`、`deleted` 和 feature + 输出角色;不唯一的后继必须诊断为歧义。 +2. 补齐 CAP、SWEPT、OFFSET、INTERSECT、MID_CAP 的 face/edge selector,并保留 + owner 与几何签名。 +3. 完成 line-angle、plane-point、three-point、mid-plane、line-point、curve-point + 基准面,覆盖曲面和曲线切线引用及退化输入。 +4. 完成 fillet、chamfer、hole、revolve/revolve_surface 的参数变体、可行性预检查 + 和拓扑后继处理。 + +### P2:清除全量语料的剩余 FeatureScript 缺口 + +实现 draft、thicken、split、moveFace、deleteFace、replaceFace、完整 transform, +以及 derive/import 的受控外部资产协议。任何依赖外部模型的操作只有在资产身份、 +输入、版本和失败方式均可复现时,才能计入全量成功。 + +## 实现参考与边界 + +`/Users/lk/Downloads/SimpleCADAPI-master 4` 可作为 OCP 调用和拓扑建模的实现 +参考,而不是可直接替换的 engine: + +- sweep、loft、shell 分别可参考 `BRepOffsetAPI_MakePipeShell`、 + `BRepOffsetAPI_ThruSections` 和 shell builder 的调用边界、失败处理和 shape + 输出方式。 +- boolean、fillet、chamfer 应参考其内核 `Modified`、`Generated`、`IsDeleted` + 的拓扑 delta 采集方式,落实本计划要求的 feature 输出角色和 selector 后继证据。 +- transform 和 pattern 应作用于明确的 shape/body 输入,并保留每个实例的独立结果。 + +SimpleCADAPI 强制将 boolean 结果收敛为单个 `Solid`,不满足 CADFS 的多 body、 +保留工具体、复制和 body 生命周期语义。实现时只能借鉴 OCP 调用与拓扑证据模型, +必须保留本项目的 CDSL schema、runtime adapter 和显式 body-result contract。 + +## Source Exception Evidence + +严格重建失败必须先按下表归类。只有“已验证 source exception”能够从全量 +成功分母移除;候选项仍是未完成样本,必须保留 source、CDSL、STEP 和比较工件。 + +| 分类 | 样本 | 当前证据 | 处理要求 | +| --- | --- | --- | --- | +| 已验证 source exception | 无 | 当前 17 样本没有已经证明可豁免的 source exception。 | 不得从验收分母移除。 | +| engine / converter 缺陷(受限路径已修复) | `00710855` | F5 的 CADFS IMPRINT cut 已由错误圆盘改为 `r=19..25.5 mm` 环。对于 F10,runtime 仅在显式同轴 surface shell 在 selected plane 提供内边界证据时,才以源 `width=10 mm` 构造受限圆锥倒角;完整 history 已可执行。 | 将该受限模式扩展为一般 surface/solid split、topology delta 和后继 selector;不得缩小源倒角宽度。 | +| source / STEP 精度候选 | `00710855` | 完整 history 的 surface 最大差为 `0.0035 mm`,面积、bbox 与实体数满足 strict;但 source F8 E14 是 `r=22 mm`,gold STEP 对应圆柱是 `r=21.9965 mm`,导致体积相对误差 `7.998e-5` 超过 strict。 | 保存 source literal 与 STEP 测量证据;未获得 source export 版本证明前不得回填 gold 半径,也不得从验收分母移除。 | +| source / STEP 差异候选 | `00035682` | FeatureScript 圆柱/孔径 literal 的精度低于原 STEP 的测量值;现有拓扑类型和数量相同,但严格体积/面积不一致。 | 保存原始精度证据;复核 source 导出版本后才能定为 exception。 | +| source / STEP 差异候选 | `00192744` | F3/F5 的 frame 与 `UP_TO_NEXT` 已修复,F7/F12 的 `COPY(BODY)` 删除也已 replay,完整 history 可执行且 bbox 对齐;但 source F15 将 F1 移动约 `-10000 mm`,gold STEP 仍位于约 `y=[-500,500]`。按 source 缺省 `NEW` 保留 8 个实体,gold 只有 1 个,体积相对误差仍为 `1.001e-3`。 | 保持严格失败并完成通用 multi-body transform/body lifecycle;不以 gold STEP 反推、伪造 transform 参数或把 source `NEW` 改成 `ADD`。 | +| source / STEP 差异候选 | `00212904` | 原 STEP 出现 source FeatureScript history 未表达的正交 cut strip。 | 保留对比工件;除非 source 能提供缺失 feature,不得在 converter 伪造几何。 | +| source / STEP 精度候选 | `00835610` | 完整 history 可执行且 RP 通过,严格最大表面差约 `0.014 mm`;FeatureScript 值与 STEP 存在约 `0.014 mm` 的 literal 差异。 | 不能从 gold STEP 回填尺寸;继续标为严格失败,等待 source 版本证据。 | + +## 全量完成门槛 + +在 17 样本严格通过后,按 capability family 对全量语料滚动运行,并按失败原因选择 +新的最小代表样本。最终报告必须以扫描快照中的全部样本为分母,同时满足: + +1. 每个有效源样本生成语义有效、可执行的 CDSL;`parse_failed`、 + `unsupported_operation`、`unsupported_engine_capability` 和因 converter 丢失 + feature 而导致的 `converted_partial` 均为零。 +2. 每个拥有原始 STEP 的样本生成完整 `rebuild.step`;`rebuild_failed` 和 + `rebuild_timeout` 均为零。 +3. 每个可比较样本严格通过比较;`comparison_timeout`、`rebuilt_rejected` 和仅 + `rebuilt_approximate` 均不计入完成。 +4. 任何合法 source exception 都有单独清单、原始证据和可复现原因;它不进入成功 + 分母,也不得吞并为“未分类失败”。 +5. 生成全量 capability-gap、比较汇总和逐样本证据,确保任一退化都可定位到 + converter、engine、源数据或比较基础设施。 + +17 个样本覆盖了已观察到的特征类别,但不是全量正确性的数学证明。只有滚动的 +全量比较通过,才能声称完成全量重建。 + +## 每次修复的执行规则 + +1. 先重跑发生退化的代表样本,并读取 `history.json`、`candidate.cdsl.json`、 + `bound.cdsl.json`、`rebuild.json` 和 `comparison.json`。 +2. 将问题归类为:CADFS parser/lowering 丢失或误译、CDSL schema/contract 无表达、 + engine/adapter 几何能力不足、selector/body 引用错误、源数据异常,或比较基础设施 + 问题。证据不足时不得猜测分类。 +3. 只在责任层修复:转换问题修改 CADFS lowering;执行问题扩展 engine;两者都涉及 + 时分开提交证据和测试。保留最新可执行 STEP/GLB 和失败诊断。 +4. 每项代码改动至少重跑受影响样本、17 样本严格回归和相应单元测试;能力状态与 + 证据同步到本地能力台账。全量统计变化后重新生成 `regression/manifest.json`。 +5. 不以关闭诊断、跳过 feature、降低 strict 阈值或用未说明的默认尺寸换取通过。 + +## 阶段交付物 + +| 阶段 | 必须交付 | 通过条件 | +| --- | --- | --- | +| R0:证据稳定 | 刷新的 regression manifest、17 份完整工件 | 每个样本可重复运行并可定位当前失败。 | +| R1:代表样本 | 17 个严格比较报告、能力台账更新 | `17/17 strict_pass`。 | +| R2:能力扩展 | 按能力 family 的 converter/engine/selector 实现及回归 | 对应缺口不再造成该 family 的失败。 | +| R3:全量验证 | 全量 CDSL、STEP、比较结果和汇总报告 | 满足“全量完成门槛”的五项条件。 | diff --git a/cadfs_to_cdsl/README.md b/cadfs_to_cdsl/README.md index 28f2a28f..48008fc0 100644 --- a/cadfs_to_cdsl/README.md +++ b/cadfs_to_cdsl/README.md @@ -15,6 +15,21 @@ All stages are resumable. Use `--force` after changing converter behavior. The original CADFS directory is read-only; generated evidence is written under `cadfs_to_cdsl/output/samples//`. +## Full corpus one-command runner + +Run the full conversion, rebuild, comparison, and Markdown report with: + +```bash +./cadfs_to_cdsl/run_full_rebuild.sh +``` + +The default is a forced fresh pass. Before it begins, the previous +`cadfs_to_cdsl/output` directory is moved to +`cadfs_to_cdsl/output-history//`; it is never deleted or mixed +with the new result. Use `--resume` to reuse the existing output without +archiving it, or `--compare-mode strict` for strict acceptance. See +`./cadfs_to_cdsl/run_full_rebuild.sh --help` for worker and timeout options. + ## Representative regression pool Generate the checked-in representative manifest after a full corpus conversion: diff --git a/cadfs_to_cdsl/compare.py b/cadfs_to_cdsl/compare.py index 027b253d..27a536cd 100644 --- a/cadfs_to_cdsl/compare.py +++ b/cadfs_to_cdsl/compare.py @@ -9,11 +9,13 @@ RP = {"surface_mm": 0.02, "bbox_mm": 0.02, "volume_relative": 0.005, "area_relat def _assess(report: dict[str, Any], limits: dict[str, float]) -> dict[str, Any]: surface = report["surface"]; metrics = report["metrics"] - maximum = max(surface["gold_to_rebuilt"]["max_mm"], surface["rebuilt_to_gold"]["max_mm"]) - p99 = max(surface["gold_to_rebuilt"]["p99_mm"], surface["rebuilt_to_gold"]["p99_mm"]) + maximum = [surface["gold_to_rebuilt"]["max_mm"], surface["rebuilt_to_gold"]["max_mm"]] + p99 = [surface["gold_to_rebuilt"]["p99_mm"], surface["rebuilt_to_gold"]["p99_mm"]] + surface_ready = all(isinstance(value, (int, float)) for value in [*maximum, *p99]) checks = { - "surface_max": maximum <= limits["surface_mm"], "surface_p99": p99 <= limits["surface_mm"], - "bbox": metrics["bbox_max_delta_mm"] <= limits["bbox_mm"], + "surface_max": surface_ready and max(maximum) <= limits["surface_mm"], + "surface_p99": surface_ready and max(p99) <= limits["surface_mm"], + "bbox": isinstance(metrics["bbox_max_delta_mm"], (int, float)) and metrics["bbox_max_delta_mm"] <= limits["bbox_mm"], "volume": metrics["volume_relative_error"] <= limits["volume_relative"], "area": metrics["surface_area_relative_error"] <= limits["area_relative"], "solid_count": metrics["gold_solid_count"] == metrics["rebuilt_solid_count"], @@ -22,8 +24,13 @@ def _assess(report: dict[str, Any], limits: dict[str, float]) -> dict[str, Any]: def compare_steps(gold_step: Path, rebuilt_step: Path, *, surface_tessellation_mm: float = 0.05) -> dict[str, Any]: - from onshape_to_cdsl.src.onshape_to_cdsl.compare import strict_compare - raw = strict_compare(gold_step, rebuilt_step, surface_tolerance_mm=surface_tessellation_mm) + from onshape_to_cdsl.compare import strict_compare + raw = strict_compare( + gold_step, + rebuilt_step, + surface_tolerance_mm=surface_tessellation_mm, + fast_reject_relative_error=RP["volume_relative"], + ) strict, rp = _assess(raw, STRICT), _assess(raw, RP) decision = "strict_pass" if strict["passed"] else "approximate_pass" if rp["passed"] else "rejected" return {"schema": "cadfs_to_cdsl.comparison.v1", "gold_step": str(gold_step), "rebuilt_step": str(rebuilt_step), "raw": raw, "strict": strict, "rp": rp, "decision": decision} diff --git a/cadfs_to_cdsl/featurescript_parser.py b/cadfs_to_cdsl/featurescript_parser.py index 7dde2219..c2ca377d 100644 --- a/cadfs_to_cdsl/featurescript_parser.py +++ b/cadfs_to_cdsl/featurescript_parser.py @@ -63,21 +63,44 @@ class Parser: return left def statements(self) -> list[Call]: - found: list[Call] = []; environment: dict[str, Any] = {} + found: list[Call] = []; scopes: list[dict[str, Any]] = [{}] + + def environment() -> dict[str, Any]: + resolved: dict[str, Any] = {} + for scope in scopes: resolved.update(scope) + return resolved + + def assignment_scope(name: str) -> dict[str, Any]: + return next((scope for scope in reversed(scopes) if name in scope), scopes[-1]) + while self.peek().kind != "eof": + if self.accept("{"): + scopes.append({}); continue + if self.accept("}"): + if len(scopes) > 1: scopes.pop() + continue + if self.peek().value == "var": + self.pop() + if self.peek().kind == "ident": + name = self.pop().value + if self.accept("="): + scopes[-1][name] = _resolve(self.expression(), environment()) + else: scopes[-1][name] = name + if isinstance(scopes[-1][name], Call) and scopes[-1][name].name == "newSketch": + found.append(scopes[-1][name]) + continue if self.peek().kind == "ident" and self.i + 1 < len(self.tokens) and self.tokens[self.i + 1].value == "=": name = self.pop().value; self.pop() try: - environment[name] = self.expression() - if isinstance(environment[name], Call) and environment[name].name == "newSketch": - value = environment[name] - value.args = [_resolve(arg, environment) for arg in value.args] + scope = assignment_scope(name); scope[name] = _resolve(self.expression(), environment()) + if isinstance(scope[name], Call) and scope[name].name == "newSketch": + value = scope[name] found.append(value) except Exception: pass elif self.peek().kind == "ident" and self.i + 1 < len(self.tokens) and self.tokens[self.i + 1].value == "(": value = self.expression() if isinstance(value, Call): - value.args = [_resolve(arg, environment) for arg in value.args] + value.args = [_resolve(arg, environment()) for arg in value.args] found.append(value) else: self.i += 1 return found @@ -119,7 +142,7 @@ def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR: if model.sketches: args = _arg_map(call); eid = str(call.args[1]) if len(call.args) > 1 else f"E{len(model.sketches[-1].entities)}" model.sketches[-1].entities.append(FeatureIR(eid, call.name, args, line_start=call.line, raw_source=call.name)) - elif call.name in {"extrude", "revolve", "fillet", "chamfer", "hole", "linearPattern", "mirror", "cPlane", "referenceAxis", "shell", "loft", "sweep", "circularPattern", "booleanBodies", "transform"}: + elif call.name in {"extrude", "revolve", "fillet", "chamfer", "hole", "linearPattern", "mirror", "cPlane", "referenceAxis", "shell", "loft", "sweep", "circularPattern", "booleanBodies", "deleteBodies", "transform", "draft", "thicken", "split", "moveFace", "replaceFace", "deleteFace", "derive"}: fid = symbolic_string(call.args[1]) if len(call.args) > 1 else f"feature_{len(model.features)}" feature_ir = FeatureIR(fid, call.name, _arg_map(call), line_start=call.line, raw_source=call.name) model.features.append(feature_ir); model.steps.append(feature_ir) diff --git a/cadfs_to_cdsl/lowering.py b/cadfs_to_cdsl/lowering.py index ce37a358..d579b0f1 100644 --- a/cadfs_to_cdsl/lowering.py +++ b/cadfs_to_cdsl/lowering.py @@ -1,6 +1,7 @@ from __future__ import annotations import math +from copy import deepcopy from dataclasses import dataclass from typing import Any from .featurescript_parser import symbolic_string @@ -8,7 +9,7 @@ from .ir import Call, FeatureIR, ModelIR, SketchIR from .query_parser import parse_query, walk_calls -UNSUPPORTED = {"shell", "sweep", "draft", "thicken", "split", "booleanBodies", "moveFace", "replaceFace", "deleteFace", "import", "derive"} +UNSUPPORTED = {"draft", "thicken", "split", "moveFace", "replaceFace", "deleteFace", "import", "derive"} PLANES = { "Top": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., 0., 1.]}, "Front": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., -1., 0.]}, @@ -16,6 +17,14 @@ PLANES = { } +# CADFS 未携带标准孔表的实体定义。下列条目由 source STEP 验证:该组合在 +# CADFS 的 B-rep 中不是普通 blind counterbore,而是简化为贯穿的攻丝孔。 +# 不能为未知目录项推测尺寸;未列入的孔仍按显式 FeatureScript 尺寸 lower。 +_STANDARD_TAPPED_THROUGH_BORE_DIAMETERS = { + ("ISO", "M10", "Clearance & tapped"): 15.0, +} + + @dataclass class LoweringResult: cdsl: dict[str, Any] | None @@ -52,6 +61,20 @@ def _number(value: Any, units: bool = False) -> float: raise ValueError(f"not a constant number: {plain(value)!r}") +def _lookup_table_definition(value: Any) -> dict[str, str]: + for call in walk_calls(value): + if call.name != "lookupTablePath" or not call.args or not isinstance(call.args[0], dict): continue + return {str(key): str(item) for key, item in call.args[0].items() if isinstance(item, (str, int, float))} + return {} + + +def _standard_tapped_through_bore_diameter(params: dict[str, Any], style: str, end_style: str) -> float | None: + if style.upper() not in {"COUNTERBORE", "C_BORE"} or "BLIND" not in end_style or not _bool(params.get("isTappedThrough")): + return None + definition = _lookup_table_definition(params.get("standardBlindInLast")) + return _STANDARD_TAPPED_THROUGH_BORE_DIAMETERS.get((definition.get("standard", ""), definition.get("size", ""), definition.get("type", ""))) + + def _point(value: Any) -> list[float]: if isinstance(value, Call) and value.name == "__binary__" and value.args[1] == "*": scale = _number(value.args[2], True); point = _point(value.args[0]); return [v * scale for v in point] @@ -102,6 +125,27 @@ def _rotate_frame(frame: dict[str, Any], axis: dict[str, list[float]], angle_rad } +def _reflect_vector(value: list[float], plane: dict[str, Any]) -> list[float]: + normal = _unit(list(plane["normal"]), "mirror plane normal is degenerate") + amount = 2.0 * _dot(value, normal) + return [value[index] - amount * normal[index] for index in range(3)] + + +def _reflect_point(point: list[float], plane: dict[str, Any]) -> list[float]: + normal = _unit(list(plane["normal"]), "mirror plane normal is degenerate") + amount = 2.0 * _dot(_sub(point, plane["origin_mm"]), normal) + return [point[index] - amount * normal[index] for index in range(3)] + + +def _reflect_frame(frame: dict[str, Any], plane: dict[str, Any]) -> dict[str, Any]: + return { + **frame, + "origin_mm": _reflect_point(frame["origin_mm"], plane), + "x_dir": _reflect_vector(frame["x_dir"], plane), + "normal": _reflect_vector(frame["normal"], plane), + } + + def _y_dir(plane: dict[str, Any]) -> list[float]: return _cross(plane["normal"], plane["x_dir"]) @@ -115,7 +159,41 @@ def _shift_plane(plane: dict[str, Any], distance: float) -> dict[str, Any]: def _oriented_plane(plane: dict[str, Any], normal_sign: float, distance: float = 0.0) -> dict[str, Any]: shifted = _shift_plane(plane, distance) - return {**shifted, "normal": [normal_sign * value for value in plane["normal"]]} + # 翻转端盖法向时同步翻转 x 轴,保持 normal × x_dir 的草图局部 y 轴 + # 不变。否则同一 CADFS 草图会在附着端盖后被镜像。 + return { + **shifted, + "x_dir": [normal_sign * value for value in plane["x_dir"]], + "normal": [normal_sign * value for value in plane["normal"]], + } + + +def _attachment_plane(plane: dict[str, Any]) -> dict[str, Any]: + """Choose the global-origin projection as the later sketch attachment origin. + + CADFS keeps a plane's physical location and the plane coordinates used by + a later sketch separate. The latter is the global origin projected onto + the physical plane, not necessarily the selected point or profile centre. + Keep this only as an attachment frame; selectors and body topology + continue to use the physical frame. + """ + normal = plane["normal"] + origin = plane["origin_mm"] + distance = _dot(origin, normal) + return {**plane, "origin_mm": [distance * normal[index] for index in range(3)]} + + +def _loft_cap_frames(start: dict[str, Any], end: dict[str, Any]) -> dict[str, dict[str, Any]] | None: + """Record the physical outer normals for the first and last loft sections.""" + direction = _sub(end["origin_mm"], start["origin_mm"]) + if math.sqrt(_dot(direction, direction)) <= 1e-9: + return None + # Loft start/end caps face away from the section sequence. Sketch normals do + # not necessarily have that orientation, so retain the CDSL frame handedness + # while choosing the actual B-rep exterior normal for CAP references. + start_sign = 1.0 if _dot(start["normal"], direction) <= 0 else -1.0 + end_sign = 1.0 if _dot(end["normal"], direction) >= 0 else -1.0 + return {"start": _oriented_plane(start, start_sign), "end": _oriented_plane(end, end_sign)} def _frame(origin: list[float], x_dir: list[float], normal: list[float]) -> dict[str, list[float]]: @@ -124,20 +202,68 @@ def _frame(origin: list[float], x_dir: list[float], normal: list[float]) -> dict return {"origin_mm": origin, "x_dir": _unit(x_dir, "reference plane x direction is degenerate"), "normal": normal} -def _plane_from_query(value: Any, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]] | None = None) -> dict[str, Any]: +def _pattern_copy_cap_plane(value: Any, feature_frames: dict[str, dict[str, Any]]) -> dict[str, Any] | None: + """Resolve one mirrored COPY(CAP_FACE) frame without flattening its source query.""" + try: + _call, pattern_owner, topology, kind, definition = _direct_make_query(value) + except ValueError: + return None + if topology != "COPY" or kind != "face": + return None + derived = definition.get("derivedFrom") + if derived is None: + return None + try: + _source, source_owner, source_topology, source_kind, source_definition = _direct_make_query(derived) + except ValueError: + return None + if source_topology != "CAP_FACE" or source_kind != "face": + return None + pattern_frame = feature_frames.get(pattern_owner) or {} + transform = pattern_frame.get("copy_transform") or {} + if transform.get("type") != "mirror" or source_owner not in pattern_frame.get("copy_source_features", []): + return None + source_frame = feature_frames.get(source_owner) + if source_frame is None or "start" not in source_frame or "end" not in source_frame: + return None + cap = source_frame["start" if _bool(source_definition.get("isStart")) else "end"] + plane = transform.get("plane") + if not isinstance(plane, dict): + return None + reflected = _reflect_frame(cap, plane) + # 镜像会把 source frame 变成左手系;CDSL workplane 必须始终由 + # normal × x_dir 推导局部 y 轴。翻转 x_dir 后,镜像 CAP 上新草图的 + # source-local y 方向保持物理不变,FeatureScript 导出的镜像侧 x 坐标 + # 也能落回正确的世界位置。 + reflected["x_dir"] = [-value for value in reflected["x_dir"]] + return reflected + + +def _plane_from_query( + value: Any, + feature_frames: dict[str, dict[str, Any]], + sketch_by_source: dict[str, dict[str, Any]] | None = None, + entity_by_sketch: dict[str, dict[str, dict[str, Any]]] | None = None, +) -> dict[str, Any]: for call in walk_calls(value): if call.name in {"makeId", "qCreatedBy"}: text = " ".join(symbolic_string(arg) for arg in call.args) for name, plane in PLANES.items(): if f"{name}.planeOp" in text: return dict(plane) + copied_cap = _pattern_copy_cap_plane(value, feature_frames) + if copied_cap is not None: + return copied_cap query = parse_query(value) if query.topology_type == "IMPRINT" and sketch_by_source and query.source_sketch in sketch_by_source: return dict(sketch_by_source[query.source_sketch]["workplane"]) + if query.topology_type == "OFFSET_FACE" and sketch_by_source is not None and entity_by_sketch is not None: + return _offset_face_plane(value, feature_frames, sketch_by_source, entity_by_sketch) frame = feature_frames.get(query.owner_feature or "") if frame and query.topology_type == "CAP_FACE": - return dict(frame["start" if query.is_start is not False else "end"]) + cap = "start" if query.is_start is not False else "end" + return dict(frame.get(f"{cap}_attachment") or frame[cap]) if frame and frame.get("start") == frame.get("end") and "qCreatedBy" in query.calls: - return dict(frame["start"]) + return dict(frame.get("start_attachment") or frame["start"]) raise ValueError("unsupported or unresolved sketch workplane") @@ -145,15 +271,211 @@ def _bound_name(value: Any) -> str: return str(value or "BLIND").split(".")[-1].upper() +def _is_new_body_operation(value: str) -> bool: + return value.rsplit(".", 1)[-1] == "NEW" + + +def _has_active_body(features: list[dict[str, Any]]) -> bool: + """Whether the lowered history already has a result body to extend.""" + return any(feature.get("atomic_id") not in {"reference_plane", "reference_axis"} for feature in features) + + def _end_condition(value: Any) -> dict[str, Any]: name = _bound_name(value) if name == "BLIND": return {"type": "blind", "solidworks_code": 0} if name == "SYMMETRIC": return {"type": "mid_plane", "solidworks_code": 8} if name == "THROUGH_ALL": return {"type": "through_all", "solidworks_code": 1} if name == "UP_TO_NEXT": return {"type": "through_next", "solidworks_code": 4} + if name == "UP_TO_SURFACE": return {"type": "up_to_surface", "solidworks_code": 2} + if name == "UP_TO_BODY": return {"type": "up_to_body", "solidworks_code": 6} + if name == "UP_TO_VERTEX": return {"type": "up_to_vertex", "solidworks_code": 5} raise UnsupportedCapability(f"extrude_extent:{name.lower()}", f"current CDSL atomic set has no exact extrusion operation for {name}") +def _extent_reference( + value: Any, + expected_kind: str, + feature_frames: dict[str, dict[str, Any]], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> dict[str, Any]: + query = parse_query(value) + if query.kind not in {expected_kind, f"entitytype.{expected_kind}"}: + raise ValueError(f"extrude extent target is not a {expected_kind}") + if expected_kind == "face" and query.topology_type in {"CAP_FACE", "SWEPT_FACE"}: + return _face_reference(value, feature_frames, sketch_by_source, entity_by_sketch) + if not query.owner_feature: + raise ValueError("extrude extent target owner is unresolved") + owner = f"f_{query.owner_feature}" + reference = { + "kind": expected_kind, + "owner_feature_id": owner, + "stable_id": f"cadfs_{owner}_{expected_kind}", + "source": "solidworks", + "confidence": 1.0, + } + return reference + + +def _direct_make_query(value: Any) -> tuple[Call, str, str, str, dict[str, Any]]: + """Return one direct CADFS makeQuery without flattening its provenance.""" + current = value + if isinstance(current, Call) and current.name == "qUnion" and len(current.args) == 1 and isinstance(current.args[0], list): + if len(current.args[0]) != 1: raise ValueError("query union does not identify one topology item") + current = current.args[0][0] + if not isinstance(current, Call) or current.name != "makeQuery" or len(current.args) < 3: + raise ValueError("topology query is unresolved") + owner_text = symbolic_string(current.args[0]) + if "F" not in owner_text: raise ValueError("topology query owner is unresolved") + owner = owner_text[owner_text.find("F"):].split(".", 1)[0] + topology = str(current.args[1]).split(".")[-1].upper() + kind = str(current.args[2]).split(".")[-1].lower() + definition = next((arg for arg in current.args if isinstance(arg, dict)), {}) + return current, owner, topology, kind, definition + + +def _face_reference( + value: Any, + feature_frames: dict[str, dict[str, Any]], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + *, + owner_feature_id: str | None = None, + binding_feature_id: str | None = None, + match_mode: str | None = None, + rotation: tuple[dict[str, list[float]], float] | None = None, +) -> dict[str, Any]: + """Lower one CAP/SWEPT face query into a bindable CDSL selector.""" + _call, owner, topology, kind, _definition = _direct_make_query(value) + if kind != "face" or topology not in {"CAP_FACE", "SWEPT_FACE"}: + raise ValueError("intersection source is not a supported face query") + source = parse_query(value) + selector_owner = owner_feature_id or f"f_{owner}" + reference: dict[str, Any] = { + "kind": "face", + "owner_feature_id": selector_owner, + "stable_id": f"cadfs_{selector_owner}_{topology.lower()}", + "source": "solidworks", + "confidence": 1.0, + } + frame = feature_frames.get(owner) + geometry: dict[str, Any] + point: list[float] + if topology == "CAP_FACE": + if frame is None or "start" not in frame or "end" not in frame: + raise ValueError("cap face source frame is unresolved") + cap = frame["start" if source.is_start else "end"] + point, normal = list(cap["origin_mm"]), list(cap["normal"]) + if rotation is not None: + axis, angle = rotation; point, normal = _rotate_point(point, axis, angle), _rotate(normal, axis["direction"], angle) + geometry = {"normal": normal, "plane_offset_mm": _dot(normal, point)} + else: + sketch = sketch_by_source.get(source.source_sketch or "") + entity = (entity_by_sketch.get(source.source_sketch or "") or {}).get(source.source_entity or "") + if sketch is None or entity is None: + raise ValueError("swept face source geometry is unresolved") + # 直接 source 草图可能已被后续 transform 烘焙到 feature profile。 + # SWEPT_FACE 必须在该实际 profile workplane 上还原,不能回退到变换前 + # 的草图坐标系,否则 pattern copy 的平面 selector 会落在错误位置。 + plane = (frame or {}).get("profile") or sketch["workplane"] + if entity["type"] == "circle": + point, direction = _global(plane, entity["center"]), list(plane["normal"]) + if rotation is not None: + axis, angle = rotation; point, direction = _rotate_point(point, axis, angle), _rotate(direction, axis["direction"], angle) + geometry = {"axis_origin_mm": point, "axis_direction": direction, "radius_mm": entity["radius_mm"]} + elif entity["type"] == "line": + point = _global(plane, entity["start"]); end = _global(plane, entity["end"]) + normal = _unit(_cross(_sub(end, point), plane["normal"]), "swept face source line is degenerate") + if rotation is not None: + axis, angle = rotation; point, normal = _rotate_point(point, axis, angle), _rotate(normal, axis["direction"], angle) + geometry = {"normal": normal, "plane_offset_mm": _dot(normal, point)} + if rotation is None and frame and frame.get("start") and frame.get("end"): + span = math.dist(frame["start"]["origin_mm"], frame["end"]["origin_mm"]) + length = math.dist(point, end) + if span > 1e-6 and length > 1e-6: + # 后续圆角和布尔可能将一个侧壁分裂成同平面的多个 face。 + # 原始 SWEPT_FACE 的母线长度与拉伸跨度给出确定的面积下界, + # 可排除与其共面的微小端盖,而不要求内核保留原始面积。 + geometry["minimum_area_mm2"] = length * span * 0.5 + else: + raise ValueError("swept face source entity is unsupported") + reference["geometry"] = geometry + if binding_feature_id is not None: reference["binding_feature_id"] = binding_feature_id + if match_mode is not None: reference["match_mode"] = match_mode + return reference + + +def _pattern_copy_face_reference( + value: Any, + feature_frames: dict[str, dict[str, Any]], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + feature_by_id: dict[str, dict[str, Any]], +) -> dict[str, Any]: + """Preserve a circular-pattern COPY face as one specific replay instance.""" + _call, pattern_owner, topology, kind, definition = _direct_make_query(value) + if topology != "COPY" or kind != "face": raise ValueError("intersection copy source is unresolved") + derived = definition.get("derivedFrom") + if derived is None: raise ValueError("pattern copy has no derived face") + _source_call, source_owner, _source_topology, source_kind, _source_definition = _direct_make_query(derived) + if source_kind != "face": raise ValueError("pattern copy source is not a face") + pattern_id = f"f_{pattern_owner}"; source_id = f"f_{source_owner}" + pattern = feature_by_id.get(pattern_id) + if pattern is None or pattern.get("atomic_id") != "pattern_circular": raise ValueError("pattern copy owner is not a circular pattern") + if source_id not in (pattern.get("params") or {}).get("source_feature_ids", []): + raise ValueError("pattern copy source feature is not replayed by its owner") + try: instance = int(str(definition.get("instanceName"))) + except (TypeError, ValueError) as error: raise ValueError("pattern copy instance is unresolved") from error + axis = (pattern.get("params") or {}).get("axis") + count = int((pattern.get("params") or {}).get("pattern_count") or 0) + if not isinstance(axis, dict) or count < 1 or instance < 0 or instance >= count: + raise ValueError("pattern copy instance transform is unresolved") + angle = math.radians(float((pattern.get("params") or {}).get("sweep_angle_deg") or 360.0) * instance / count) + return _face_reference( + derived, feature_frames, sketch_by_source, entity_by_sketch, + owner_feature_id=f"{pattern_id}.c{instance}.{source_id}", + binding_feature_id=pattern_id, + rotation=(axis, angle), + ) + + +def _intersection_vertex_reference( + value: Any, + feature_frames: dict[str, dict[str, Any]], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + feature_by_id: dict[str, dict[str, Any]], + feature_id: str, +) -> dict[str, Any]: + """Lower a CADFS INTERSECT vertex without replacing it with a coordinate.""" + _call, owner, topology, kind, definition = _direct_make_query(value) + if topology != "INTERSECT" or kind != "vertex": raise ValueError("up-to-vertex target is not an INTERSECT vertex") + derived = definition.get("derivedFrom") + if not isinstance(derived, list) or len(derived) < 2: + raise ValueError("intersection vertex has no complete face provenance") + components = [] + for item in derived: + _item_call, item_owner, item_topology, item_kind, _item_definition = _direct_make_query(item) + if item_kind != "face": raise ValueError("intersection vertex source is not a face") + if item_topology == "COPY": + components.append(_pattern_copy_face_reference(item, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id)) + elif item_topology == "SWEPT_FACE": + components.append(_face_reference( + item, feature_frames, sketch_by_source, entity_by_sketch, + owner_feature_id=f"f_{item_owner}", binding_feature_id=f"f_{item_owner}", match_mode="all", + )) + else: + raise ValueError(f"intersection vertex source topology {item_topology} is unsupported") + return { + "kind": "vertex", + "owner_feature_id": f"f_{owner}", + "stable_id": f"cadfs_f_{feature_id}_intersect_vertex", + "source": "solidworks", + "confidence": 1.0, + "intersection_of": components, + } + + def _arc(start: list[float], mid: list[float], end: list[float]) -> dict[str, Any]: ax, ay = start; bx, by = mid; cx, cy = end d = 2 * (ax*(by-cy) + bx*(cy-ay) + cx*(ay-by)) @@ -161,47 +483,232 @@ def _arc(start: list[float], mid: list[float], end: list[float]) -> dict[str, An ux = ((ax*ax+ay*ay)*(by-cy)+(bx*bx+by*by)*(cy-ay)+(cx*cx+cy*cy)*(ay-by))/d uy = ((ax*ax+ay*ay)*(cx-bx)+(bx*bx+by*by)*(ax-cx)+(cx*cx+cy*cy)*(bx-ax))/d cross = (mid[0]-start[0])*(end[1]-mid[1])-(mid[1]-start[1])*(end[0]-mid[0]) - return {"type": "arc", "start": start, "end": end, "center": [ux, uy], "radius_mm": math.hypot(ax-ux, ay-uy), "clockwise": cross < 0} + clockwise = cross < 0 + # CADFS 的倒圆角会把中点按显示精度写回 FeatureScript。端点已经完整 + # 表达等半径直角圆弧时,优先从端点恢复原始圆心,避免把显示精度误作 + # 几何精度;任意三点圆弧仍保持原来的外接圆求解。 + for center in ([ax, cy], [cx, ay]): + first = [ax - center[0], ay - center[1]]; second = [cx - center[0], cy - center[1]] + radius = math.hypot(*first) + if radius <= 1e-9 or abs(math.hypot(*second) - radius) > 1e-9 or abs(_dot(first, second)) > 1e-9: + continue + start_angle = math.atan2(first[1], first[0]); end_angle = math.atan2(second[1], second[0]) + sweep = end_angle - start_angle + if clockwise and sweep >= 0: sweep -= math.tau + elif not clockwise and sweep <= 0: sweep += math.tau + expected = [center[0] + radius * math.cos(start_angle + sweep / 2), center[1] + radius * math.sin(start_angle + sweep / 2)] + if math.dist(mid, expected) <= max(1e-6, radius * 5e-4): + return {"type": "arc", "start": start, "end": end, "center": center, "radius_mm": radius, "clockwise": clockwise} + return {"type": "arc", "start": start, "end": end, "center": [ux, uy], "radius_mm": math.hypot(ax-ux, ay-uy), "clockwise": clockwise} def _endpoint(segment: dict[str, Any], end: bool = False) -> tuple[int, int]: point = segment["end" if end else "start"]; return round(point[0]*1e5), round(point[1]*1e5) +def _reverse_segment(segment: dict[str, Any]) -> dict[str, Any]: + output = dict(segment) + output["start"], output["end"] = segment["end"], segment["start"] + if output["type"] == "arc": output["clockwise"] = not bool(segment["clockwise"]) + elif output["type"] == "bspline": + # OCC 的 periodic interpolator 对 reversed point list 会求出另一条 + # 曲线。半边图只需要反转其拓扑走向,保留原插值点并以私有标记供 + # 切线和面积计算使用;该标记不会进入 CDSL profile。 + if output.get("periodic"): + output["_reversed"] = not bool(segment.get("_reversed")) + else: + output["points"] = list(reversed(segment["points"])) + parameters = output.get("parameters") + if parameters is not None: + final_parameter = float(parameters[-1]) + output["parameters"] = [final_parameter - float(value) for value in reversed(parameters)] + start_tangent = output.pop("start_tangent", None) + end_tangent = output.pop("end_tangent", None) + if end_tangent is not None: + output["start_tangent"] = [-float(value) for value in end_tangent] + if start_tangent is not None: + output["end_tangent"] = [-float(value) for value in start_tangent] + return output + + +def _segment_tangent(segment: dict[str, Any]) -> tuple[float, float]: + if segment["type"] == "line": + return segment["end"][0] - segment["start"][0], segment["end"][1] - segment["start"][1] + if segment["type"] == "arc": + radius = [segment["start"][0] - segment["center"][0], segment["start"][1] - segment["center"][1]] + return (radius[1], -radius[0]) if segment["clockwise"] else (-radius[1], radius[0]) + points = segment["points"] + if segment.get("periodic") and segment.get("_reversed"): + return points[0][0] - points[-2][0], points[0][1] - points[-2][1] + return points[1][0] - points[0][0], points[1][1] - points[0][1] + + +def _contour_area(segments: list[dict[str, Any]]) -> float: + points: list[list[float]] = [] + for segment in segments: + points.append(segment["start"]) + if segment["type"] == "arc": + start = segment["start"]; end = segment["end"]; center = segment["center"] + start_angle = math.atan2(start[1] - center[1], start[0] - center[0]) + end_angle = math.atan2(end[1] - center[1], end[0] - center[0]) + sweep = end_angle - start_angle + if segment["clockwise"] and sweep >= 0: sweep -= 2 * math.pi + if not segment["clockwise"] and sweep <= 0: sweep += 2 * math.pi + angle = start_angle + sweep / 2 + points.append([center[0] + segment["radius_mm"] * math.cos(angle), center[1] + segment["radius_mm"] * math.sin(angle)]) + elif segment["type"] == "bspline": + interior = segment["points"][1:-1] + points.extend(reversed(interior) if segment.get("_reversed") else interior) + return sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) / 2 + + +def _split_line_vertices(edges: list[dict[str, Any]]) -> list[dict[str, Any]]: + # FeatureScript 允许小轮廓端点落在一条未显式分段的长线上。面环遍历前将 + # 长线按这些顶点切开,避免 T 形连接被误判为开链。 + points = [point for edge in edges for point in (edge["start"], edge["end"])] + output: list[dict[str, Any]] = [] + for edge in edges: + if edge["type"] != "line": + output.append(edge); continue + start, end = edge["start"], edge["end"] + direction = [end[0] - start[0], end[1] - start[1]] + length_squared = direction[0] * direction[0] + direction[1] * direction[1] + if length_squared <= 1e-16: + output.append(edge); continue + cuts = {0.0, 1.0} + for point in points: + delta = [point[0] - start[0], point[1] - start[1]] + parameter = (delta[0] * direction[0] + delta[1] * direction[1]) / length_squared + cross = abs(delta[0] * direction[1] - delta[1] * direction[0]) + if 1e-8 < parameter < 1.0 - 1e-8 and cross <= 1e-7 * max(1.0, math.sqrt(length_squared)): + cuts.add(parameter) + ordered = sorted(cuts) + output.extend({ + "type": "line", + "start": [start[0] + direction[0] * left, start[1] + direction[1] * left], + "end": [start[0] + direction[0] * right, start[1] + direction[1] * right], + } for left, right in zip(ordered, ordered[1:])) + return output + + def _contours(segments: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - circles = [{"role": "unknown", "closed": True, "segments": [item]} for item in segments if item["type"] == "circle"] - edges = [item for item in segments if item["type"] != "circle"]; unused = set(range(len(edges))); contours = []; construction = [] - while unused: - idx = unused.pop(); contour = [edges[idx]]; first = _endpoint(contour[0]); tail = _endpoint(contour[-1], True) - while tail != first: - match = next((j for j in unused if _endpoint(edges[j]) == tail or _endpoint(edges[j], True) == tail), None) - if match is None: - construction.extend(contour); break - unused.remove(match); item = dict(edges[match]) - if _endpoint(item, True) == tail: - item["start"], item["end"] = item["end"], item["start"] - if item["type"] == "arc": item["clockwise"] = not item["clockwise"] - elif item["type"] == "bspline": item["points"] = list(reversed(item["points"])) - contour.append(item); tail = _endpoint(item, True) - if tail == first: contours.append({"role": "unknown", "closed": True, "segments": contour}) - return contours + circles, construction + closed_curves = [{"role": "unknown", "closed": True, "segments": [item]} for item in segments if item["type"] in {"circle", "ellipse"}] + edges = _split_line_vertices([item for item in segments if item["type"] not in {"circle", "ellipse"}]) + # CADFS 草图可包含共享边和多个 qSketchRegion。逐条贪心接线会在第一个 + # 分叉处吃掉共用边,使其余合法区域退化为“open”。这里按局部切线建立平面 + # 半边图;每条有向边沿入射顶点的左侧继续,稳定枚举所有有界面环。 + half_edges: list[dict[str, Any]] = [] + outgoing: dict[tuple[int, int], list[int]] = {} + for edge_index, edge in enumerate(edges): + for forward, segment in ((True, edge), (False, _reverse_segment(edge))): + index = len(half_edges) + half_edges.append({"edge_index": edge_index, "forward": forward, "segment": segment}) + outgoing.setdefault(_endpoint(segment), []).append(index) + for vertex, indexes in outgoing.items(): + del vertex + indexes.sort(key=lambda index: (math.atan2(*reversed(_segment_tangent(half_edges[index]["segment"]))), index)) + next_edge: dict[int, int] = {} + for index, item in enumerate(half_edges): + arrivals = outgoing[_endpoint(item["segment"], True)] + twin = next(candidate for candidate in arrivals if half_edges[candidate]["edge_index"] == item["edge_index"] and half_edges[candidate]["forward"] != item["forward"]) + next_edge[index] = arrivals[(arrivals.index(twin) - 1) % len(arrivals)] + visited: set[int] = set(); used_edges: set[int] = set(); contours = [] + for start in range(len(half_edges)): + if start in visited: continue + chain: list[int] = []; current = start + while current not in visited: + visited.add(current); chain.append(current); current = next_edge[current] + if current != start: continue + contour = [half_edges[index]["segment"] for index in chain] + if contour and _contour_area(contour) > 1e-8: + # `_reversed` belongs only to the planar half-edge traversal. + # A periodic B-spline remains the same physical closed profile + # regardless of the retained OCC edge orientation. + public_segments = [{key: value for key, value in segment.items() if key != "_reversed"} for segment in contour] + contours.append({"role": "unknown", "closed": True, "segments": public_segments}) + used_edges.update(half_edges[index]["edge_index"] for index in chain) + construction = [edge for index, edge in enumerate(edges) if index not in used_edges] + return contours + closed_curves, construction + + +def _recover_imperial_grid(items: list[dict[str, Any]], point_records: list[dict[str, Any]]) -> None: + # 部分 CADFS 导出会将英制草图的个别坐标截断为两位小数,但其余坐标仍 + # 保留 0.001 in 网格。只在同一草图已能证明该网格时恢复相邻显示值; + # 半径和 feature 参数不属于这一坐标恢复规则,绝不能在这里猜测或改写。 + grid = 0.0254; tolerance = grid / 5 + coordinates: list[float] = [] + + def collect(point: list[float] | None) -> None: + if isinstance(point, list): coordinates.extend(float(value) for value in point[:2]) + + for item in items: + if item["type"] in {"line", "arc"}: collect(item.get("start")); collect(item.get("end")) + if item["type"] in {"circle", "ellipse"}: collect(item.get("center")) + for point_record in point_records: collect(point_record.get("point")) + + nonzero = [value for value in coordinates if abs(value) > 1e-9] + if not nonzero: return + error = lambda value: abs(value - round(value / grid) * grid) + aligned = [value for value in nonzero if error(value) <= tolerance] + exact = [value for value in nonzero if error(value) <= 1e-8] + if len(aligned) / len(nonzero) < 0.9 or len(exact) * 2 < len(nonzero): return + + def recover(point: list[float] | None) -> None: + if not isinstance(point, list): return + for index, value in enumerate(point[:2]): + if error(float(value)) <= tolerance: point[index] = round(float(value) / grid) * grid + + for item in items: + if item["type"] in {"line", "arc"}: recover(item.get("start")); recover(item.get("end")) + if item["type"] in {"circle", "ellipse"}: recover(item.get("center")) + for point_record in point_records: recover(point_record.get("point")) + + +def _centripetal_parameters(points: list[list[float]]) -> list[float]: + parameters = [0.0] + for start, end in zip(points, points[1:]): + distance = math.dist(start, end) + if distance <= 1e-9: raise ValueError("fit spline has coincident interpolation points") + parameters.append(parameters[-1] + math.sqrt(distance)) + total = parameters[-1] + return [value / total for value in parameters] def _lower_sketch(sketch: SketchIR, plane: dict[str, Any], allow_open: bool = False) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: - segments: list[dict[str, Any]] = []; explicit_construction: list[dict[str, Any]] = []; entities: dict[str, dict[str, Any]] = {}; unsupported = [] + segments: list[dict[str, Any]] = []; explicit_construction: list[dict[str, Any]] = []; entities: dict[str, dict[str, Any]] = {}; points: list[dict[str, Any]] = []; unsupported = [] for entity in sketch.entities: p = entity.params - if entity.operation == "skPoint": entities[entity.feature_id] = {"type": "point", "point": _point(p["position"])}; continue + if entity.operation == "skPoint": + item = {"type": "point", "point": _point(p["position"])}; entities[entity.feature_id] = item; points.append(item); continue if entity.operation == "skLineSegment": item = {"type": "line", "start": _point(p["start"]), "end": _point(p["end"])} elif entity.operation == "skCircle": item = {"type": "circle", "center": _point(p["center"]), "radius_mm": _number(p["radius"], True)} + elif entity.operation == "skEllipse": + major_axis = _unit(_point(p["majorAxis"]), "ellipse major axis is degenerate") + item = {"type": "ellipse", "center": _point(p["center"]), "major_radius_mm": _number(p["majorRadius"], True), "minor_radius_mm": _number(p["minorRadius"], True), "major_axis": major_axis} elif entity.operation == "skArc": item = _arc(_point(p["start"]), _point(p["mid"]), _point(p["end"])) elif entity.operation == "skFitSpline": - points = [_point(point) for point in p.get("points") or []] - if len(points) < 3: raise ValueError("fit spline needs at least 3 points") - item = {"type": "bspline", "start": points[0], "end": points[-1], "points": points} + spline_points = [_point(point) for point in p.get("points") or []] + if len(spline_points) < 3: raise ValueError("fit spline needs at least 3 points") + # FeatureScript 的 skFitSpline 以根号弦长参数化。参数域既决定 + # 插值曲线,也决定端点导数的长度语义;半边遍历反转轮廓时会将 + # 它按反向参数域同步变换,不能交给 OCC 默认重新计算。 + item = { + "type": "bspline", "start": spline_points[0], "end": spline_points[-1], + "points": spline_points, "parameterization": "centripetal", + "parameters": _centripetal_parameters(spline_points), + } + # skFitSpline uses a periodic curve when the author closes it by + # repeating the first interpolation point. Passing that repeated + # point into a non-periodic interpolator changes the profile area + # and produces a seam that does not exist in CADFS. + if _same_point(spline_points[0], spline_points[-1]): + item["periodic"] = True + if p.get("startDerivative") is not None: item["start_tangent"] = _point(p["startDerivative"]) + if p.get("endDerivative") is not None: item["end_tangent"] = _point(p["endDerivative"]) else: unsupported.append(entity.operation); continue (explicit_construction if _bool(p.get("construction")) else segments).append(item); entities[entity.feature_id] = item if unsupported: raise ValueError("unsupported sketch entities: " + ",".join(sorted(set(unsupported)))) + _recover_imperial_grid(segments + explicit_construction, points) if not segments: profile: dict[str, Any] = {"type": "analytic_contours", "contours": []} if explicit_construction: profile["construction"] = explicit_construction @@ -211,9 +718,14 @@ def _lower_sketch(sketch: SketchIR, plane: dict[str, Any], allow_open: bool = Fa else: contours, open_segments = _contours(segments) if open_segments: - if not allow_open: raise ValueError(f"sketch has {len(open_segments)} open non-construction segment(s)") - profile = {"type": "analytic_contours", "contours": [], "construction": explicit_construction + open_segments} - return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities + # qSketchRegion 只会选取闭合区域。与之无关的开链必须保留为 + # reference geometry,不能因为它们存在就丢弃同一草图中的合法 + # 区域;若草图没有任何闭合区域,则仍按原有规则拒绝实体 profile。 + if not contours: + if not allow_open: raise ValueError(f"sketch has {len(open_segments)} open non-construction segment(s)") + profile = {"type": "analytic_contours", "contours": [], "construction": explicit_construction + open_segments} + return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities + explicit_construction = [*explicit_construction, *open_segments] construction = list(explicit_construction) if not contours: profile = {"type": "analytic_contours", "contours": [], "construction": construction} @@ -237,13 +749,732 @@ def _source_refs(value: Any) -> list[tuple[str, str]]: def _source_sketch(params: dict[str, Any]) -> str | None: - for key in ("entities", "sheetProfilesArray"): + for key in ("entities", "sheetProfilesArray", "surfaceEntities"): if key in params: query = parse_query(params[key]) if query.source_sketch: return query.source_sketch return None +def _sketch_region_query(value: Any) -> Any | None: + """Return one explicit qSketchRegion when a profile query also carries context faces.""" + matches = [ + item for item in _queries(value) + if any(call.name == "qSketchRegion" for call in walk_calls(item)) + ] + return matches[0] if len(matches) == 1 else None + + +def _imprint_sketch(value: Any) -> str | None: + for call in walk_calls(value): + if call.name != "makeQuery" or not call.args: continue + owner = symbolic_string(call.args[0]) + if owner.endswith(".imprint"): return owner.split(".", 1)[0] + return None + + +def _profile_selection_side(value: Any) -> float | None: + def numbers(item: Any): + if isinstance(item, (int, float)): yield float(item) + elif isinstance(item, list): + for child in item: yield from numbers(child) + elif isinstance(item, dict): + for child in item.values(): yield from numbers(child) + for call in walk_calls(value): + if call.name in {"TD", "topologyDisambiguation"}: + return next(numbers(call.args), None) + return None + + +def _profile_selection_sides(value: Any) -> list[float]: + """Return every explicit topology side from one nested IMPRINT query.""" + def numbers(item: Any): + if isinstance(item, (int, float)): yield float(item) + elif isinstance(item, list): + for child in item: yield from numbers(child) + elif isinstance(item, dict): + for child in item.values(): yield from numbers(child) + sides = [] + for call in walk_calls(value): + if call.name in {"TD", "topologyDisambiguation"}: + side = next(numbers(call.args), None) + if side is not None: sides.append(side) + return sides + + +def _partitioned_imprint_sketch( + value: Any, + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> str | None: + """Resolve a two-sided split IMPRINT union back to its bounded sketch region. + + A closed sketch curve that intersects a parent-face boundary is split into + two IMPRINT regions. CADFS records the two halves as a qUnion, with the + same outer face side and opposite sides around the intersection vertex. + The union is exactly the curve's bounded sketch region. Do not accept a + partial union, a mixed source curve, or an open curve here: each case has + different material semantics and must remain an explicit capability gap. + """ + roots = _queries(value) + if len(roots) != 2: + return None + source_sketch: str | None = None + source_entity: str | None = None + nested_sides: set[float] = set() + for root in roots: + try: + _call, owner, topology, kind, _definition = _direct_make_query(root) + except ValueError: + return None + info = parse_query(root) + references = { + (sketch, entity) + for sketch, entity in _source_refs(root) + if sketch == owner + } + sides = _profile_selection_sides(root) + if topology != "IMPRINT" or kind != "face" or owner != info.source_sketch: + return None + if len(references) != 1 or len(sides) != 2 or sides[0] >= 0: + return None + sketch, entity = next(iter(references)) + if source_sketch is None: + source_sketch, source_entity = sketch, entity + elif (source_sketch, source_entity) != (sketch, entity): + return None + nested_sides.add(sides[1]) + if source_sketch is None or source_entity is None or nested_sides != {-1.0, 1.0}: + return None + entities = entity_by_sketch.get(source_sketch) or {} + source = entities.get(source_entity) + if source is None: + source_id = max((key for key in entities if source_entity.startswith(key + ".")), key=len, default="") + source = entities.get(source_id) + if source is None or source.get("type") != "bspline" or not _same_point(source["start"], source["end"]): + return None + return source_sketch + + +def _intersect_partition_profile_sketch( + value: Any, + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + feature_id: str, +) -> dict[str, Any] | None: + """Materialize one full circular region selected through a split diameter. + + A sketch line that crosses nested concentric circles is split at the + selected circle intersection. CADFS can select the two face sides around + that split edge instead of naming the circular region directly. Both + sides together are exactly the bounded region inside the selected circle: + either its disk or the annulus ending at the immediately inner circle. + Keep this deliberately narrow. A non-diameter line, any non-circular + source, a partial side selection, or an ambiguous nested query remains an + INTERSECT capability diagnostic rather than an invented profile. + """ + roots = _queries(value) + if len(roots) != 2: + return None + source_sketch: str | None = None + source_line: str | None = None + source_circle: str | None = None + outer_sides: set[float] = set() + for root in roots: + try: + _call, owner, topology, kind, _definition = _direct_make_query(root) + except ValueError: + return None + sides = _profile_selection_sides(root) + if topology != "IMPRINT" or kind != "face" or len(sides) != 2 or sides[1] != -1.0: + return None + references = list(dict.fromkeys(_source_refs(root))) + if len(references) != 2 or any(sketch != owner for sketch, _entity in references): + return None + entities = entity_by_sketch.get(owner) or {} + line_ids = [entity_id for _sketch, entity_id in references if (entities.get(entity_id) or {}).get("type") == "line"] + circle_ids = [entity_id for _sketch, entity_id in references if (entities.get(entity_id) or {}).get("type") == "circle"] + if len(line_ids) != 1 or len(circle_ids) != 1: + return None + if source_sketch is None: + source_sketch, source_line, source_circle = owner, line_ids[0], circle_ids[0] + elif (source_sketch, source_line, source_circle) != (owner, line_ids[0], circle_ids[0]): + return None + outer_sides.add(sides[0]) + if source_sketch is None or source_line is None or source_circle is None or outer_sides != {-1.0, 1.0}: + return None + sketch = sketch_by_source.get(source_sketch) + entities = entity_by_sketch.get(source_sketch) or {} + line, circle = entities.get(source_line), entities.get(source_circle) + if sketch is None or line is None or circle is None: + return None + start, end, center = line.get("start"), line.get("end"), circle.get("center") + if not all(isinstance(point, list) and len(point) == 2 for point in (start, end, center)): + return None + direction = [end[index] - start[index] for index in range(2)] + length = math.hypot(*direction) + if length <= 1e-9: + return None + projection = sum((center[index] - start[index]) * direction[index] for index in range(2)) / (length * length) + distance = abs((center[0] - start[0]) * direction[1] - (center[1] - start[1]) * direction[0]) / length + if not 1e-6 < projection < 1.0 - 1e-6 or distance > 1e-6: + return None + profile = _circle_imprint_region((sketch.get("profile") or {}).get("contours") or [], circle, 1.0) + if profile is None: + return None + output = deepcopy(sketch) + output["id"] = f"{sketch['id']}__{feature_id}" + output["name"] = f"{sketch['name']}__{feature_id}" + output["profile"] = profile + return output + + +def _same_point(left: list[float], right: list[float]) -> bool: + return math.dist(left, right) <= 1e-5 + + +def _matching_profile_segment(segment: dict[str, Any], source: dict[str, Any]) -> int: + if segment.get("type") != source.get("type"): return 0 + if segment["type"] == "circle": + return 1 if _same_point(segment["center"], source["center"]) and abs(segment["radius_mm"] - source["radius_mm"]) <= 1e-5 else 0 + if segment["type"] == "ellipse": + if not ( + _same_point(segment["center"], source["center"]) + and abs(segment["major_radius_mm"] - source["major_radius_mm"]) <= 1e-5 + and abs(segment["minor_radius_mm"] - source["minor_radius_mm"]) <= 1e-5 + ): + return 0 + direction = _dot(segment["major_axis"], source["major_axis"]) + return 1 if direction >= 0 else -1 + if _same_point(segment["start"], source["start"]) and _same_point(segment["end"], source["end"]): return 1 + if _same_point(segment["start"], source["end"]) and _same_point(segment["end"], source["start"]): return -1 + return 0 + + +def _circle_imprint_region(contours: list[dict[str, Any]], source: dict[str, Any], side: float | None) -> dict[str, Any] | None: + """Materialize one bounded side of a nested circular IMPRINT edge.""" + if source.get("type") != "circle" or side is None: return None + center = source["center"]; radius = float(source["radius_mm"]) + circles = sorted({ + float(segment["radius_mm"]) + for contour in contours + for segment in contour.get("segments") or [] + if segment.get("type") == "circle" and _same_point(segment.get("center") or [], center) + }) + index = next((item for item, value in enumerate(circles) if abs(value - radius) <= 1e-5), None) + if index is None: return None + # 标准 skCircle 的正侧位于圆内。若同心圆存在,选区应止于其直接 + # 内层边界;负侧则选择与直接外层边界之间的环域。没有相邻边界时, + # 只有圆内区域是可由 CDSL 闭合 profile 表达的。 + inner = circles[index - 1] if side > 0 and index > 0 else radius + outer = radius if side > 0 else (circles[index + 1] if index + 1 < len(circles) else None) + if outer is None: return None + if inner == 0 or (side > 0 and index == 0): return {"type": "circle", "center": list(center), "radius_mm": outer} + return { + "type": "analytic_contours", + "contours": [ + {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": list(center), "radius_mm": outer}]}, + {"role": "inner", "closed": True, "segments": [{"type": "circle", "center": list(center), "radius_mm": inner}]}, + ], + } + + +def _circle_imprint_union_profile( + contours: list[dict[str, Any]], + query_values: list[Any], + entities: dict[str, dict[str, Any]], +) -> dict[str, Any] | None: + """Materialize one bounded union of concentric circular IMPRINT regions.""" + resolved: list[tuple[dict[str, Any], float]] = [] + for query_value in query_values: + selection = parse_query(query_value) + source_id = max((key for key in entities if selection.source_entity and selection.source_entity.startswith(key)), key=len, default="") + source = entities.get(source_id) + side = _profile_selection_side(query_value) + if source is None or source.get("type") != "circle" or side is None: + return None + resolved.append((source, side)) + if not resolved: + return None + center = resolved[0][0]["center"] + if any(not _same_point(source["center"], center) for source, _side in resolved[1:]): + return None + radii = sorted({ + float(segment["radius_mm"]) + for contour in contours + for segment in contour.get("segments") or [] + if segment.get("type") == "circle" and _same_point(segment.get("center") or [], center) + }) + intervals: list[tuple[float, float]] = [] + for source, side in resolved: + radius = float(source["radius_mm"]) + index = next((item for item, value in enumerate(radii) if abs(value - radius) <= 1e-5), None) + if index is None: + return None + if side > 0: + intervals.append((radii[index - 1] if index else 0.0, radius)) + elif index + 1 < len(radii): + intervals.append((radius, radii[index + 1])) + else: + # 最外圆的负侧是无界区域,不能变成任意默认实体。 + return None + intervals.sort() + merged: list[list[float]] = [] + for lower, upper in intervals: + if merged and lower <= merged[-1][1] + 1e-5: + merged[-1][1] = max(merged[-1][1], upper) + else: + merged.append([lower, upper]) + if len(merged) != 1: + return None + inner, outer = merged[0] + if inner <= 1e-5: + return {"type": "circle", "center": list(center), "radius_mm": outer} + return { + "type": "analytic_contours", + "contours": [ + {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": list(center), "radius_mm": outer}]}, + {"role": "inner", "closed": True, "segments": [{"type": "circle", "center": list(center), "radius_mm": inner}]}, + ], + } + + +def _surface_circle_radii(profile: dict[str, Any]) -> list[tuple[list[float], float]]: + """Return the explicit circular wires retained by a surface extrusion.""" + contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None + if not isinstance(contours, list): return [] + circles = [] + for contour in contours: + segments = contour.get("segments") or [] + if len(segments) != 1 or segments[0].get("type") != "circle": continue + circles.append((list(segments[0].get("center") or []), float(segments[0]["radius_mm"]))) + return circles + + +def _surface_trimmed_imprint_profile( + profile_sketch: dict[str, Any], + surface_profile_sketch: dict[str, Any] | None, + previous_surfaces: list[dict[str, Any]], +) -> dict[str, Any]: + """Materialize the bounded solid region created by a coaxial surface split. + + CADFS can retain an earlier, bidirectional surface extrusion while a later + mixed extrusion selects an IMPRINT face. The selected circular face is + then bounded by that surface, rather than by the sketch origin. CDSL has + no general surface/solid trim operation yet, so preserve this proven + circular case as its actual annular profile. Other surface combinations + remain on the ordinary IMPRINT lowering path instead of guessing a trim. + """ + profile = profile_sketch.get("profile") or {} + if profile.get("type") != "circle" or surface_profile_sketch is None: + return profile_sketch + center = list(profile.get("center") or []) + outer = float(profile.get("radius_mm") or 0.0) + if len(center) != 2 or outer <= 0: + return profile_sketch + selected_radii = _surface_circle_radii(surface_profile_sketch.get("profile") or {}) + if not any(_same_point(item_center, center) and abs(radius - outer) <= 1e-5 for item_center, radius in selected_radii): + return profile_sketch + plane = profile_sketch.get("workplane") or {} + origin = plane.get("origin_mm") or [] + x_dir = plane.get("x_dir") or [] + y_dir = _y_dir(plane) + if len(origin) != 3 or len(x_dir) != 3: + return profile_sketch + world_center = [origin[index] + center[0] * x_dir[index] + center[1] * y_dir[index] for index in range(3)] + candidates: list[float] = [] + for surface in previous_surfaces: + surface_plane = surface["workplane"] + normal = surface_plane["normal"] + surface_origin = surface_plane["origin_mm"] + span_start = -float(surface.get("reverse_distance_mm") or 0.0) + span_end = float(surface.get("distance_mm") or 0.0) + projection = _dot(_sub(world_center, surface_origin), normal) + if projection < span_start - 1e-5 or projection > span_end + 1e-5: + continue + surface_x_dir = surface_plane["x_dir"] + surface_y_dir = _y_dir(surface_plane) + local_center = [_dot(_sub(world_center, surface_origin), surface_x_dir), _dot(_sub(world_center, surface_origin), surface_y_dir)] + for surface_center, radius in _surface_circle_radii(surface["profile"]): + if _same_point(surface_center, local_center) and 1e-5 < radius < outer - 1e-5: + candidates.append(radius) + if not candidates: + return profile_sketch + inner = max(candidates) + output = deepcopy(profile_sketch) + output["profile"] = { + "type": "analytic_contours", + "contours": [ + {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": center, "radius_mm": outer}]}, + {"role": "inner", "closed": True, "segments": [{"type": "circle", "center": center, "radius_mm": inner}]}, + ], + } + return output + + +def _open_imprint_profile_sketch( + sketch: dict[str, Any], + value: Any, + feature_id: str, +) -> dict[str, Any] | None: + """Materialize an open IMPRINT contour closed by its attached host face.""" + query = parse_query(value) + profile = sketch.get("profile") or {} + segments = profile.get("construction") or [] + if query.topology_type != "IMPRINT" or profile.get("contours") or len(segments) < 2: + return None + if any(segment.get("type") not in {"line", "arc", "bspline"} for segment in segments): + return None + return { + "id": f"{sketch['id']}__{feature_id}", + "name": f"{sketch['name']}__{feature_id}", + "workplane": dict(sketch["workplane"]), + "profile": { + "type": "analytic_contours", + "contours": [{"role": "open", "closed": False, "segments": deepcopy(segments)}], + }, + } + + +def _profile_outer_circle(profile: dict[str, Any]) -> dict[str, Any] | None: + if profile.get("type") == "circle": + return profile + contours = profile.get("contours") or [] + outer = next((contour for contour in contours if contour.get("role") == "outer"), None) + segments = (outer or {}).get("segments") or [] + if len(segments) == 1 and segments[0].get("type") == "circle": + return segments[0] + return None + + +def _imprint_cap_profiles( + sketch: dict[str, Any], + query_value: Any, + entities: dict[str, dict[str, Any]], +) -> dict[tuple[str, str], dict[str, Any]]: + """Record selected circular regions by their outer sketch edge provenance. + + A CADFS CAP_FACE can identify one output face through the source outer + sketch edge, even when its producing extrude selected several adjacent + IMPRINT regions. The aggregate selected profile is insufficient in that + case: for example, the outer ring and the inner disk have different CAP + faces although their union is a disk. Keep only the unambiguous bounded + circular regions here; general multi-region provenance still needs the + engine output-role model. + """ + contours = (sketch.get("profile") or {}).get("contours") or [] + profiles: dict[tuple[str, str], dict[str, Any]] = {} + for selection_value in _queries(query_value): + selection = parse_query(selection_value) + source_id = max((key for key in entities if selection.source_entity and selection.source_entity.startswith(key)), key=len, default="") + source = entities.get(source_id) + profile = _circle_imprint_region(contours, source or {}, _profile_selection_side(selection_value)) + outer = _profile_outer_circle(profile or {}) + if outer is None or source is None: + continue + outer_id = next(( + entity_id + for entity_id, entity in entities.items() + if entity.get("type") == "circle" + and _same_point(entity.get("center") or [], outer.get("center") or []) + and abs(float(entity.get("radius_mm") or 0.0) - float(outer.get("radius_mm") or 0.0)) <= 1e-5 + ), None) + if outer_id is not None: + profiles[(selection.source_sketch or "", outer_id)] = profile + return profiles + + +def _cap_face_profile_sketch( + value: Any, + feature_frames: dict[str, dict[str, Any]], + cap_profiles: dict[str, dict[tuple[str, str], dict[str, Any]]], + feature_id: str, +) -> dict[str, Any] | None: + """Materialize one previously recorded CAP_FACE output role as a sketch.""" + query = parse_query(value) + if query.topology_type != "CAP_FACE" or not query.owner_feature: + return None + references = _source_refs(value) + if len(references) != 1: + return None + profile = (cap_profiles.get(query.owner_feature) or {}).get(references[0]) + frame = feature_frames.get(query.owner_feature) + if profile is None or frame is None: + return None + cap_name = "start" if query.is_start else "end" + cap = frame.get(f"{cap_name}_attachment") or frame[cap_name] + return { + "id": f"sketch_{query.owner_feature}__{feature_id}", + "name": f"{query.owner_feature}__{feature_id}", + "workplane": dict(cap), + "profile": deepcopy(profile), + } + + +def _cap_face_selector(value: Any, feature_frames: dict[str, dict[str, Any]], feature_id: str, suffix: str) -> dict[str, Any] | None: + """Capture one uniquely framed CAP_FACE/CAP_EDGE as a runtime face selector.""" + query = parse_query(value) + if query.topology_type not in {"CAP_FACE", "CAP_EDGE"} or not query.owner_feature or query.is_start is None: + return None + frame = feature_frames.get(query.owner_feature) + if frame is None: + return None + cap = frame.get("start" if query.is_start else "end") + if cap is None: + return None + normal = list(cap["normal"]) + return { + "kind": "face", + "owner_feature_id": f"f_{query.owner_feature}", + "stable_id": f"cadfs_{feature_id}_{suffix}", + "source": "runtime_snapshot", + "confidence": 1.0, + "binding_feature_id": f"f_{query.owner_feature}", + "geometry": {"normal": normal, "plane_offset_mm": _dot(normal, cap["origin_mm"])}, + } + + +def _cap_edge_hole_profile_sketch( + value: Any, + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + feature_frames: dict[str, dict[str, Any]], + feature_id: str, +) -> tuple[dict[str, Any], dict[str, Any]] | None: + """Materialize an outer IMPRINT profile with one CAP_EDGE-derived inner wire. + + The inner curve may already be a draft/offset B-rep edge, so it must be + taken from its producer's selected cap face at runtime rather than guessed + from the original sketch entity. This intentionally accepts one outer + closed region and one uniquely framed cap edge only. + """ + roots = _queries(value) + if len(roots) != 2: + return None + outer_value = next((item for item in roots if parse_query(item).topology_type == "IMPRINT"), None) + inner_value = next((item for item in roots if parse_query(item).topology_type == "CAP_EDGE"), None) + if outer_value is None or inner_value is None: + return None + # 同向的 TD region query 选择的是外轮廓两侧相邻的两个 IMPRINT 区域。 + # 将二者并集解释成 "外轮廓减 CAP_EDGE" 会错误挖去中心,留下一个仅沿 + # 边接触前序实体的薄环。只有两条边的 region side 相反时,才有明确的 + # 外环 + 内孔语义可以交给受限的 CAP_EDGE profile atomic。 + outer_side = _profile_selection_side(outer_value) + inner_side = _profile_selection_side(inner_value) + if outer_side is not None and inner_side is not None and outer_side == inner_side: + return None + outer = parse_query(outer_value) + if not outer.source_sketch or outer.source_sketch not in sketch_by_source: + return None + outer_sketch = _profile_selection_sketch( + sketch_by_source[outer.source_sketch], outer_value, + entity_by_sketch[outer.source_sketch], feature_id, + ) + profile = outer_sketch.get("profile") or {} + contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None + if not ( + profile.get("type") == "circle" + or isinstance(contours, list) and len(contours) == 1 and bool(contours[0].get("closed")) + ): + return None + selector = _cap_face_selector(inner_value, feature_frames, feature_id, "profile_hole") + return (outer_sketch, selector) if selector is not None else None + + +def _cap_edge_union_profile_sketch( + value: Any, + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + feature_id: str, +) -> dict[str, Any] | None: + """Materialize the complete outer region selected around one CAP_EDGE.""" + roots = _queries(value) + if len(roots) != 2: + return None + outer_value = next((item for item in roots if parse_query(item).topology_type == "IMPRINT"), None) + inner_value = next((item for item in roots if parse_query(item).topology_type == "CAP_EDGE"), None) + if outer_value is None or inner_value is None: + return None + outer_side = _profile_selection_side(outer_value) + inner_side = _profile_selection_side(inner_value) + if outer_side is None or inner_side is None or outer_side != inner_side: + return None + outer = parse_query(outer_value) + if not outer.source_sketch or outer.source_sketch not in sketch_by_source: + return None + return _profile_selection_sketch( + sketch_by_source[outer.source_sketch], outer_value, + entity_by_sketch[outer.source_sketch], feature_id, + ) + + +def _loft_cap_face_profile( + params: dict[str, Any], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + feature_frames: dict[str, dict[str, Any]], + feature_id: str, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]] | None: + """Resolve one CAP_FACE outer wire followed by one sketch-imprint loft section.""" + profiles = params.get("sheetProfilesArray") + if not isinstance(profiles, list) or len(profiles) != 2: + return None + values = [item.get("sheetProfileEntities") if isinstance(item, dict) else item for item in profiles] + cap, sketch_query = (parse_query(value) for value in values) + if cap.topology_type != "CAP_FACE" or sketch_query.topology_type != "IMPRINT": + return None + if not sketch_query.source_sketch or sketch_query.source_sketch not in sketch_by_source: + return None + selector = _cap_face_selector(values[0], feature_frames, feature_id, "loft_cap") + frame = feature_frames.get(cap.owner_feature or "") + cap_frame = frame and frame.get("start" if cap.is_start else "end") + if selector is None or cap_frame is None: + return None + sketch = _profile_selection_sketch( + sketch_by_source[sketch_query.source_sketch], values[1], + entity_by_sketch[sketch_query.source_sketch], feature_id, + ) + return sketch, selector, dict(cap_frame) + + +def _profile_selection_sketch( + sketch: dict[str, Any], + query_value: Any, + entities: dict[str, dict[str, Any]], + feature_id: str, +) -> dict[str, Any]: + """Materialize a uniquely selected sketch region for an IMPRINT query.""" + query = parse_query(query_value) + if query.topology_type != "IMPRINT" or not query.source_entity: + return sketch + contours = (sketch.get("profile") or {}).get("contours") or [] + query_values = _queries(query_value) + selections = [parse_query(item) for item in query_values] + + union_profile = _circle_imprint_union_profile(contours, query_values, entities) + if union_profile is not None: + output = deepcopy(sketch) + output["id"] = f"{sketch['id']}__{feature_id}" + output["name"] = f"{sketch['name']}__{feature_id}" + output["profile"] = union_profile + return output + + # qUnion 可以从同一草图选择多个互不相同的 IMPRINT region。此前只取 + # parse_query(qUnion(...)) 最后看到的一个 source entity,且在没有正反 + # orientation 对时退回整张草图,导致未选圆也被错误拉伸。仅当每个 source + # entity 唯一对应一条 contour 时,直接物化该明确选择集;同源的双侧 region + # 继续交给下方的 orientation 逻辑,避免把环形区域猜成圆盘。 + source_ids = [ + max((key for key in entities if selection.source_entity and selection.source_entity.startswith(key)), key=len, default="") + for selection in selections + ] + # 单一同心圆边的 IMPRINT 查询并不总是选择圆盘。它表达的是该边 + # 一侧的 bounded region;例如 `00111611` F7 选 E6 的正侧时,真实 + # profile 是 E7/E6 环域而不是 E6 圆盘。 + if len(selections) == 1 and source_ids and source_ids[0]: + source = entities[source_ids[0]] + profile = _circle_imprint_region(contours, source, _profile_selection_side(_queries(query_value)[0])) + if profile is not None: + output = deepcopy(sketch) + output["id"] = f"{sketch['id']}__{feature_id}" + output["name"] = f"{sketch['name']}__{feature_id}" + output["profile"] = profile + return output + if ( + len(source_ids) == len(set(source_ids)) + and all(source_ids) + and all(selection.topology_type == "IMPRINT" and selection.source_entity for selection in selections) + ): + selected: list[dict[str, Any]] = [] + for selected_id in source_ids: + source = entities[selected_id] + matches = [ + contour for contour in contours + if any(_matching_profile_segment(segment, source) for segment in contour.get("segments") or []) + ] + if len(matches) != 1: + break + selected.append(matches[0]) + else: + output = deepcopy(sketch) + output["id"] = f"{sketch['id']}__{feature_id}" + output["name"] = f"{sketch['name']}__{feature_id}" + output["profile"] = {**output["profile"], "contours": selected} + return output + + source_id = max((key for key in entities if query.source_entity.startswith(key)), key=len, default="") + source = entities.get(source_id) + if source is None or len(contours) < 2: + return sketch + selection_sides = [_profile_selection_side(item) for item in _queries(query_value)] + if source.get("type") == "circle" and all(side is not None for side in selection_sides): + # 两个相反 orientation 的同一圆形 IMPRINT region 表示其两侧区域的 + # 并集。对于同心圆草图,CADFS 用这套写法选择内圆盘和相邻圆环;将它 + # 们规整为最小包围圆盘,不能按普通 nested-circle profile 误作圆环。 + circles = [ + segment for contour in contours for segment in contour.get("segments") or [] + if segment.get("type") == "circle" and _same_point(segment.get("center") or [], source.get("center") or []) + ] + radii = sorted({float(segment["radius_mm"]) for segment in circles}) + if len(radii) >= 2 and any(side < 0 for side in selection_sides) and any(side > 0 for side in selection_sides): + outer = next((radius for radius in radii if radius > float(source["radius_mm"]) + 1e-6), None) + if outer is not None: + output = deepcopy(sketch) + output["id"] = f"{sketch['id']}__{feature_id}" + output["name"] = f"{sketch['name']}__{feature_id}" + output["profile"] = {"type": "circle", "center": list(source["center"]), "radius_mm": outer} + return output + matches: list[tuple[dict[str, Any], int]] = [] + for contour in contours: + direction = next((_matching_profile_segment(segment, source) for segment in contour.get("segments") or [] if _matching_profile_segment(segment, source)), 0) + if direction: matches.append((contour, direction)) + side = _profile_selection_side(query_value) + if side is None or len(matches) < 2: + return sketch + selected = [contour for contour, direction in matches if direction == (1 if side > 0 else -1)] + if len(selected) != 1: + raise UnsupportedCapability("sketch_region_selection", "IMPRINT profile selection does not resolve one sketch region") + output = deepcopy(sketch) + output["id"] = f"{sketch['id']}__{feature_id}" + output["name"] = f"{sketch['name']}__{feature_id}" + output["profile"] = {**output["profile"], "contours": [selected[0]]} + return output + + +def _surface_profile_selection_sketch( + sketch: dict[str, Any], + query_value: Any, + entities: dict[str, dict[str, Any]], + feature_id: str, +) -> dict[str, Any]: + """Materialize the explicit circular wires selected by surfaceEntities.""" + references = _source_refs(query_value) + selected = [] + seen = set() + for source, entity_id in references: + if source != sketch["name"] or entity_id in seen: + continue + entity = entities.get(entity_id) + if entity is None or entity.get("type") != "circle": + raise UnsupportedCapability( + "extrude_surface_profile", + "current CDSL surface extrude requires explicitly selected circular wires", + ) + selected.append(entity); seen.add(entity_id) + if not selected: + raise ValueError("surface extrude profile query is unresolved") + output = deepcopy(sketch) + output["id"] = f"{sketch['id']}__{feature_id}_surface" + output["name"] = f"{sketch['name']}__{feature_id}_surface" + output["profile"] = { + "type": "analytic_contours", + "contours": [ + {"role": "unknown", "closed": True, "segments": [deepcopy(entity)]} + for entity in selected + ], + } + return output + + def _pattern_source_features(value: Any, previous: list[str]) -> list[str]: sources = [] for call in walk_calls(value): @@ -260,6 +1491,176 @@ def _pattern_source_features(value: Any, previous: list[str]) -> list[str]: return sources +def _pattern_body_history_sources( + value: Any, + sources: list[str], + previous: list[str], + feature_by_id: dict[str, dict[str, Any]], +) -> list[str]: + """Extend one selected SWEPT_BODY with its fused additive history. + + CADFS body queries name the feature that originally created the body. A + following ADD sweep can already have become part of that same body before + `circularPattern` copies it. Replaying only the creator omits the fused + geometry and makes later COPY(CAP_FACE) selectors impossible to bind. + Keep this restricted to direct additive body producers whose replay is + already supported by the runtime. + """ + has_swept_body = any( + parse_query(item).topology_type == "SWEPT_BODY" + and parse_query(item).kind in {"body", "entitytype.body"} + for item in _queries(value) + ) + if not has_swept_body: + return sources + replayable_adds = { + "extrude_add_blind", "extrude_add_two_sided", "loft_add", + "loft_add_with_cap_face", "sweep_add", "revolve_add", + "sphere_add", "box_add", "cylinder_add", + } + output = list(sources) + source_indexes = [previous.index(source) for source in sources if source in previous] + if not source_indexes: + return output + for feature_id in previous[max(source_indexes) + 1:]: + feature = feature_by_id.get(feature_id) or {} + if feature.get("atomic_id") not in replayable_adds: + continue + if (feature.get("params") or {}).get("result_mode") == "new_body": + continue + output.append(feature_id) + return list(dict.fromkeys(output)) + + +def _sweep_cap_frames(profile: dict[str, Any], path: dict[str, Any]) -> dict[str, dict[str, Any]] | None: + """Record physical CAP_FACE frames for one line or B-spline sweep.""" + segment = path.get("segment") or {} + path_plane = path.get("workplane") or {} + profile_plane = profile.get("workplane") or {} + points = segment.get("points") if segment.get("type") == "bspline" else [segment.get("start"), segment.get("end")] + if not isinstance(points, list) or len(points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in points): + return None + y_dir = _y_dir(path_plane) + + def point(value: list[float]) -> list[float]: + return [ + path_plane["origin_mm"][index] + path_plane["x_dir"][index] * value[0] + y_dir[index] * value[1] + for index in range(3) + ] + + def direction(value: Any, fallback: list[float]) -> list[float]: + if isinstance(value, list) and len(value) == 2: + return _unit([ + path_plane["x_dir"][index] * value[0] + y_dir[index] * value[1] + for index in range(3) + ], "sweep cap tangent is degenerate") + return _unit(fallback, "sweep path is degenerate") + + start, end = point(points[0]), point(points[-1]) + start_direction = direction(segment.get("start_tangent"), _sub(point(points[1]), start)) + end_direction = direction(segment.get("end_tangent"), _sub(end, point(points[-2]))) + x_dir = list(profile_plane["x_dir"]) + return { + # OCC keeps the source profile as the start cap of a solid sweep. Its + # plane follows the profile workplane, not the tangent of a curved + # spine. The profile centre may lie at the path point while the plane + # origin is elsewhere, so retain the actual profile plane frame. + "start": _frame(list(profile_plane["origin_mm"]), x_dir, list(profile_plane["normal"])), + "end": _frame(end, x_dir, end_direction), + "profile": dict(profile_plane), + } + + +def _reversed_sweep_path_segment(segment: dict[str, Any]) -> dict[str, Any]: + """Reverse one CADFS line/B-spline sweep path without changing its curve.""" + output = deepcopy(segment) + output["start"], output["end"] = segment["end"], segment["start"] + if output.get("type") == "bspline": + output["points"] = list(reversed(segment.get("points") or [])) + parameters = segment.get("parameters") + if isinstance(parameters, list) and parameters: + start_parameter, end_parameter = float(parameters[0]), float(parameters[-1]) + output["parameters"] = [start_parameter + end_parameter - float(value) for value in reversed(parameters)] + start_tangent, end_tangent = segment.get("start_tangent"), segment.get("end_tangent") + if isinstance(start_tangent, list) and isinstance(end_tangent, list): + output["start_tangent"] = [-float(value) for value in end_tangent] + output["end_tangent"] = [-float(value) for value in start_tangent] + return output + + +def _sweep_profile_attaches_at_path_end(profile: dict[str, Any], path: dict[str, Any]) -> bool: + """Detect a circle profile placed at the terminal point of a CADFS path.""" + segment = path.get("segment") or {} + workplane = path.get("workplane") or {} + profile_plane = profile.get("workplane") or {} + profile_shape = profile.get("profile") or {} + if profile_shape.get("type") != "circle" or segment.get("type") not in {"line", "bspline"}: + return False + start, end = segment.get("start"), segment.get("end") + center = profile_shape.get("center") or [0.0, 0.0] + if not all(isinstance(value, list) and len(value) == 2 for value in (start, end, center)): + return False + try: + start_point, end_point = _global(workplane, start), _global(workplane, end) + profile_center = _global(profile_plane, center) + except (KeyError, TypeError, ValueError): + return False + return math.dist(profile_center, end_point) <= 1e-5 and math.dist(profile_center, start_point) > 1e-5 + + +def _pattern_copy_body(value: Any) -> tuple[str, str, int]: + """Resolve one CADFS circular-pattern body copy without flattening it to a feature.""" + _call, pattern_owner, topology, kind, definition = _direct_make_query(value) + if topology != "COPY" or kind not in {"body", "entitytype.body"}: + raise UnsupportedCapability("delete_bodies", "current CDSL deleteBodies only supports circular pattern body copies") + derived = definition.get("derivedFrom") + if derived is None: + raise ValueError("pattern copy deletion has no derived body") + _source, source_owner, source_topology, source_kind, _source_definition = _direct_make_query(derived) + if source_topology != "SWEPT_BODY" or source_kind not in {"body", "entitytype.body"}: + raise UnsupportedCapability("delete_bodies", "pattern copy deletion source is not a direct swept body") + try: + instance = int(str(definition.get("instanceName"))) + except (TypeError, ValueError) as error: + raise ValueError("pattern copy deletion instance is unresolved") from error + return f"f_{pattern_owner}", f"f_{source_owner}", instance + + +def _boolean_body_sources(value: Any) -> list[str]: + """Resolve CADFS SWEPT_BODY query members to their producing features.""" + sources: list[str] = [] + for query_value in _queries(value): + query = parse_query(query_value) + if query.topology_type != "SWEPT_BODY" or query.kind not in {"body", "entitytype.body"}: + raise ValueError("booleanBodies requires explicit SWEPT_BODY queries") + if not query.owner_feature: + raise ValueError("booleanBodies source feature is unresolved") + source = f"f_{query.owner_feature}" + if source not in sources: + sources.append(source) + if not sources: + raise ValueError("booleanBodies has no selected bodies") + return sources + + +def _pattern_remove_source(feature: dict[str, Any]) -> None: + # circularPattern 的 REMOVE 会把源实体及其实例作为切削工具。仅直接的 + # 加料拉伸/回转可无歧义改写为同一 profile 的切除;其他 source 需要 + # body 生命周期与工具保留策略,不能猜测成任意 boolean。 + cut_atomic = { + "extrude_add_blind": "extrude_cut_blind", + "extrude_add_two_sided": "extrude_cut_two_sided", + "revolve_add": "revolve_cut", + }.get(str(feature.get("atomic_id") or "")) + if cut_atomic is None: + raise UnsupportedCapability( + "circular_pattern_remove_source", + "current CDSL engine can only replay a REMOVE pattern from a direct additive extrusion or revolve", + ) + feature["atomic_id"] = cut_atomic + feature["params"].pop("result_mode", None) + + def _transform_source_features(value: Any, previous: list[str]) -> list[str]: sources = [] for call in walk_calls(value): @@ -372,7 +1773,11 @@ def _entity_point(entity: dict[str, Any], plane: dict[str, Any], token: str) -> local = entity["end"] if ".end" in token else entity["start"] return _global(plane, local) if entity["type"] == "bspline": - index = next((int(part) - 1 for part in token.split(".") if part.isdigit()), 0) + # FeatureScript spline query suffixes are zero-based output-vertex + # indexes: ``E1.2.internal`` identifies the third interpolation + # point, including a point that is also the wire end. Treating them + # as one-based moves a reference plane to its preceding control point. + index = next((int(part) for part in token.split(".") if part.isdigit()), 0) points = entity.get("points") or [] if not points: raise ValueError("B-spline reference point is unresolved") return _global(plane, points[max(0, min(index, len(points) - 1))]) @@ -384,6 +1789,42 @@ def _entity_line(entity: dict[str, Any], plane: dict[str, Any]) -> tuple[list[fl return _global(plane, entity["start"]), _global(plane, entity["end"]) +def _local(plane: dict[str, Any], point: list[float]) -> list[float]: + relative = _sub(point, plane["origin_mm"]) + return [_dot(relative, plane["x_dir"]), _dot(relative, _y_dir(plane))] + + +def _revolve_swept_face_profile( + query: Any, + plane: dict[str, Any], + feature_frames: dict[str, dict[str, Any]], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> dict[str, Any] | None: + info = parse_query(query); frame = feature_frames.get(info.owner_feature or "") + if info.topology_type != "SWEPT_FACE" or frame is None or not frame.get("revolve_full"): + return None + axis = frame.get("revolve_axis") + if not isinstance(axis, dict): return None + start, end = _query_line(query, feature_frames, sketch_by_source, entity_by_sketch) + direction = _sub(end, start); axis_direction = axis["direction"] + if abs(_dot(direction, axis_direction)) > 1e-6 * max(1.0, math.sqrt(_dot(direction, direction))): + raise UnsupportedCapability("extrude_profile_topology:swept_face", "revolved swept face source line is not perpendicular to the revolve axis") + start_offset = _dot(_sub(start, axis["origin_mm"]), axis_direction) + end_offset = _dot(_sub(end, axis["origin_mm"]), axis_direction) + if abs(start_offset - end_offset) > 1e-6: + raise UnsupportedCapability("extrude_profile_topology:swept_face", "revolved swept face source line is not coplanar with the revolve axis") + center = [axis["origin_mm"][index] + axis_direction[index] * start_offset for index in range(3)] + radii = sorted([math.dist(start, center), math.dist(end, center)]) + if radii[1] <= 1e-9: + raise ValueError("revolved swept face source line is degenerate") + local_center = _local(plane, center) + contours = [{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": local_center, "radius_mm": radii[1]}]}] + if radii[0] > 1e-9: + contours.append({"role": "inner", "closed": True, "segments": [{"type": "circle", "center": local_center, "radius_mm": radii[0]}]}) + return {"type": "analytic_contours", "contours": contours} + + def _query_plane( query: Any, feature_frames: dict[str, dict[str, Any]], @@ -391,7 +1832,7 @@ def _query_plane( entity_by_sketch: dict[str, dict[str, dict[str, Any]]], ) -> dict[str, Any]: try: - return _plane_from_query(query, feature_frames, sketch_by_source) + return _plane_from_query(query, feature_frames, sketch_by_source, entity_by_sketch) except ValueError: info = parse_query(query) if info.topology_type != "SWEPT_FACE" or not info.owner_feature: @@ -399,9 +1840,111 @@ def _query_plane( entity, source_plane, _ = _entity_from_query(query, sketch_by_source, entity_by_sketch) start, end = _entity_line(entity, source_plane); frame = feature_frames.get(info.owner_feature) if frame is None: raise ValueError("swept face owner frame is unresolved") + if frame.get("revolve_full"): + axis = frame.get("revolve_axis") + if not isinstance(axis, dict): raise ValueError("revolve swept face axis is unresolved") + direction = _sub(end, start); axis_direction = axis["direction"] + if abs(_dot(direction, axis_direction)) > 1e-6 * max(1.0, math.sqrt(_dot(direction, direction))): + raise UnsupportedCapability("reference_plane:swept_face", "revolved swept face source line is not perpendicular to the revolve axis") + return _frame(start, direction, axis_direction) direction = _unit(_sub(end, start), "swept face source line is degenerate") - normal = _cross(direction, frame["end"]["normal"]) - return _frame(start, direction, normal) + sketch = sketch_by_source.get(info.source_sketch or "") or {} + contours = (sketch.get("profile") or {}).get("contours") or [] + contour = next(( + item for item in contours + if any(_matching_profile_segment(segment, entity) for segment in item.get("segments") or []) + ), None) + points = [ + segment["start"] + for segment in (contour or {}).get("segments") or [] + if isinstance(segment.get("start"), list) + ] + if points: + center = _global(source_plane, [ + sum(point[index] for point in points) / len(points) + for index in range(2) + ]) + midpoint = [(start[index] + end[index]) / 2.0 for index in range(3)] + inward = _sub(center, midpoint) + inward = _sub(inward, [direction[index] * _dot(inward, direction) for index in range(3)]) + normal = [-value for value in _unit(inward, "swept face interior is degenerate")] + else: + normal = _cross(direction, frame["end"]["normal"]) + x_dir = direction + if _dot(_cross(normal, x_dir), source_plane["normal"]) < 0: + x_dir = [-value for value in x_dir] + # 附着草图的局部原点是全局原点在实体侧面上的投影,不是 source + # 草图边的任一端点。后者会把以原全局平面坐标表达的 F7/F9 等草图 + # 平移一个完整边长,令后续 cut 落在主体之外。 + return _attachment_plane(_frame(start, x_dir, normal)) + + +def _offset_face_plane( + value: Any, + feature_frames: dict[str, dict[str, Any]], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> dict[str, Any]: + """Recover one planar inner shell wall from its source profile edge.""" + query = parse_query(value) + frame = feature_frames.get(query.owner_feature or "") or {} + if query.topology_type != "OFFSET_FACE" or not frame.get("shell_source"): + raise ValueError("offset face owner is unresolved") + thickness = float(frame.get("shell_thickness_mm") or 0.0) + if thickness <= 0: + raise ValueError("offset face shell thickness is unresolved") + entity, source_plane, _ = _entity_from_query(value, sketch_by_source, entity_by_sketch) + start, end = _entity_line(entity, source_plane) + direction = _unit(_sub(end, start), "offset face source line is degenerate") + source_sketch = sketch_by_source.get(query.source_sketch or "") or {} + contours = (source_sketch.get("profile") or {}).get("contours") or [] + contour = next(( + item for item in contours + if any(_matching_profile_segment(segment, entity) for segment in item.get("segments") or []) + ), None) + points = [ + segment["start"] + for segment in (contour or {}).get("segments") or [] + if isinstance(segment.get("start"), list) + ] + if not points: + raise ValueError("offset face source contour is unresolved") + center = _global(source_plane, [ + sum(point[index] for point in points) / len(points) + for index in range(2) + ]) + midpoint = [(start[index] + end[index]) / 2.0 for index in range(3)] + inward = _sub(center, midpoint) + inward = _sub(inward, [direction[index] * _dot(inward, direction) for index in range(3)]) + normal = _unit(inward, "offset face source interior is degenerate") + x_dir = direction + if _dot(_cross(normal, x_dir), source_plane["normal"]) < 0: + x_dir = [-value for value in x_dir] + offset = [start[index] + thickness * normal[index] for index in range(3)] + return _attachment_plane(_frame(offset, x_dir, normal)) + + +def _offset_face_profile_sketch( + value: Any, + feature_frames: dict[str, dict[str, Any]], + sketches_by_id: dict[str, dict[str, Any]], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + feature_id: str, +) -> dict[str, Any] | None: + """Materialize a planar OFFSET_FACE as the shell's shifted profile region.""" + query = parse_query(value) + frame = feature_frames.get(query.owner_feature or "") or {} + profile_id = frame.get("shell_profile_sketch_id") + profile = sketches_by_id.get(str(profile_id)) if profile_id else None + if query.topology_type != "OFFSET_FACE" or profile is None: + return None + return { + "id": f"sketch_{query.owner_feature}__{feature_id}", + "name": f"{query.owner_feature}__{feature_id}", + "workplane": _offset_face_plane(value, feature_frames, sketch_by_source, entity_by_sketch), + "profile": deepcopy(profile["profile"]), + } def _query_line( @@ -415,6 +1958,15 @@ def _query_line( if info.topology_type == "CAP_EDGE" and info.owner_feature in feature_frames: frame = feature_frames[info.owner_feature]["start" if info.is_start else "end"] plane = frame + if entity["type"] == "circle": + center = _global(plane, entity["center"]) + return center, [center[index] + plane["normal"][index] for index in range(3)] + if info.topology_type == "SWEPT_FACE" and entity["type"] == "circle" and info.owner_feature in feature_frames: + frame = feature_frames[info.owner_feature] + start, end = frame.get("start"), frame.get("end") + if start is None or end is None: raise ValueError("cylindrical swept face frame is unresolved") + center = _global(plane, entity["center"]) + return [center[index] + start["origin_mm"][index] - plane["origin_mm"][index] for index in range(3)], [center[index] + end["origin_mm"][index] - plane["origin_mm"][index] for index in range(3)] return _entity_line(entity, plane) @@ -469,7 +2021,13 @@ def _bake_transform( sketch["workplane"] = transform_frame(sketch["workplane"]) frame = feature_frames.get(source_feature_id) if frame is not None: - feature_frames[source_feature_id] = {key: transform_frame(value) for key, value in frame.items()} + transformed = {key: transform_frame(value) for key, value in frame.items() if key in {"start", "end", "profile"}} + for cap in ("start", "end"): + if cap in transformed: + transformed[f"{cap}_attachment"] = _attachment_plane(transformed[cap]) + if isinstance(frame.get("revolve_axis"), dict): transformed["revolve_axis"] = transform_axis(frame["revolve_axis"]) + if "revolve_full" in frame: transformed["revolve_full"] = frame["revolve_full"] + feature_frames[source_feature_id] = transformed source_axis = source.get("params", {}).get("axis") if isinstance(source_axis, dict) and source_axis.get("origin_mm") and source_axis.get("direction"): source["params"]["axis"] = transform_axis(source_axis) @@ -523,9 +2081,20 @@ def _cplane( except ValueError: _, base, _ = _entity_from_query(line_query, sketch_by_source, entity_by_sketch) start, end = _query_line(line_query, feature_frames, sketch_by_source, entity_by_sketch) - axis = _sub(end, start); angle = _number(params.get("angle", 0.0)) - if _bool(params.get("oppositeDirection")): angle = -angle - return _frame(start, _rotate(base["x_dir"], axis, math.radians(angle)), _rotate(base["normal"], axis, math.radians(angle))) + axis = _sub(end, start); angle = -_number(params.get("angle", 0.0)) + opposite = _bool(params.get("oppositeDirection")) + normal = _rotate(base["normal"], axis, math.radians(angle)) + x_dir = _rotate(base["x_dir"], axis, math.radians(angle)) + entity, _, _ = _entity_from_query(line_query, sketch_by_source, entity_by_sketch) + info = parse_query(line_query) + if entity.get("type") == "circle" and info.topology_type in {"SWEPT_FACE", "CAP_EDGE"}: + # 圆柱面/端盖圆边用于 LINE_ANGLE 时,FeatureScript 取圆柱面上的 + # 母线作为旋转轴,而不是圆心处的中心轴。将该轴移到旋转后的面 + # 法向所指的圆柱半径位置,才能保持后续草图和端面引用的位置。 + axis_direction = _unit(axis, "line-angle reference line is degenerate") + radial = _sub(normal, [axis_direction[index] * _dot(normal, axis_direction) for index in range(3)]) + start = [start[index] + _unit(radial, "line-angle cylinder radial direction is degenerate")[index] * entity["radius_mm"] for index in range(3)] + return _frame(start, x_dir, normal) if plane_type == "PLANE_POINT": base_query = next((item for item in entities if _default_plane(item) or "qCreatedBy" in parse_query(item).calls), None) point_query = next((item for item in entities if item is not base_query), None) @@ -550,38 +2119,71 @@ def _cplane( line_query = next((item for item in entities if "edge" in (parse_query(item).kind or "")), None) point_query = next((item for item in entities if item is not line_query), None) if line_query is None or point_query is None: raise ValueError("line-point references are unresolved") + # FeatureScript 的 LINE_POINT 平面经过指定点,法向与参考线平行。 + # 点可以是线端点;它不是用来和直线共同定义平面的第三个方向。 + _, source_plane, _ = _entity_from_query(line_query, sketch_by_source, entity_by_sketch) start, end = _query_line(line_query, feature_frames, sketch_by_source, entity_by_sketch) point = _query_point(point_query, feature_frames, sketch_by_source, entity_by_sketch) - direction = _sub(end, start); normal = _cross(direction, _sub(point, start)) + normal = _sub(end, start) if _bool(params.get("oppositeDirection")): normal = [-value for value in normal] - return _frame(start, direction, normal) + return _frame(point, _cross(source_plane["normal"], normal), normal) if plane_type == "MID_PLANE": if len(entities) != 2: raise ValueError("mid-plane requires exactly two reference planes") first, second = [_query_plane(item, feature_frames, sketch_by_source, entity_by_sketch) for item in entities] - alignment = 1.0 if _dot(first["normal"], second["normal"]) >= 0 else -1.0 - offset = _dot(_sub(second["origin_mm"], first["origin_mm"]), first["normal"]) * alignment - return _shift_plane(first, offset / 2.0) + first_normal = _unit(first["normal"], "first mid-plane normal is degenerate") + second_normal = _unit(second["normal"], "second mid-plane normal is degenerate") + intersection = _cross(first_normal, second_normal) + intersection_length_squared = _dot(intersection, intersection) + if intersection_length_squared <= 1e-12: + alignment = 1.0 if _dot(first_normal, second_normal) >= 0 else -1.0 + offset = _dot(_sub(second["origin_mm"], first["origin_mm"]), first_normal) * alignment + return _shift_plane(first, offset / 2.0) + # 两个相交面没有“中点偏移面”。CADFS 的 MID_PLANE 是两面形成的 + # 二面角平分面:先令两个法向同向,再取其和作为平分面的法向;平面 + # 经过两原平面的交线。此处的 local x 轴由交线推导,后续草图的 + # (u, v) 坐标不依赖任意选择的输入 face frame。 + if _dot(first_normal, second_normal) < 0: + second_normal = [-value for value in second_normal] + # 法向翻转也会反转两平面的交线方向。交点公式中的交线必须与已 + # 对齐的法向保持同一方向,否则会将原点映射到交线的对称位置。 + intersection = _cross(first_normal, second_normal) + normal = _unit([first_normal[index] + second_normal[index] for index in range(3)], "mid-plane angle bisector is degenerate") + first_offset = _dot(first_normal, first["origin_mm"]) + second_offset = _dot(second_normal, second["origin_mm"]) + first_term = _cross(second_normal, intersection) + second_term = _cross(intersection, first_normal) + origin = [ + (first_offset * first_term[index] + second_offset * second_term[index]) / intersection_length_squared + for index in range(3) + ] + return _frame(origin, _cross(normal, intersection), normal) raise UnsupportedCapability(f"reference_plane:{plane_type.lower()}", f"current converter has no exact {plane_type} reference plane") def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: diagnostics: list[dict[str, Any]] = []; history = [] sketches: list[dict[str, Any]] = []; sketches_by_id: dict[str, dict[str, Any]] = {}; sketch_by_source: dict[str, dict[str, Any]] = {}; entity_by_sketch: dict[str, dict[str, dict[str, Any]]] = {} - feature_frames: dict[str, dict[str, Any]] = {} + feature_frames: dict[str, dict[str, Any]] = {}; cap_profiles: dict[str, dict[tuple[str, str], dict[str, Any]]] = {}; surface_profiles: list[dict[str, Any]] = []; swept_face_sketches: set[str] = set() features: list[dict[str, Any]] = []; complete = True; previous: list[str] = [] feature_by_id: dict[str, dict[str, Any]] = {}; feature_source_by_id: dict[str, str] = {} for step in model.steps: if isinstance(step, SketchIR): history.append({"feature_id": step.feature_id, "operation": "newSketch", "parameters": {"sketchPlane": plain(step.workplane)}, "entities": [{"entity_id": e.feature_id, "operation": e.operation, "parameters": plain(e.params)} for e in step.entities]}) try: - plane = _plane_from_query(step.workplane, feature_frames, sketch_by_source) - lowered, entities = _lower_sketch(step, plane); sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities + swept_face = parse_query(step.workplane).topology_type == "SWEPT_FACE" + plane = _query_plane(step.workplane, feature_frames, sketch_by_source, entity_by_sketch) if swept_face else _plane_from_query(step.workplane, feature_frames, sketch_by_source, entity_by_sketch) + profile = _revolve_swept_face_profile(step.workplane, plane, feature_frames, sketch_by_source, entity_by_sketch) if swept_face and not step.entities else None + if profile is None: lowered, entities = _lower_sketch(step, plane) + else: + lowered, entities = {"id": f"sketch_{step.feature_id}", "name": step.feature_id, "workplane": plane, "profile": profile}, {} + swept_face_sketches.add(step.feature_id) + sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities except Exception as exc: # 开放草图不能作为实体 profile,但其几何仍可能是后续基准面、 # 阵列轴或旋转轴的精确引用。保留为 reference 草图,后续实体 # 特征仍由 _profile_executable 明确拒绝,不能静默把开放轮廓实体化。 try: - plane = _plane_from_query(step.workplane, feature_frames, sketch_by_source) + plane = _query_plane(step.workplane, feature_frames, sketch_by_source, entity_by_sketch) if swept_face else _plane_from_query(step.workplane, feature_frames, sketch_by_source, entity_by_sketch) lowered, entities = _lower_sketch(step, plane, allow_open=True) sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities except Exception: @@ -599,34 +2201,143 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: # 聚合主体:CADFS transform 可能只选择 pattern copy 或多 body。 _bake_transform(p, previous, feature_by_id, feature_source_by_id, sketches_by_id, feature_frames, sketch_by_source, entity_by_sketch) continue + elif item.operation == "deleteBodies": + copies = [_pattern_copy_body(query) for query in _queries(p.get("entities"))] + if not copies: + raise ValueError("deleteBodies selection is empty") + for pattern_id, source_id, instance in copies: + pattern = feature_by_id.get(pattern_id) + if pattern is None or pattern.get("atomic_id") != "pattern_circular": + raise UnsupportedCapability("delete_bodies", "deleted body is not owned by a circular pattern") + params = pattern["params"] + if source_id not in params.get("source_feature_ids", []): + raise ValueError("pattern copy deletion source is not replayed by its owner") + count = int(params.get("pattern_count") or 0) + if instance < 1 or instance >= count: + raise ValueError("pattern copy deletion instance is outside the generated range") + excluded = params.setdefault("excluded_instance_indices", []) + if instance not in excluded: + excluded.append(instance) + continue elif item.operation == "cPlane": plane = _cplane(p, feature_frames, sketch_by_source, entity_by_sketch) feature = {"id": fid, "name": item.feature_id, "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane}, "execution_status": "supported"} - feature_frames[item.feature_id] = {"start": plane, "end": plane} + attachment = _attachment_plane(plane) + feature_frames[item.feature_id] = { + "start": plane, + "end": plane, + "start_attachment": attachment, + "end_attachment": attachment, + } elif item.operation == "extrude": + surface_profile_sketch = None if p.get("surfaceOperationType") is not None: - raise UnsupportedCapability("extrude_surface_or_mixed", "current CDSL engine has no exact surface or mixed solid/surface extrusion operation") - profile_kind = _profile_query_kind(p) - if profile_kind and profile_kind not in {"IMPRINT"}: - raise UnsupportedCapability(f"extrude_profile_topology:{profile_kind.lower()}", f"current CDSL engine cannot exactly replay an extrude profile selected from {profile_kind}") - source = _source_sketch(p) - if not source or source not in sketch_by_source: raise ValueError("extrude sketch query is unresolved") - if not _profile_executable(sketch_by_source[source]): raise ValueError("extrude sketch has no closed profile") + surface_operation = str(p.get("surfaceOperationType") or "").rsplit(".", 1)[-1].upper() + if surface_operation != "ADD": + raise UnsupportedCapability("extrude_surface_operation", "current CDSL surface extrude supports only NewSurfaceOperationType.ADD") + surface_source = _source_sketch({"surfaceEntities": p.get("surfaceEntities")}) + if not surface_source or surface_source not in sketch_by_source: + raise ValueError("surface extrude sketch query is unresolved") + surface_profile_sketch = _surface_profile_selection_sketch( + sketch_by_source[surface_source], p.get("surfaceEntities"), entity_by_sketch[surface_source], fid, + ) + sketches.append(surface_profile_sketch); sketches_by_id[surface_profile_sketch["id"]] = surface_profile_sketch + profile_value = _sketch_region_query(p.get("entities")) or p.get("entities") + profile_kind = parse_query(profile_value).topology_type + source = parse_query(profile_value).source_sketch or _source_sketch(p) + imprint = _imprint_sketch(profile_value) + cap_profile_sketch = _cap_face_profile_sketch(profile_value, feature_frames, cap_profiles, fid) + cap_edge_hole = _cap_edge_hole_profile_sketch( + profile_value, sketch_by_source, entity_by_sketch, feature_frames, fid, + ) + cap_edge_union_profile = _cap_edge_union_profile_sketch( + profile_value, sketch_by_source, entity_by_sketch, fid, + ) + intersect_profile_sketch = _intersect_partition_profile_sketch( + profile_value, sketch_by_source, entity_by_sketch, fid, + ) if profile_kind == "INTERSECT" else None + offset_face_profile = _offset_face_profile_sketch( + profile_value, feature_frames, sketches_by_id, sketch_by_source, entity_by_sketch, fid, + ) if profile_kind == "OFFSET_FACE" else None + if cap_edge_hole is not None: + profile_sketch, hole_selector = cap_edge_hole + if profile_sketch["id"] not in sketches_by_id: + sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch + elif cap_edge_union_profile is not None: + profile_sketch = cap_edge_union_profile + if profile_sketch["id"] not in sketches_by_id: + sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch + elif profile_kind == "SWEPT_EDGE" and imprint in swept_face_sketches: source = imprint + elif profile_kind == "CAP_FACE" and cap_profile_sketch is not None: + profile_sketch = cap_profile_sketch + sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch + elif intersect_profile_sketch is not None: + profile_sketch = intersect_profile_sketch + sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch + elif offset_face_profile is not None: + profile_sketch = offset_face_profile + sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch + elif profile_kind not in {None, "IMPRINT"}: + partitioned = _partitioned_imprint_sketch(p.get("entities"), entity_by_sketch) + if partitioned is not None: + source = partitioned; profile_kind = "IMPRINT" + else: + raise UnsupportedCapability(f"extrude_profile_topology:{profile_kind.lower()}", f"current CDSL engine cannot exactly replay an extrude profile selected from {profile_kind}") + if cap_edge_hole is None and cap_edge_union_profile is None and cap_profile_sketch is None and intersect_profile_sketch is None and offset_face_profile is None: + if not source or source not in sketch_by_source: raise ValueError("extrude sketch query is unresolved") + open_profile_sketch = _open_imprint_profile_sketch(sketch_by_source[source], profile_value, fid) + profile_sketch = open_profile_sketch or _profile_selection_sketch(sketch_by_source[source], profile_value, entity_by_sketch[source], fid) + if profile_sketch is not sketch_by_source[source]: sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch + operation = str(p.get("operationType") or "NEW").upper() + cutting = any(x in operation for x in ("REMOVE", "CUT")) + if cutting and profile_kind == "IMPRINT": + trimmed_profile_sketch = _surface_trimmed_imprint_profile(profile_sketch, surface_profile_sketch, surface_profiles) + if trimmed_profile_sketch is not profile_sketch: + for index, sketch in enumerate(sketches): + if sketch is profile_sketch: + sketches[index] = trimmed_profile_sketch; break + sketches_by_id[trimmed_profile_sketch["id"]] = trimmed_profile_sketch + profile_sketch = trimmed_profile_sketch + if not _profile_executable(profile_sketch): raise ValueError("extrude sketch has no closed profile") end = _end_condition("SYMMETRIC" if _bool(p.get("symmetric")) else p.get("endBound")) + if end["type"] == "up_to_body": + end["reference"] = _extent_reference(p.get("endBoundEntityBody"), "body", feature_frames, sketch_by_source, entity_by_sketch) + elif end["type"] == "up_to_surface": + end["reference"] = _extent_reference(p.get("endBoundEntityFace"), "face", feature_frames, sketch_by_source, entity_by_sketch) + elif end["type"] == "up_to_vertex": + end["reference"] = _intersection_vertex_reference( + p.get("endBoundEntityVertex"), feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, item.feature_id, + ) + if _bool(p.get("hasOffset")): + offset = _number(p.get("offsetDistance"), True) + if offset < 0: raise ValueError("extrude extent offset must be non-negative") + end["offset_mm"] = offset depth_value = p.get("depth"); depth = _number(depth_value, True) if depth_value is not None else 1.0 - operation = str(p.get("operationType") or "NEW").upper(); reverse = _bool(p.get("oppositeDirection")); cutting = any(x in operation for x in ("REMOVE", "CUT")) + # FeatureScript 的 operationType 缺省为 NEW。source 若要把结果 + # 并入既有主体会显式给出 ADD;不能根据当前 CDSL history 猜成 ADD, + # 否则会把应保留的独立 body 在转换时丢失。 + reverse = _bool(p.get("oppositeDirection")); cutting = any(x in operation for x in ("REMOVE", "CUT")) second = _bool(p.get("hasSecondDirection")) - if cutting and not second and end["type"] not in {"blind", "mid_plane", "through_all", "through_next"}: + if cutting and not second and end["type"] not in {"blind", "mid_plane", "through_all", "through_next", "up_to_surface", "up_to_vertex", "up_to_body"}: capability = f"extrude_cut_{end['type']}" raise UnsupportedCapability(capability, f"current CDSL atomic set has no exact {capability} operation") - if not cutting and not second and end["type"] not in {"blind", "mid_plane", "through_all", "through_next"}: + if not cutting and not second and end["type"] not in {"blind", "mid_plane", "through_all", "through_next", "up_to_surface", "up_to_vertex", "up_to_body"}: capability = f"extrude_add_{end['type']}" raise UnsupportedCapability(capability, f"current CDSL atomic set has no exact {capability} operation") if second: atomic = "extrude_cut_two_sided" if cutting else "extrude_add_two_sided" params = {"distance_mm": depth, "reverse": reverse, "end_condition": end} reverse_depth = _number(p.get("secondDirectionDepth", depth), True) - params.update({"reverse_distance_mm": reverse_depth, "reverse_end_condition": _end_condition(p.get("secondDirectionBound"))}) + reverse_end = _end_condition(p.get("secondDirectionBound")) + if reverse_end["type"] == "up_to_body": + reverse_end["reference"] = _extent_reference(p.get("secondDirectionBoundEntityBody"), "body", feature_frames, sketch_by_source, entity_by_sketch) + elif reverse_end["type"] == "up_to_surface": + reverse_end["reference"] = _extent_reference(p.get("secondDirectionBoundEntityFace"), "face", feature_frames, sketch_by_source, entity_by_sketch) + elif reverse_end["type"] == "up_to_vertex": + reverse_end["reference"] = _intersection_vertex_reference( + p.get("secondDirectionBoundEntityVertex"), feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, item.feature_id, + ) + params.update({"reverse_distance_mm": reverse_depth, "reverse_end_condition": reverse_end}) elif end["type"] == "mid_plane": blind = _end_condition("BLIND") atomic = "extrude_cut_two_sided" if cutting else "extrude_add_two_sided" @@ -634,43 +2345,194 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: else: atomic = "extrude_cut_blind" if cutting else "extrude_add_blind" params = {"distance_mm": depth, "reverse": reverse, "end_condition": end} - feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": params, "execution_status": "supported"} - plane = sketch_by_source[source]["workplane"] + if _bool(p.get("hasDraft")): + if second or end["type"] != "blind": + raise UnsupportedCapability("extrude_draft_extent", "current CDSL draft supports only one-sided blind extrusions") + params["draft"] = { + "angle_deg": _number(p.get("draftAngle")), + "pull_direction": _bool(p.get("draftPullDirection")), + } + if cap_edge_hole is not None: + if cutting or second or end["type"] != "blind" or "draft" in params: + raise UnsupportedCapability("extrude_cap_edge_profile", "current CAP_EDGE profile extrusion supports one-sided blind additive results") + atomic = "extrude_add_blind_with_hole" + if not cutting and _is_new_body_operation(operation): params["result_mode"] = "new_body" + feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": profile_sketch["id"], "params": params, "execution_status": "supported"} + if cap_edge_hole is not None: feature["selectors"] = [hole_selector] + if surface_profile_sketch is not None: + if end["type"] not in {"blind", "mid_plane"}: + raise UnsupportedCapability("extrude_surface_extent", "current CDSL surface extrude supports blind and symmetric extents only") + surface_params = {"distance_mm": params["distance_mm"], "reverse": bool(params.get("reverse"))} + if "reverse_distance_mm" in params: + surface_params["reverse_distance_mm"] = params["reverse_distance_mm"] + surface_feature = { + "id": f"{fid}_surface", + "name": f"{item.feature_id} surface", + "atomic_id": "extrude_surface", + "depends_on": [fid], + "sketch_id": surface_profile_sketch["id"], + "params": surface_params, + "execution_status": "supported", + } + plane = profile_sketch["workplane"] if end["type"] == "blind" and not second: direction = -1 if reverse else 1 - feature_frames[item.feature_id] = {"start": dict(plane), "end": _shift_plane(plane, direction * depth)} + # FeatureScript 的 CAP_FACE 是实体端盖,而不是原草图平面。 + # 记录实际外法向后,后续附着在 start/end cap 的草图才能沿 + # 正确一侧拉伸;不能复用 profile 的初始法向。 + feature_frames[item.feature_id] = { + "start": _oriented_plane(plane, -direction), + "end": _oriented_plane(plane, direction, direction * depth), + "profile": dict(plane), + } + if source and source in sketch_by_source: + cap_profiles[item.feature_id] = _imprint_cap_profiles( + sketch_by_source[source], p.get("entities"), entity_by_sketch[source], + ) elif end["type"] == "mid_plane": direction = -1 if reverse else 1 - feature_frames[item.feature_id] = {"start": _shift_plane(plane, -direction * depth / 2), "end": _shift_plane(plane, direction * depth / 2)} + feature_frames[item.feature_id] = { + "start": _oriented_plane(plane, -direction, -direction * depth / 2), + "end": _oriented_plane(plane, direction, direction * depth / 2), + "profile": dict(plane), + } elif item.operation == "loft": - sources = _loft_profile_sketches(p) - missing = [source for source in sources if source not in sketch_by_source] - if missing: - raise ValueError("loft profile sketches are unresolved: " + ", ".join(missing)) - non_closed = [source for source in sources if not _profile_executable(sketch_by_source[source])] - if non_closed: - raise ValueError("loft profile sketches have no closed profile: " + ", ".join(non_closed)) + cap_face_loft = _loft_cap_face_profile(p, sketch_by_source, entity_by_sketch, feature_frames, fid) + if cap_face_loft is not None: + profile_sketch, cap_selector, cap_plane = cap_face_loft + if profile_sketch["id"] not in sketches_by_id: + sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch + if not _profile_executable(profile_sketch): raise ValueError("loft profile sketch has no closed profile") + feature = { + "id": fid, + "name": item.feature_id, + "atomic_id": "loft_add_with_cap_face", + "depends_on": depends, + "params": {"profile_sketch_ids": [profile_sketch["id"]]}, + "selectors": [cap_selector], + "execution_status": "supported", + } + frames = _loft_cap_frames(cap_plane, profile_sketch["workplane"]) + else: + sources = _loft_profile_sketches(p) + missing = [source for source in sources if source not in sketch_by_source] + if missing: + raise ValueError("loft profile sketches are unresolved: " + ", ".join(missing)) + non_closed = [source for source in sources if not _profile_executable(sketch_by_source[source])] + if non_closed: + raise ValueError("loft profile sketches have no closed profile: " + ", ".join(non_closed)) + feature = { + "id": fid, + "name": item.feature_id, + "atomic_id": "loft_add", + "depends_on": depends, + "params": {"profile_sketch_ids": [sketch_by_source[source]["id"] for source in sources]}, + "execution_status": "supported", + } + frames = _loft_cap_frames( + sketch_by_source[sources[0]]["workplane"], sketch_by_source[sources[-1]]["workplane"], + ) + if frames is not None: feature_frames[item.feature_id] = frames + elif item.operation == "sweep": + profile_source = _source_sketch({"entities": p.get("profiles")}) + if not profile_source or profile_source not in sketch_by_source: + raise ValueError("sweep profile sketch is unresolved") + profile_sketch = _profile_selection_sketch( + sketch_by_source[profile_source], p.get("profiles"), entity_by_sketch[profile_source], fid, + ) + if profile_sketch is not sketch_by_source[profile_source]: + sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch + if not _profile_executable(profile_sketch): + raise ValueError("sweep profile sketch has no closed profile") + path_query = parse_query(p.get("path")) + path_source = path_query.source_sketch + path_entity = (entity_by_sketch.get(path_source or "") or {}).get(path_query.source_entity or "") + path_sketch = sketch_by_source.get(path_source or "") + if path_entity is None or path_sketch is None: + raise ValueError("sweep path is unresolved") + if path_entity.get("type") not in {"line", "bspline"}: + raise UnsupportedCapability("sweep_path", "current CDSL sweep requires one line or B-spline path") + operation = str(p.get("operationType") or "NEW").upper() + if any(value in operation for value in ("REMOVE", "CUT")): + raise UnsupportedCapability("sweep_remove", "current CDSL sweep supports additive solid results only") + path_segment = deepcopy(path_entity) + path = {"workplane": path_sketch["workplane"], "segment": path_segment} + if _sweep_profile_attaches_at_path_end(profile_sketch, path): + path_segment = _reversed_sweep_path_segment(path_segment) feature = { "id": fid, "name": item.feature_id, - "atomic_id": "loft_add", + "atomic_id": "sweep_add", "depends_on": depends, - "params": {"profile_sketch_ids": [sketch_by_source[source]["id"] for source in sources]}, + "sketch_id": profile_sketch["id"], + "params": {"path": {"workplane": path_sketch["workplane"], "segment": path_segment}}, + "execution_status": "supported", + } + if _is_new_body_operation(operation): feature["params"]["result_mode"] = "new_body" + frames = _sweep_cap_frames(profile_sketch, feature["params"]["path"]) + if frames is not None: + feature_frames[item.feature_id] = frames + elif item.operation == "booleanBodies": + operation = str(p.get("operationType") or "").split(".")[-1].upper() + operation_map = { + "UNION": "union", + "SUBTRACTION": "subtract", + "INTERSECTION": "intersect", + } + if operation not in operation_map: + raise UnsupportedCapability("boolean_bodies_operation", "current CDSL booleanBodies supports union, subtraction and intersection") + targets = _boolean_body_sources(p.get("targets")) + tools = _boolean_body_sources(p.get("tools")) + if set(targets) & set(tools): + raise ValueError("booleanBodies targets and tools must be disjoint") + missing = [source for source in targets + tools if source not in feature_by_id] + if missing: + raise ValueError("booleanBodies source features are unresolved: " + ", ".join(missing)) + feature = { + "id": fid, + "name": item.feature_id, + "atomic_id": "boolean_bodies", + "depends_on": list(dict.fromkeys(targets + tools + depends)), + "params": { + "operation": operation_map[operation], + "target_feature_ids": targets, + "tool_feature_ids": tools, + "keep_tools": _bool(p.get("keepTools")), + }, "execution_status": "supported", } elif item.operation == "revolve": - if p.get("surfaceOperationType") is not None and p.get("operationType") is None: - raise UnsupportedCapability("revolve_surface", "current CDSL engine has no exact surface-revolve operation") + # surfaceOperationType alone does not make a body operation a + # surface operation. CADFS emits it for closed sketch regions + # that produce ordinary solids too. Only ToolBodyType.SURFACE + # selects a sheet result; its profile is in surfaceEntities. + surface_operation = str(p.get("bodyType") or "").rsplit(".", 1)[-1].upper() == "SURFACE" + profile_entities = p.get("surfaceEntities") if surface_operation else p.get("entities") source = _source_sketch(p) if not source or source not in sketch_by_source: raise ValueError("revolve sketch query is unresolved") - if not _profile_executable(sketch_by_source[source]): raise ValueError("revolve sketch has no closed profile") + profile_sketch = _profile_selection_sketch(sketch_by_source[source], profile_entities, entity_by_sketch[source], fid) + if profile_sketch is not sketch_by_source[source]: sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch + if not _profile_executable(profile_sketch): raise ValueError("revolve sketch has no closed profile") axis_q = parse_query(p.get("axis")); axis_entity = (entity_by_sketch.get(axis_q.source_sketch or "") or {}).get(axis_q.source_entity or "") if not axis_entity or axis_entity.get("type") != "line": raise ValueError("revolve axis is unresolved") plane = sketch_by_source[axis_q.source_sketch]["workplane"]; start, end = _global(plane, axis_entity["start"]), _global(plane, axis_entity["end"]) direction = [end[i]-start[i] for i in range(3)]; norm = math.sqrt(sum(x*x for x in direction)); direction = [x/norm for x in direction] - operation = str(p.get("operationType") or p.get("surfaceOperationType") or "NEW").upper(); atomic = "revolve_cut" if "REMOVE" in operation else "revolve_add" + # 闭合实体回转携带 surfaceOperationType、但没有 bodyType 时, + # 该字段只是曲面处理参数,不能被误读为 NewBodyOperationType.NEW。 + # 它保持默认 ADD,F8 这类重叠回转因而会参与当前实体的融合。 + # 完全没有该字段的普通实体回转仍保持 FeatureScript 的默认 NEW + # body 语义;显式 ToolBodyType.SURFACE 则以独立 shell 执行。 + default_operation = "NEW" if surface_operation or p.get("surfaceOperationType") is None else "ADD" + operation = str(p.get("operationType") or default_operation).upper() + surface_kind = str(p.get("surfaceOperationType") or "NEW").upper() + if surface_operation and "NEW" not in surface_kind: + raise UnsupportedCapability("revolve_surface_operation", "current CDSL surface revolve supports only NewSurfaceOperationType.NEW") + atomic = "revolve_surface" if surface_operation else "revolve_cut" if "REMOVE" in operation else "revolve_add" full = "FULL" in str(p.get("revolveType") or "FULL").upper(); angle = 360.0 if full else _number(p.get("angle", 360.0)) - feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": {"angle_deg": angle, "reverse": _bool(p.get("oppositeDirection")), "axis": {"origin_mm": start, "direction": direction}}, "execution_status": "supported"} + params = {"angle_deg": angle, "reverse": _bool(p.get("oppositeDirection")), "axis": {"origin_mm": start, "direction": direction}} + if atomic == "revolve_add" and _is_new_body_operation(operation): params["result_mode"] = "new_body" + feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": profile_sketch["id"], "params": params, "execution_status": "supported"} + if full: feature_frames[item.feature_id] = {"revolve_axis": {"origin_mm": start, "direction": direction}, "revolve_full": True} elif item.operation in {"fillet", "chamfer"}: key = "radius" if item.operation == "fillet" else "width" chamfer_type = str(p.get("chamferType") or "EQUAL_OFFSETS").split(".")[-1].upper() @@ -684,9 +2546,30 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: refs = _source_refs(query_value) source_entity = (entity_by_sketch.get(query.source_sketch or "") or {}).get(query.source_entity or "") geometry: dict[str, Any] = {} - frame = feature_frames.get(owner); cap = frame and frame["start" if query.is_start else "end"] + frame = feature_frames.get(owner) + cap = frame.get("start" if query.is_start else "end") if frame and {"start", "end"}.issubset(frame) else None if selector_kind == "face" and query.topology_type == "CAP_FACE" and cap: geometry = {"normal": cap["normal"], "plane_offset_mm": sum(cap["normal"][i] * cap["origin_mm"][i] for i in range(3))} + elif selector_kind == "face" and query.topology_type == "SWEPT_FACE" and source_entity and source_entity["type"] == "circle": + # 圆形 profile 拉伸得到的侧面以轴线、半径为标识。同一 feature 可以 + # 生成多个半径相同的圆柱面,因此保留草图圆心作为轴原点来解消歧义。 + source_plane = sketch_by_source.get(query.source_sketch or "", {}).get("workplane") + if source_plane is not None: + geometry = { + "axis_origin_mm": _global(source_plane, source_entity["center"]), + "axis_direction": source_plane["normal"], + "radius_mm": source_entity["radius_mm"], + } + elif selector_kind == "edge" and query.topology_type == "OFFSET_EDGE" and source_entity and source_entity["type"] == "circle": + offset_planes = (frame or {}).get("shell_offset_edge_planes") or {} + source_key = f"{query.source_sketch}:{query.source_entity}" + offset_plane = offset_planes.get(source_key) + if offset_plane is not None: + geometry = { + "source_circle_center_mm": _global(offset_plane, source_entity["center"]), + "source_circle_radius_mm": source_entity["radius_mm"], + "source_plane_normal": offset_plane["normal"], + } if selector_kind == "edge" and source_entity and cap: if query.topology_type == "SWEPT_EDGE" and frame: local_point = source_entity.get("point") if source_entity["type"] == "point" else None @@ -695,7 +2578,19 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: if left and right and left.get("type") == right.get("type") == "line": local_point = next((a for a in (left["start"], left["end"]) for b in (right["start"], right["end"]) if math.dist(a, b) <= 1e-5), None) if local_point is None: raise ValueError("swept edge source intersection is unresolved") - start, end = _global(frame["start"], local_point), _global(frame["end"], local_point) + # CAP_FACE 的外法向会使 start/end frame 在反向拉伸 + # 时翻转局部 x 轴。SWEPT_EDGE 的 source point 仍在 + # 原草图 frame 中,不能把同一个局部坐标分别投到两个 + # 朝向不同的 cap,否则一个竖直棱会伪造成跨整个截面的 + # 对角 bbox。用 profile frame 定位起点,再只平移到 + # end cap 的实际原点,保持 source 点在两端一致。 + profile = frame.get("profile") + if profile is None: + start, end = _global(frame["start"], local_point), _global(frame["end"], local_point) + else: + start = _global(profile, local_point) + offset = _sub(frame["end"]["origin_mm"], profile["origin_mm"]) + end = [start[axis] + offset[axis] for axis in range(3)] geometry = {"curve_type": "line", "bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]} elif source_entity["type"] == "circle": # Keep the source circle signature until the prefix has been @@ -711,6 +2606,8 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: selectors.append({"kind": selector_kind, "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry}) params = {"radius_mm" if item.operation == "fillet" else "distance_mm": amount} if item.operation == "fillet": params["tangent_propagation"] = _bool(p.get("tangentPropagation")) + elif _bool(p.get("tangentPropagation")): + params["tangent_propagation"] = True elif chamfer_type == "TWO_OFFSETS": second = _number(p.get("width2"), True) if _bool(p.get("oppositeDirection")): params["distance_mm"], second = second, params["distance_mm"] @@ -721,6 +2618,49 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: second = amount * math.tan(angle); params["distance_mm"] = second; params["distance_2_mm"] = amount else: params["angle_rad"] = angle feature = {"id": fid, "name": item.feature_id, "atomic_id": item.operation, "depends_on": depends, "params": params, "selectors": selectors, "execution_status": "supported"} + elif item.operation == "shell": + if _bool(p.get("oppositeDirection")): + raise UnsupportedCapability("shell_outward", "current CDSL shell only supports inward wall offsets") + thickness = _number(p.get("thickness"), True) + selectors = []; offset_edge_planes: dict[str, dict[str, Any]] = {} + for index, query_value in enumerate(_queries(p.get("entities"))): + query = parse_query(query_value) + _call, _owner, topology, kind, _definition = _direct_make_query(query_value) + if kind not in {"face", "entitytype.face"}: + raise UnsupportedCapability("shell_face_selector", "current CDSL shell requires face removal selectors") + if topology == "CAP_FACE": + selector = _face_reference(query_value, feature_frames, sketch_by_source, entity_by_sketch) + source_frame = feature_frames.get(query.owner_feature or "") or {} + cap = source_frame.get("start" if query.is_start else "end") + if cap is not None: + for source, entity_id in _source_refs(query_value): + offset_edge_planes[f"{source}:{entity_id}"] = dict(cap) + elif topology == "COPY": + selector = _pattern_copy_face_reference( + query_value, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, + ) + else: + raise UnsupportedCapability("shell_face_selector", "current CDSL shell requires CAP_FACE or COPY(CAP_FACE) removal selectors") + selector["stable_id"] = f"cadfs_{fid}_{index}" + selectors.append(selector) + if not selectors: + raise ValueError("shell has no face removal selector") + feature = {"id": fid, "name": item.feature_id, "atomic_id": "shell", "depends_on": depends, "params": {"thickness_mm": thickness, "inward": True}, "selectors": selectors, "execution_status": "supported"} + owners = {selector["owner_feature_id"].removeprefix("f_") for selector in selectors} + if len(owners) == 1: + source = next(iter(owners)) + source_feature = feature_by_id.get(f"f_{source}") or {} + source_frame = feature_frames.get(source) + if source_frame is not None and source_feature.get("sketch_id"): + feature_frames[item.feature_id] = { + **source_frame, + "shell_source": source, + "shell_thickness_mm": thickness, + "shell_profile_sketch_id": source_feature["sketch_id"], + } + if offset_edge_planes: + frame = feature_frames.setdefault(item.feature_id, {}) + frame["shell_offset_edge_planes"] = offset_edge_planes elif item.operation == "hole": locations = _queries(p.get("locations")); positions = []; host_plane = None for location in locations: @@ -732,24 +2672,34 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: frame = {**host_plane, "y_dir": _y_dir(host_plane)} if _bool(p.get("oppositeDirection")): frame = {**frame, "normal": [-v for v in frame["normal"]]} style = str(p.get("style") or "SIMPLE").split(".")[-1].lower(); end = str(p.get("endStyle") or "BLIND").upper() - condition = "through_all_both" if "BOTH" in end else "through_all" if "THROUGH" in end else "blind" + standard_through_diameter = _standard_tapped_through_bore_diameter(p, style, end) + condition = "through_all_both" if "BOTH" in end else "through_all" if "THROUGH" in end or standard_through_diameter is not None else "blind" depth_value = p.get("holeDepth") or p.get("tappedDepth") if condition == "blind" and depth_value is None: raise ValueError("blind hole depth is unresolved") depth = _number(depth_value, True) if depth_value is not None else 1.0 condition_code = {"blind": 0, "through_all": 1, "through_all_both": 2}[condition] - hole_params: dict[str, Any] = {"hole_type": style, "diameter_mm": _number(p.get("holeDiameter"), True), "depth_mm": depth, "end_condition": {"type": condition, "solidworks_code": condition_code}, "positions": positions, "host_face": {"frame": frame}} + hole_params: dict[str, Any] = {"hole_type": style, "diameter_mm": standard_through_diameter or _number(p.get("holeDiameter"), True), "depth_mm": depth, "end_condition": {"type": condition, "solidworks_code": condition_code}, "positions": positions, "host_face": {"frame": frame}} if style.upper() in {"COUNTERSINK", "C_SINK"}: hole_params["countersink"] = {"diameter_mm": _number(p.get("countersinkDiameter") or p.get("cSinkDiameter") or p.get("majorDiameter"), True), "angle_rad": math.radians(_number(p.get("countersinkAngle") or p.get("cSinkAngle") or 90.0))} - if style.upper() in {"COUNTERBORE", "C_BORE"}: + if standard_through_diameter is None and style.upper() in {"COUNTERBORE", "C_BORE"}: hole_params["counterbore"] = {"diameter_mm": _number(p.get("counterboreDiameter") or p.get("cBoreDiameter") or p.get("majorDiameter"), True), "depth_mm": _number(p.get("counterboreDepth") or p.get("cBoreDepth"), True)} if _bool(p.get("isTappedThrough")) or p.get("tapSize") is not None: hole_params["thread"] = {"source": "CADFS", "decorative": True} feature = {"id": fid, "name": item.feature_id, "atomic_id": "hole_wizard", "depends_on": depends, "params": hole_params, "execution_status": "supported"} elif item.operation == "circularPattern": sources = _pattern_source_features(p.get("entities"), previous) + sources = _pattern_body_history_sources(p.get("entities"), sources, previous, feature_by_id) axis = _circular_pattern_axis(p.get("axis"), feature_frames, sketch_by_source, entity_by_sketch) count = int(_number(p.get("instanceCount"))) if count < 1: raise ValueError("circular pattern instanceCount must be positive") + operation = str(p.get("operationType") or "NEW").split(".")[-1].upper() + operation_mode = "remove" if any(value in operation for value in ("REMOVE", "CUT")) else "add" + if operation_mode == "remove": + for source in sources: + source_feature = feature_by_id.get(source) + if source_feature is None: + raise ValueError("circular remove pattern source is unresolved") + _pattern_remove_source(source_feature) feature = { "id": fid, "name": item.feature_id, @@ -760,21 +2710,28 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: "axis": axis, "pattern_count": count, "sweep_angle_deg": _number(p.get("angle", 360.0)), + "operation_mode": operation_mode, }, "execution_status": "supported", } elif item.operation == "mirror": owners = [] + mirror_current_body = False for call in walk_calls(p.get("entities")): if call.name == "makeQuery" and call.args: owner = symbolic_string(call.args[0]); if "F" in owner: source_id = "f_" + owner[owner.find("F"):].split(".", 1)[0] if source_id in previous and source_id not in owners: owners.append(source_id) + query = parse_query(call) + if query.topology_type == "SWEPT_BODY" and query.kind in {"body", "entitytype.body"}: + mirror_current_body = True if not owners: raise ValueError("mirror source features are unresolved") plane_query = p.get("mirrorPlane"); plane_info = parse_query(plane_query); plane_owner = f"f_{plane_info.owner_feature}" if plane_info.owner_feature else None if plane_owner and any(existing["id"] == plane_owner and existing["atomic_id"] == "reference_plane" for existing in features): mirror_plane = {"kind": "plane", "owner_feature_id": plane_owner, "stable_id": f"cadfs_{fid}_plane", "source": "runtime_snapshot", "confidence": 1.0} + source_plane = feature_frames.get(plane_info.owner_feature or "", {}).get("start") + if source_plane is None: raise ValueError("mirror plane frame is unresolved") else: plane = _default_plane(plane_query) if plane is None: raise ValueError("mirror plane is not a default or reference plane") @@ -782,10 +2739,27 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: features.append({"id": plane_owner, "name": f"{item.feature_id} plane", "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane}, "execution_status": "supported"}) previous.append(plane_owner) mirror_plane = {"kind": "plane", "owner_feature_id": plane_owner, "stable_id": f"cadfs_{fid}_plane", "source": "runtime_snapshot", "confidence": 1.0} + source_plane = plane feature = {"id": fid, "name": item.feature_id, "atomic_id": "pattern_mirror", "depends_on": list(dict.fromkeys(owners + [plane_owner])), "params": {"source_feature_ids": owners, "mirror_plane": mirror_plane}, "selectors": [mirror_plane], "execution_status": "supported"} + if mirror_current_body: feature["params"]["mirror_current_body"] = True + # COPY(CAP_FACE) query 属于 pattern 的特定 instance,不能直接回退到 + # source loft 的端盖。保存镜像变换和 source feature,供后续草图 + # 在 lowering 期从 CADFS provenance 复算物理端盖 frame。 + feature_frames[item.feature_id] = { + "copy_transform": {"type": "mirror", "plane": source_plane}, + "copy_source_features": [source[2:] for source in owners], + } else: raise ValueError(f"operation mapping not implemented: {item.operation}") - features.append(feature); feature_by_id[fid] = feature; feature_source_by_id[fid] = item.feature_id; previous.append(fid) + features.append(feature); feature_by_id[fid] = feature; feature_source_by_id[fid] = item.feature_id + if item.operation == "extrude" and surface_profile_sketch is not None: + features.append(surface_feature) + surface_profiles.append({ + "profile": deepcopy(surface_profile_sketch["profile"]), + "workplane": dict(surface_profile_sketch["workplane"]), + **surface_feature["params"], + }) + previous.append(fid) except UnsupportedCapability as exc: diagnostics.append({"code": "unsupported_engine_capability", "capability": exc.capability, "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False except Exception as exc: diff --git a/cadfs_to_cdsl/pipeline.py b/cadfs_to_cdsl/pipeline.py index 0cee6328..bccc6b82 100644 --- a/cadfs_to_cdsl/pipeline.py +++ b/cadfs_to_cdsl/pipeline.py @@ -102,7 +102,7 @@ def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_sec def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_seconds: float = 30.0) -> dict[str, Any]: directory = _sample_dir(output, sample.sample_id); status_path = directory / "status.json" - status = read_json(status_path) if status_path.exists() else convert_one(sample, output, force=force) + status = convert_one(sample, output, force=True) if force else read_json(status_path) if status_path.exists() else convert_one(sample, output) # A partial conversion may still retain a self-contained, semantically # executable CDSL prefix. That prefix is valuable engine evidence and # must be rebuilt instead of being hidden behind the conversion label. @@ -146,7 +146,7 @@ def compare_one(sample: Sample, output: Path, *, force: bool = False, compare_mo else: comparison = read_json(comparison_path) status["comparison_decision"] = comparison["decision"] accepted = comparison[compare_mode]["passed"] - status["status"] = "rebuilt_approximate" if accepted else "rebuilt_rejected" + status["status"] = "rebuilt_strict" if comparison["strict"]["passed"] else "rebuilt_approximate" if accepted else "rebuilt_rejected" write_json(status_path, status); return status diff --git a/cadfs_to_cdsl/query_parser.py b/cadfs_to_cdsl/query_parser.py index b14f7899..c0f9c13c 100644 --- a/cadfs_to_cdsl/query_parser.py +++ b/cadfs_to_cdsl/query_parser.py @@ -45,7 +45,8 @@ def parse_query(value: Any) -> QueryInfo: elif "isStart" in definition: info.is_start = bool(definition["isStart"]) if call.name in {"sQuery", "sketchEntityQuery"} and len(call.args) >= 3: sketch = symbolic_string(call.args[0]); info.source_sketch = sketch.split(".", 1)[0] - info.kind = str(call.args[1]).lower(); info.source_entity = str(call.args[2]) + if info.topology_type is None: info.kind = str(call.args[1]).lower() + info.source_entity = str(call.args[2]) if call.name == "qSketchRegion" and call.args: info.source_sketch = symbolic_string(call.args[0]); info.kind = "face" return info diff --git a/cadfs_to_cdsl/reports.py b/cadfs_to_cdsl/reports.py index c72ad908..8b26191b 100644 --- a/cadfs_to_cdsl/reports.py +++ b/cadfs_to_cdsl/reports.py @@ -1,6 +1,6 @@ from __future__ import annotations -import csv, json, re +import csv, json, re, tempfile from collections import Counter, defaultdict from datetime import datetime from pathlib import Path @@ -9,9 +9,16 @@ from typing import Any def write_json(path: Path, value: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_suffix(path.suffix + ".tmp") - temporary.write_text(json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8") - temporary.replace(path) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False, + ) as handle: + temporary = Path(handle.name) + handle.write(json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True) + "\n") + temporary.replace(path) + finally: + if temporary is not None: temporary.unlink(missing_ok=True) def read_json(path: Path) -> Any: return json.loads(path.read_text(encoding="utf-8")) diff --git a/cadfs_to_cdsl/run_full_rebuild.sh b/cadfs_to_cdsl/run_full_rebuild.sh new file mode 100755 index 00000000..d97edb90 --- /dev/null +++ b/cadfs_to_cdsl/run_full_rebuild.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Run the complete CADFS corpus while retaining a snapshot of the prior run. +set -Eeuo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +INPUT_DIR="${ROOT_DIR}/data/cadfs-sample/CADFS_test" +OUTPUT_DIR="${ROOT_DIR}/cadfs_to_cdsl/output" +HISTORY_DIR="${ROOT_DIR}/cadfs_to_cdsl/output-history" +WORKERS=4 +TIMEOUT_SECONDS=60 +COMPARE_MODE=rp +FORCE=true + +usage() { + cat <<'EOF' +Usage: ./cadfs_to_cdsl/run_full_rebuild.sh [options] + +Runs conversion, CDSL rebuild, STEP comparison, and the Markdown report for +every sample in data/cadfs-sample/CADFS_test. + +By default the existing cadfs_to_cdsl/output directory is moved to +cadfs_to_cdsl/output-history// before a forced full rerun. + +Options: + --resume Reuse cached results in output; do not archive or force rerun. + --workers N Concurrent samples (1-8, default: 4). + --timeout-seconds N Per-model rebuild timeout in seconds (default: 60). + --compare-mode rp|strict Comparison acceptance mode (default: rp). + -h, --help Show this help message. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --resume) + FORCE=false + shift + ;; + --workers) + [[ $# -ge 2 ]] || { echo "--workers requires a value" >&2; exit 2; } + WORKERS="$2" + shift 2 + ;; + --timeout-seconds) + [[ $# -ge 2 ]] || { echo "--timeout-seconds requires a value" >&2; exit 2; } + TIMEOUT_SECONDS="$2" + shift 2 + ;; + --compare-mode) + [[ $# -ge 2 ]] || { echo "--compare-mode requires a value" >&2; exit 2; } + COMPARE_MODE="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +[[ "$WORKERS" =~ ^[1-8]$ ]] || { echo "--workers must be an integer between 1 and 8" >&2; exit 2; } +[[ "$TIMEOUT_SECONDS" =~ ^[0-9]+([.][0-9]+)?$ ]] && (( $(awk "BEGIN { print (${TIMEOUT_SECONDS} > 0) }") )) || { + echo "--timeout-seconds must be positive" >&2 + exit 2 +} +[[ "$COMPARE_MODE" == "rp" || "$COMPARE_MODE" == "strict" ]] || { + echo "--compare-mode must be rp or strict" >&2 + exit 2 +} +[[ -d "$INPUT_DIR" ]] || { echo "CADFS input directory not found: $INPUT_DIR" >&2; exit 1; } +[[ -f "$INPUT_DIR/CADFS_text_test.jsonl" ]] || { echo "CADFS text manifest not found: $INPUT_DIR/CADFS_text_test.jsonl" >&2; exit 1; } + +if [[ -x "${ROOT_DIR}/backend/.venv/bin/python" ]]; then + PYTHON_BIN="${PYTHON_BIN:-${ROOT_DIR}/backend/.venv/bin/python}" +else + PYTHON_BIN="${PYTHON_BIN:-python3}" +fi +command -v "$PYTHON_BIN" >/dev/null 2>&1 || { echo "Python executable not found: $PYTHON_BIN" >&2; exit 1; } + +archive_output() { + [[ -d "$OUTPUT_DIR" ]] || return + [[ -n "$(find "$OUTPUT_DIR" -mindepth 1 -maxdepth 1 -print -quit)" ]] || return + + mkdir -p "$HISTORY_DIR" + local timestamp archive_dir suffix=1 + timestamp="$(date +%Y%m%d-%H%M%S)" + archive_dir="${HISTORY_DIR}/${timestamp}" + while [[ -e "$archive_dir" ]]; do + archive_dir="${HISTORY_DIR}/${timestamp}-${suffix}" + ((suffix += 1)) + done + mv "$OUTPUT_DIR" "$archive_dir" + echo "Previous output archived at: $archive_dir" +} + +on_error() { + local code=$? + echo "Run stopped with exit code ${code}. Partial artifacts remain in: $OUTPUT_DIR" >&2 + exit "$code" +} +trap on_error ERR + +if [[ "$FORCE" == true ]]; then + archive_output + echo "Starting forced full corpus rebuild." +else + echo "Resuming existing full corpus rebuild without archiving output." +fi +mkdir -p "$OUTPUT_DIR" + +cd "$ROOT_DIR" +pipeline_args=( + pipeline + --input "$INPUT_DIR" + --output "$OUTPUT_DIR" + --workers "$WORKERS" + --timeout-seconds "$TIMEOUT_SECONDS" + --compare-mode "$COMPARE_MODE" +) +if [[ "$FORCE" == true ]]; then + pipeline_args+=(--force) +fi + +PYTHONPATH="${ROOT_DIR}/backend:${ROOT_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \ + "$PYTHON_BIN" -m cadfs_to_cdsl "${pipeline_args[@]}" +PYTHONPATH="${ROOT_DIR}/backend:${ROOT_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \ + "$PYTHON_BIN" -m cadfs_to_cdsl report --input "$INPUT_DIR" --output "$OUTPUT_DIR" --markdown + +echo "Current results: $OUTPUT_DIR" +echo "Full report: $OUTPUT_DIR/full_run_report.md" diff --git a/cadfs_to_cdsl/selector_binding.py b/cadfs_to_cdsl/selector_binding.py index 7baafa3c..122be348 100644 --- a/cadfs_to_cdsl/selector_binding.py +++ b/cadfs_to_cdsl/selector_binding.py @@ -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 diff --git a/cadfs_to_cdsl/tests/test_integration.py b/cadfs_to_cdsl/tests/test_integration.py index 20b8e712..c83de4dc 100644 --- a/cadfs_to_cdsl/tests/test_integration.py +++ b/cadfs_to_cdsl/tests/test_integration.py @@ -23,7 +23,7 @@ class IntegrationTests(unittest.TestCase): self.assertTrue(comparison["rp"]["passed"]) self.assertEqual(comparison["decision"], "approximate_pass") - def test_counterbore_abbreviation_00002243(self): + def test_standard_tapped_through_counterbore_00002243(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0000/00002243.txt" if not feature.exists(): self.skipTest("CADFS sample is not installed") @@ -32,13 +32,165 @@ class IntegrationTests(unittest.TestCase): self.assertEqual(first["atomic_id"], "extrude_add_two_sided") self.assertEqual(first["params"]["distance_mm"], 125.0) self.assertEqual(first["params"]["reverse_distance_mm"], 125.0) - gap = next(item for item in result.diagnostics if item.get("feature_id") == "F3") - self.assertEqual(gap["code"], "unsupported_engine_capability") - self.assertEqual(gap["capability"], "extrude_cut_two_sided") + cut = next(item for item in result.cdsl["features"] if item["id"] == "f_F3") + self.assertEqual(cut["atomic_id"], "extrude_cut_two_sided") hole = next(item for item in result.cdsl["features"] if item["atomic_id"] == "hole_wizard") self.assertEqual(hole["params"]["hole_type"], "c_bore") - self.assertEqual(hole["params"]["counterbore"]["diameter_mm"], 17.25) - self.assertEqual(hole["params"]["counterbore"]["depth_mm"], 10.0) + self.assertEqual(hole["params"]["diameter_mm"], 15.0) + self.assertEqual(hole["params"]["end_condition"]["type"], "through_all") + self.assertNotIn("counterbore", hole["params"]) + + with tempfile.TemporaryDirectory() as tmp: + rebuilt = Path(tmp) / "rebuild.step" + self.assertEqual(rebuild_candidate(result.cdsl, rebuilt)["status"], "rebuilt") + comparison = compare_steps(root / "step_abc/0000/00002243.step", rebuilt) + self.assertTrue(comparison["strict"]["passed"]) + + def test_rectilinear_fillet_arcs_00129362_preserve_recovered_sketch_geometry(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0012/00129362.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00129362"), {}) + profile = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F0")["profile"] + arcs = [segment for segment in profile["contours"][0]["segments"] if segment["type"] == "arc"] + self.assertEqual(sorted(arc["center"] for arc in arcs), [[25.4, 12.7], [25.4, 63.5]]) + self.assertTrue(all(abs(arc["radius_mm"] - 12.7) <= 1e-12 for arc in arcs)) + triangle = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F5")["profile"] + self.assertEqual( + sorted({coordinate for contour in triangle["contours"] for segment in contour["segments"] for point in (segment.get("start"), segment.get("end")) if point for coordinate in point}), + [-12.7, -6.35, 0.0, 6.35, 12.7, 25.4, 34.925, 41.275], + ) + + with tempfile.TemporaryDirectory() as tmp: + rebuilt = Path(tmp) / "rebuild.step" + outcome = rebuild_candidate(result.cdsl, rebuilt) + self.assertEqual(outcome["status"], "rebuilt") + + def test_cylindrical_swept_face_selector_00111611(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0011/00111611.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00111611"), {}) + fillet = next(item for item in result.cdsl["features"] if item["id"] == "f_F3") + self.assertEqual(fillet["atomic_id"], "fillet") + self.assertEqual(fillet["selectors"][0]["kind"], "face") + self.assertEqual( + fillet["selectors"][0]["geometry"], + {"axis_origin_mm": [0.0, 0.0, 0.0], "axis_direction": [0.0, -1.0, 0.0], "radius_mm": 45.66}, + ) + + chamfer = next(item for item in result.cdsl["features"] if item["id"] == "f_F5") + self.assertEqual( + sorted(selector["geometry"]["axis_origin_mm"] for selector in chamfer["selectors"]), + sorted([[0.0, 25.4, 33.37], [33.38, 25.4, 0.0], [0.0, 25.4, -33.38], [-33.38, 25.4, 0.0]]), + ) + + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + f4 = next(item for item in result.cdsl["features"] if item["id"] == "f_F4") + self.assertEqual( + sorted(segment["center"] for contour in sketches[f4["sketch_id"]]["profile"]["contours"] for segment in contour["segments"]), + sorted([[0.0, 33.37], [33.38, 0.0], [-33.38, 0.0], [0.0, -33.38]]), + ) + f7 = next(item for item in result.cdsl["features"] if item["id"] == "f_F7") + self.assertEqual( + [segment["radius_mm"] for contour in sketches[f7["sketch_id"]]["profile"]["contours"] for segment in contour["segments"]], + [4.48, 2.36], + ) + + self.assertNotIn("result_mode", next(item for item in result.cdsl["features"] if item["id"] == "f_F7")["params"]) + self.assertEqual(next(item for item in result.cdsl["features"] if item["id"] == "f_F9")["params"]["result_mode"], "new_body") + + from build123d import import_step + with tempfile.TemporaryDirectory() as tmp: + rebuilt = Path(tmp) / "rebuild.step" + outcome = rebuild_candidate(result.cdsl, rebuilt) + self.assertEqual(outcome["status"], "rebuilt") + runtime_box = outcome["result"]["bbox_mm"] + imported_box = import_step(str(rebuilt)).bounding_box() + actual = [imported_box.min.X, imported_box.min.Y, imported_box.min.Z, imported_box.max.X, imported_box.max.Y, imported_box.max.Z] + expected = runtime_box["min"] + runtime_box["max"] + for value, target in zip(actual, expected): self.assertAlmostEqual(value, target, places=5) + + def test_inward_shells_00789939_lower_and_rebuild(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0078/00789939.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00789939"), {}) + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F2"]["atomic_id"], "shell") + self.assertEqual(features["f_F5"]["atomic_id"], "shell") + self.assertEqual(features["f_F2"]["params"], {"thickness_mm": 2.5, "inward": True}) + self.assertEqual(features["f_F5"]["params"], {"thickness_mm": 2.5, "inward": True}) + self.assertNotIn("F2", [item.get("feature_id") for item in result.diagnostics]) + self.assertNotIn("F5", [item.get("feature_id") for item in result.diagnostics]) + + with tempfile.TemporaryDirectory() as tmp: + rebuilt = Path(tmp) / "rebuild.step" + outcome = rebuild_candidate(result.cdsl, rebuilt) + self.assertEqual(outcome["status"], "rebuilt") + self.assertEqual(outcome["result"]["solid_count"], 2) + + def test_sweep_00542223_preserves_its_open_bspline_path(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0054/00542223.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00542223"), {}) + sweep = next(item for item in result.cdsl["features"] if item["id"] == "f_F5") + segment = sweep["params"]["path"]["segment"] + self.assertEqual(sweep["atomic_id"], "sweep_add") + self.assertEqual(sweep["sketch_id"], "sketch_F4") + self.assertEqual(segment["points"], [[-40.0, -50.0], [-20.0, -14.96], [0.0, 5.0]]) + self.assertEqual(segment["start_tangent"], [27.94, 92.49]) + self.assertEqual(segment["end_tangent"], [52.88, 49.52]) + self.assertNotIn("F5", [item.get("feature_id") for item in result.diagnostics]) + + def test_face_chamfer_ignores_periodic_seams_00111611(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0011/00111611.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + from copy import deepcopy + from engine.cdsl_engine.capabilities import CapabilityAnalyzer, sketch_ids_required_by_contract + from engine.cdsl_engine.runtime import EXECUTORS, ExecutionSession, _execute_node, _selector_edges + from engine.cdsl_engine.sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches + from cadfs_to_cdsl.selector_binding import bind_candidate_selectors + + candidate = lower_model(parse_featurescript(feature.read_text(), "00111611"), {}).cdsl + bound, _ = bind_candidate_selectors(candidate) + resolved = resolve_required_sketches( + deepcopy(bound), sketch_ids_required_by_contract(bound), errors={}, + ) + analysis = CapabilityAnalyzer( + atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS, + ).analyze(resolved) + session = ExecutionSession( + sketches={str(item["id"]): item for item in resolved["geometry"]["sketches"]}, + nodes={node.feature_id: node for node in analysis.plan}, + ) + + for node in analysis.plan: + if node.feature_id == "f_F5": + edges = _selector_edges(node, session, tangent_propagation=True) + break + _execute_node(node, session) + else: + self.fail("F5 chamfer was not planned") + + self.assertEqual(len(edges), 8) + self.assertTrue(all(str(edge.geom_type).endswith("CIRCLE") for edge in edges)) + + def test_circular_remove_pattern_replays_cut_sources_00159804(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0015/00159804.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(feature.read_text(), "00159804"), {}) + source = next(item for item in result.cdsl["features"] if item["id"] == "f_F8") + pattern = next(item for item in result.cdsl["features"] if item["id"] == "f_F9") + + self.assertEqual(source["atomic_id"], "extrude_cut_blind") + self.assertNotIn("result_mode", source["params"]) + self.assertEqual(pattern["params"]["operation_mode"], "remove") if __name__ == "__main__": unittest.main() diff --git a/cadfs_to_cdsl/tests/test_lowering.py b/cadfs_to_cdsl/tests/test_lowering.py index 6ca5cc11..cac862c4 100644 --- a/cadfs_to_cdsl/tests/test_lowering.py +++ b/cadfs_to_cdsl/tests/test_lowering.py @@ -1,16 +1,26 @@ from __future__ import annotations -import json, tempfile, unittest +import json, math, tempfile, unittest from pathlib import Path from cadfs_to_cdsl.featurescript_parser import parse_featurescript -from cadfs_to_cdsl.lowering import lower_model -from cadfs_to_cdsl.pipeline import convert_one +from cadfs_to_cdsl.lowering import _arc, _contours, _global, lower_model +from cadfs_to_cdsl.pipeline import compare_one, convert_one, rebuild_one from cadfs_to_cdsl.dataset import Sample from cadfs_to_cdsl.dataset import scan_dataset from cadfs_to_cdsl.tests.test_parser import SOURCE, TRANSFORM_SOURCE class LoweringTests(unittest.TestCase): + def test_strict_comparison_status_is_not_labeled_approximate(self): + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp); directory = output / "samples" / "strict-status"; directory.mkdir(parents=True) + sample = Sample("strict-status", {"step": str(directory / "gold.step")}, {}, {}, []) + (directory / "rebuild.step").touch(); (directory / "gold.step").touch() + (directory / "status.json").write_text(json.dumps({"sample_id": sample.sample_id, "rebuild_status": "rebuilt"}), encoding="utf-8") + (directory / "comparison.json").write_text(json.dumps({"decision": "strict_pass", "strict": {"passed": True}, "rp": {"passed": True}}), encoding="utf-8") + status = compare_one(sample, output, compare_mode="strict") + self.assertEqual(status["status"], "rebuilt_strict") + def test_missing_dataset_fails_explicitly(self): with tempfile.TemporaryDirectory() as tmp: with self.assertRaises(FileNotFoundError): scan_dataset(Path(tmp) / "missing") @@ -23,8 +33,20 @@ class LoweringTests(unittest.TestCase): from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + def test_rectilinear_fillet_arc_recovers_its_exact_endpoint_radius(self): + arc = _arc([38.1, 63.5], [34.38, 72.48], [25.4, 76.2]) + self.assertEqual(arc["center"], [25.4, 63.5]) + self.assertAlmostEqual(arc["radius_mm"], 12.7) + self.assertFalse(arc["clockwise"]) + + def test_general_three_point_arc_remains_circumcircle_defined(self): + arc = _arc([0, 0], [2, 3], [5, 0]) + self.assertAlmostEqual(arc["center"][0], 2.5) + self.assertAlmostEqual(arc["center"][1], 0.5) + self.assertAlmostEqual(arc["radius_mm"], math.hypot(2.5, 0.5)) + def test_unsupported_operation_is_audited_not_invented(self): - source = SOURCE.replace('extrude(context, id + "F1",', 'shell(context, id + "F1",') + source = SOURCE.replace('extrude(context, id + "F1",', 'draft(context, id + "F1",') result = lower_model(parse_featurescript(source, "00000173"), {}) self.assertEqual(result.status, "deferred_no_executable_feature") self.assertIsNone(result.cdsl) @@ -86,21 +108,225 @@ class LoweringTests(unittest.TestCase): self.assertEqual(feature["atomic_id"], expected_atomic) self.assertEqual(feature["params"]["end_condition"]["type"], "through_next") - def test_open_nonconstruction_geometry_is_not_silently_dropped(self): + def test_implicit_extrude_after_a_body_uses_default_new_body_semantics(self): + source = SOURCE.replace( + '\n});\n', + ''' + { var Q0; Q0=qSketchRegion(id + "F0", true); + extrude(context, id + "F2", {"entities":qUnion([Q0]), "depth":20 * mm}); } +}); +''', + ) + result = lower_model(parse_featurescript(source, "implicit-add"), {}) + first, second = result.cdsl["features"] + self.assertEqual(first["params"]["result_mode"], "new_body") + self.assertEqual(second["params"]["result_mode"], "new_body") + + def test_up_to_body_extrude_captures_target_and_offset(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0069/00694309.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00694309"), {}) + self.assertEqual(result.status, "converted_complete") + extrude = next(item for item in result.cdsl["features"] if item["id"] == "f_F3") + end = extrude["params"]["end_condition"] + self.assertEqual(end["type"], "up_to_body") + self.assertEqual(end["offset_mm"], 3.0) + self.assertEqual(end["reference"]["kind"], "body") + self.assertEqual(end["reference"]["owner_feature_id"], "f_F1") + + def test_up_to_surface_extrude_captures_the_cap_face(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0092/00925274.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00925274"), {}) + extrude = next(item for item in result.cdsl["features"] if item["id"] == "f_F3") + end = extrude["params"]["end_condition"] + self.assertEqual(end["type"], "up_to_surface") + self.assertEqual(end["reference"]["kind"], "face") + self.assertEqual(end["reference"]["owner_feature_id"], "f_F1") + self.assertEqual(end["reference"]["geometry"]["plane_offset_mm"], 0.0) + swept = next(item for item in result.cdsl["features"] if item["id"] == "f_F6") + self.assertEqual(swept["params"]["end_condition"]["reference"]["geometry"]["radius_mm"], 24.3) + mirror = next(item for item in result.cdsl["features"] if item["id"] == "f_F18") + self.assertTrue(mirror["params"]["mirror_current_body"]) + + def test_mirrored_copy_cap_face_uses_the_mirrored_loft_frame(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0061/00612529.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(feature.read_text(), "00612529"), {}) + sketch = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F6") + plane = sketch["workplane"] + + self.assertEqual(plane["origin_mm"], [0.0, 50.0, 0.0]) + self.assertEqual(plane["normal"], [0.0, 1.0, 0.0]) + self.assertEqual(plane["x_dir"], [-1.0, 0.0, 0.0]) + + spline = sketch["profile"]["contours"][0]["segments"][0] + first = spline["points"][0] + self.assertEqual([ + plane["origin_mm"][index] + + plane["x_dir"][index] * first[0] + + (plane["normal"][(index + 1) % 3] * plane["x_dir"][(index + 2) % 3] + - plane["normal"][(index + 2) % 3] * plane["x_dir"][(index + 1) % 3]) * first[1] + for index in range(3) + ], [-60.68, 50.0, 66.22]) + + def test_line_angle_opposite_direction_preserves_the_sketch_side(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0092/00925274.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00925274"), {}) + sketch = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F8") + plane = sketch["workplane"] + y_dir = [ + plane["normal"][1] * plane["x_dir"][2] - plane["normal"][2] * plane["x_dir"][1], + plane["normal"][2] * plane["x_dir"][0] - plane["normal"][0] * plane["x_dir"][2], + plane["normal"][0] * plane["x_dir"][1] - plane["normal"][1] * plane["x_dir"][0], + ] + center = sketch["profile"]["center"] + self.assertAlmostEqual(plane["origin_mm"][2] + y_dir[2] * center[1], 25.0) + + def test_line_angle_cylinder_reference_uses_the_surface_generatrix(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0092/00925274.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00925274"), {}) + sketch = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F5") + plane = sketch["workplane"] + self.assertAlmostEqual(plane["normal"][0], -0.5) + self.assertAlmostEqual(plane["normal"][1], -math.sqrt(3) / 2) + self.assertAlmostEqual(plane["origin_mm"][0], -12.15) + self.assertAlmostEqual(plane["origin_mm"][1], -24.3 * math.sqrt(3) / 2) + + def test_open_nonconstruction_geometry_is_preserved_as_reference(self): source = SOURCE.replace('skSolve(sketch);', 'skLineSegment(sketch, "open", {"start":v(0, 0) * mm, "end":v(20, 0) * mm}); skSolve(sketch);', 1) result = lower_model(parse_featurescript(source, "open-profile"), {}) - self.assertIsNone(result.cdsl) - self.assertEqual(result.diagnostics[0]["code"], "sketch_deferred") - self.assertIn("open non-construction", result.diagnostics[0]["message"]) + self.assertEqual(result.status, "converted_complete") + profile = result.cdsl["geometry"]["sketches"][0]["profile"] + self.assertEqual(profile["type"], "analytic_contours") + self.assertEqual(profile["construction"][0]["type"], "line") - def test_surface_revolve_is_not_disguised_as_solid_revolve(self): + def test_open_imprint_profiles_and_mixed_sketch_context_lower_for_00789939(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0078/00789939.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00789939"), {}) + + features = {item["id"]: item for item in result.cdsl["features"]} + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + self.assertEqual(features["f_F8"]["sketch_id"], "sketch_F7__f_F8") + self.assertEqual(features["f_F10"]["sketch_id"], "sketch_F9__f_F10") + self.assertEqual(features["f_F12"]["sketch_id"], "sketch_F11") + self.assertFalse(sketches["sketch_F7__f_F8"]["profile"]["contours"][0]["closed"]) + self.assertEqual(sketches["sketch_F7__f_F8"]["workplane"]["origin_mm"], [45.7, 0.0, 0.0]) + self.assertEqual(sketches["sketch_F9__f_F10"]["workplane"]["origin_mm"], [-44.3, -0.0, -0.0]) + self.assertEqual(sketches["sketch_F7__f_F8"]["workplane"]["normal"], [1.0, -0.0, -0.0]) + self.assertEqual(sketches["sketch_F9__f_F10"]["workplane"]["normal"], [-1.0, -0.0, -0.0]) + self.assertEqual(sketches["sketch_F9__f_F10"]["workplane"]["x_dir"], [-0.0, -1.0, -0.0]) + + def test_circular_pattern_replays_the_fused_swept_body_history(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0054/00542223.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00542223"), {}) + + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F6"]["params"]["source_feature_ids"], ["f_F1", "f_F5"]) + + def test_shared_sketch_edge_produces_each_bounded_region(self): + segments = [ + {"type": "line", "start": [0, 0], "end": [1, 0]}, + {"type": "line", "start": [1, 0], "end": [2, 0]}, + {"type": "line", "start": [2, 0], "end": [2, 1]}, + {"type": "line", "start": [2, 1], "end": [1, 1]}, + {"type": "line", "start": [1, 1], "end": [0, 1]}, + {"type": "line", "start": [0, 1], "end": [0, 0]}, + {"type": "line", "start": [1, 0], "end": [1, 1]}, + ] + contours, construction = _contours(segments) + self.assertEqual(len(contours), 2) + self.assertFalse(construction) + self.assertEqual(sorted(len(item["segments"]) for item in contours), [4, 4]) + + def test_vertex_on_long_line_is_split_before_region_extraction(self): + segments = [ + {"type": "line", "start": [0, 0], "end": [2, 0]}, + {"type": "line", "start": [2, 0], "end": [2, 2]}, + {"type": "line", "start": [2, 2], "end": [0, 2]}, + {"type": "line", "start": [0, 2], "end": [0, 0]}, + {"type": "line", "start": [2, 0.5], "end": [3, 0.5]}, + {"type": "line", "start": [3, 0.5], "end": [3, 1.5]}, + {"type": "line", "start": [3, 1.5], "end": [2, 1.5]}, + ] + contours, construction = _contours(segments) + self.assertEqual(len(contours), 2) + self.assertFalse(construction) + + def test_surface_operation_on_a_closed_region_lowers_to_a_solid_revolve(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0095/00957738.txt" if not feature.exists(): self.skipTest("CADFS sample is not installed") result = lower_model(parse_featurescript(feature.read_text(), "00957738"), {}) - gap = next(item for item in result.diagnostics if item.get("feature_id") == "F2") - self.assertEqual(gap["capability"], "revolve_surface") - self.assertIsNone(result.cdsl) + self.assertEqual(result.status, "converted_complete") + feature = next(item for item in result.cdsl["features"] if item["id"] == "f_F2") + self.assertEqual(feature["atomic_id"], "revolve_add") + self.assertNotIn("result_mode", feature["params"]) + + def test_explicit_surface_body_lowers_to_the_surface_revolve_contract(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0078/00784880.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00784880"), {}) + self.assertEqual(result.status, "converted_complete") + feature = next(item for item in result.cdsl["features"] if item["id"] == "f_F3") + self.assertEqual(feature["atomic_id"], "revolve_surface") + self.assertNotIn("result_mode", feature["params"]) + + def test_mixed_extrude_lowers_selected_circular_surface_wires(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0071/00710855.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00710855"), {}) + + self.assertEqual(result.status, "converted_complete") + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F1_surface"]["atomic_id"], "extrude_surface") + self.assertEqual(features["f_F1_surface"]["params"], { + "distance_mm": 63.5, + "reverse": False, + "reverse_distance_mm": 63.5, + }) + self.assertEqual(features["f_F5_surface"]["atomic_id"], "extrude_surface") + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + radii = [ + contour["segments"][0]["radius_mm"] + for contour in sketches["sketch_F0__f_F1_surface"]["profile"]["contours"] + ] + self.assertEqual(radii, [19.0, 16.0]) + profile = sketches[features["f_F5"]["sketch_id"]]["profile"] + self.assertEqual(profile["type"], "analytic_contours") + self.assertEqual( + [(item["role"], item["segments"][0]["radius_mm"]) for item in profile["contours"]], + [("outer", 25.5), ("inner", 19.0)], + ) + + def test_mixed_extrude_rebuilds_surface_limited_chamfer_history(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0071/00710855.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00710855"), {}) + + with tempfile.TemporaryDirectory() as tmp: + from cadfs_to_cdsl.rebuild import rebuild_candidate + rebuilt = rebuild_candidate(result.cdsl, Path(tmp) / "rebuilt.step") + + self.assertEqual(rebuilt["status"], "rebuilt") + chamfer = next(item for item in rebuilt["result"]["feature_results"] if item["feature_id"] == "f_F10") + self.assertIn("chamfer_surface_limited", [item["code"] for item in chamfer["diagnostics"]]) + self.assertAlmostEqual(rebuilt["result"]["volume_mm3"], 72577.33528261917, places=6) def test_fit_spline_loft_lowers_to_executable_loft_add(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -128,6 +354,171 @@ class LoweringTests(unittest.TestCase): self.assertEqual(rebuilt["status"], "rebuilt") self.assertGreater(rebuilt["result"]["volume_mm3"], 0) + def test_closed_fit_splines_lower_to_periodic_contours_and_join_split_imprints(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0061/00612529.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00612529"), {}) + + self.assertEqual(result.status, "converted_complete") + self.assertNotIn("F8", {item.get("feature_id") for item in result.diagnostics}) + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + for sketch_id in ("sketch_F0", "sketch_F2", "sketch_F5", "sketch_F6"): + segment = sketches[sketch_id]["profile"]["contours"][0]["segments"][0] + self.assertTrue(segment["periodic"]) + self.assertEqual(segment["parameterization"], "centripetal") + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F8"]["sketch_id"], "sketch_F6") + + def test_open_fit_spline_reversal_preserves_parameter_and_tangent_semantics(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0028/00287955.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00287955"), {}) + + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + spline = sketches["sketch_F13__f_F14"]["profile"]["contours"][0]["segments"][1] + source_points = [[33.77, 39.06], [38.2, 35.02], [36.58, 30.72], [33.77, 28.66]] + source_parameters = [0.0] + for start, end in zip(source_points, source_points[1:]): + source_parameters.append(source_parameters[-1] + math.sqrt(math.dist(start, end))) + source_parameters = [value / source_parameters[-1] for value in source_parameters] + + self.assertEqual(spline["points"], list(reversed(source_points))) + self.assertEqual(spline["start_tangent"], [11.58, -1.2]) + self.assertEqual(spline["end_tangent"], [-14.95, 1.46]) + self.assertEqual( + spline["parameters"], + [source_parameters[-1] - value for value in reversed(source_parameters)], + ) + + def test_draft_and_boolean_bodies_preserve_explicit_source_body_selection(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0083/00835610.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00835610"), {}) + + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F1"]["params"]["draft"], {"angle_deg": 49.0, "pull_direction": True}) + self.assertEqual(features["f_F3"]["sketch_id"], "sketch_F0__f_F3") + self.assertEqual(features["f_F3"]["params"]["draft"], {"angle_deg": 49.0, "pull_direction": True}) + self.assertEqual(features["f_F4"]["atomic_id"], "boolean_bodies") + self.assertEqual(features["f_F4"]["params"], { + "operation": "subtract", + "target_feature_ids": ["f_F3"], + "tool_feature_ids": ["f_F2"], + "keep_tools": False, + }) + self.assertEqual(features["f_F8"]["atomic_id"], "revolve_add") + self.assertNotIn("result_mode", features["f_F8"]["params"]) + + def test_cap_face_profile_preserves_the_selected_circular_output_region(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0083/00835610.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00835610"), {}) + + features = {item["id"]: item for item in result.cdsl["features"]} + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + cap_profile = sketches[features["f_F5"]["sketch_id"]]["profile"] + + self.assertEqual(features["f_F5"]["sketch_id"], "sketch_F3__f_F5") + self.assertEqual(cap_profile["type"], "analytic_contours") + self.assertEqual([ + contour["segments"][0]["radius_mm"] + for contour in cap_profile["contours"] + ], [34.0, 32.0]) + + def test_transformed_cap_face_uses_its_attachment_plane_origin(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0083/00835610.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00835610"), {}) + + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + plane = sketches["sketch_F14"]["workplane"] + + self.assertAlmostEqual(plane["origin_mm"][0], 0.0) + self.assertAlmostEqual(plane["origin_mm"][1], 0.0) + self.assertAlmostEqual(plane["origin_mm"][2], -24.08) + self.assertAlmostEqual(plane["normal"][0], 0.0) + self.assertAlmostEqual(plane["normal"][1], 0.0) + self.assertAlmostEqual(plane["normal"][2], 1.0) + + def test_chamfer_tangent_propagation_lowers_to_engine_param(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0000/00002243.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00002243"), {}) + chamfer = next(item for item in result.cdsl["features"] if item["id"] == "f_F4") + self.assertTrue(chamfer["params"]["tangent_propagation"]) + + def test_imprint_profile_query_selects_one_shared_edge_region(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0003/00035682.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00035682"), {}) + revolve = next(item for item in result.cdsl["features"] if item["id"] == "f_F1") + self.assertEqual(revolve["sketch_id"], "sketch_F0__f_F1") + selected = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == revolve["sketch_id"]) + self.assertEqual(len(selected["profile"]["contours"]), 1) + x_coordinates = {point[0] for segment in selected["profile"]["contours"][0]["segments"] for point in (segment["start"], segment["end"])} + self.assertEqual(x_coordinates, {11.68, 12.05}) + + def test_line_point_plane_uses_the_reference_line_as_its_normal(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0003/00035682.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00035682"), {}) + self.assertEqual(result.status, "converted_complete") + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F5"]["atomic_id"], "reference_plane") + self.assertEqual(features["f_F8"]["atomic_id"], "reference_plane") + self.assertEqual(features["f_F7"]["atomic_id"], "extrude_cut_blind") + self.assertEqual(features["f_F10"]["atomic_id"], "extrude_cut_blind") + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + length = math.hypot(10.44, 6.03) + for actual, expected in zip(sketches["sketch_F6"]["workplane"]["normal"], [-10.44 / length, 6.03 / length, 0.0]): + self.assertAlmostEqual(actual, expected) + for actual, expected in zip(sketches["sketch_F6"]["workplane"]["x_dir"], [-6.03 / length, -10.44 / length, 0.0]): + self.assertAlmostEqual(actual, expected) + for actual, expected in zip(sketches["sketch_F9"]["workplane"]["normal"], [10.44 / length, 6.03 / length, 0.0]): + self.assertAlmostEqual(actual, expected) + for actual, expected in zip(sketches["sketch_F9"]["workplane"]["x_dir"], [-6.03 / length, 10.44 / length, 0.0]): + self.assertAlmostEqual(actual, expected) + + def test_revolved_swept_face_lowers_to_an_annular_profile(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0005/00054089.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00054089"), {}) + self.assertEqual(result.status, "converted_complete") + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F5"]["atomic_id"], "extrude_add_blind") + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + profile = sketches["sketch_F4"]["profile"] + self.assertEqual(profile["type"], "analytic_contours") + radii = {contour["role"]: contour["segments"][0]["radius_mm"] for contour in profile["contours"]} + self.assertEqual(radii, {"outer": 18.0, "inner": 14.0}) + + def test_intersect_partition_profile_lowers_to_its_bounded_circular_region(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0083/00835610.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00835610"), {}) + self.assertEqual(result.status, "converted_partial") + self.assertEqual({item["feature_id"] for item in result.diagnostics}, {"F7", "F12"}) + features = {item["id"]: item for item in result.cdsl["features"]} + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + first = sketches[features["f_F15"]["sketch_id"]]["profile"] + second = sketches[features["f_F16"]["sketch_id"]]["profile"] + self.assertEqual(first["type"], "analytic_contours") + self.assertEqual( + {contour["role"]: contour["segments"][0]["radius_mm"] for contour in first["contours"]}, + {"outer": 2.75, "inner": 2.25}, + ) + self.assertEqual(second, {"type": "circle", "center": [-50.8, 0.0], "radius_mm": 2.25}) + def test_direct_translation_transform_updates_the_source_revolve(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0011/00111611.txt" @@ -136,10 +527,63 @@ class LoweringTests(unittest.TestCase): self.assertNotIn("F10", {item.get("feature_id") for item in result.diagnostics}) features = {item["id"]: item for item in result.cdsl["features"]} sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + self.assertEqual(features["f_F9"]["params"]["result_mode"], "new_body") self.assertEqual(sketches[features["f_F9"]["sketch_id"]]["workplane"]["origin_mm"], [77.16, -11.67, -63.0]) for actual, expected in zip(features["f_F9"]["params"]["axis"]["origin_mm"], [0.13, -11.67, 56.11]): self.assertAlmostEqual(actual, expected) + def test_cap_face_workplane_uses_the_physical_cap_normal(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0011/00111611.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00111611"), {}) + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + + self.assertEqual(sketches["sketch_F2"]["workplane"]["origin_mm"], [0.0, 25.4, 0.0]) + self.assertEqual(sketches["sketch_F2"]["workplane"]["normal"], [0.0, 1.0, 0.0]) + self.assertEqual(sketches["sketch_F6"]["workplane"]["origin_mm"], [0.0, 0.0, 0.0]) + self.assertEqual(sketches["sketch_F6"]["workplane"]["normal"], [0.0, -1.0, 0.0]) + + def test_nested_circle_imprint_selects_its_bounded_annulus(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0011/00111611.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00111611"), {}) + features = {item["id"]: item for item in result.cdsl["features"]} + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + profile = sketches[features["f_F7"]["sketch_id"]]["profile"] + + self.assertEqual(profile["type"], "analytic_contours") + self.assertEqual( + [(item["role"], item["segments"][0]["radius_mm"]) for item in profile["contours"]], + [("outer", 4.48), ("inner", 2.36)], + ) + + def test_ellipse_profiles_unblock_the_00287955_feature_chain(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0028/00287955.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(feature.read_text(), "00287955"), {}) + features = {item["id"] for item in result.cdsl["features"]} + feature_by_id = {item["id"]: item for item in result.cdsl["features"]} + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + + self.assertTrue({"f_F3", "f_F5", "f_F6", "f_F11"}.issubset(features)) + self.assertEqual(feature_by_id["f_F9"]["atomic_id"], "extrude_add_blind") + self.assertNotIn("selectors", feature_by_id["f_F9"]) + mid_plane = feature_by_id["f_F12"]["params"]["plane"] + self.assertAlmostEqual(mid_plane["origin_mm"][0], -18.67, places=5) + self.assertAlmostEqual(mid_plane["normal"][0], 1.0, places=5) + self.assertAlmostEqual(mid_plane["normal"][1], 0.0, places=3) + axis = feature_by_id["f_F14"]["params"]["axis"] + self.assertAlmostEqual(axis["origin_mm"][0], -18.67, delta=0.01) + self.assertAlmostEqual(axis["origin_mm"][1], 33.77, delta=0.01) + for sketch_id in ("sketch_F2", "sketch_F4", "sketch_F7", "sketch_F8"): + segment = sketches[sketch_id]["profile"]["contours"][0]["segments"][0] + self.assertEqual(segment["type"], "ellipse") + self.assertNotIn("sketch_deferred", {item["code"] for item in result.diagnostics}) + def test_circular_pattern_lowers_to_existing_engine_contract(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0042/00423838.txt" @@ -155,9 +599,20 @@ class LoweringTests(unittest.TestCase): self.assertEqual(pattern["params"]["axis"]["direction"], [0.0, 0.0, -1.0]) self.assertNotIn("F2", {item.get("feature_id") for item in result.diagnostics}) sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} - self.assertNotEqual(sketches["sketch_F0"]["workplane"]["normal"], [0.0, 0.0, 1.0]) + self.assertNotEqual(sketches[features["f_F1"]["sketch_id"]]["workplane"]["normal"], [0.0, 0.0, 1.0]) self.assertNotIn("circularPattern", {item.get("operation") for item in result.diagnostics}) + def test_opposed_circle_regions_lower_to_their_union_disk(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0042/00423838.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00423838"), {}) + features = {item["id"]: item for item in result.cdsl["features"]} + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + profile = sketches[features["f_F6"]["sketch_id"]]["profile"] + self.assertEqual(profile["type"], "circle") + self.assertEqual(profile["radius_mm"], 30.0) + def test_reference_plane_variants_lower_to_explicit_frames(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" cases = { @@ -179,6 +634,57 @@ class LoweringTests(unittest.TestCase): diagnostics = [item for item in result.diagnostics if item.get("operation") == "cPlane"] self.assertFalse(diagnostics) + def test_shell_preserves_pattern_copy_cap_faces_and_offset_edge_fillet(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0054/00542223.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(feature.read_text(), "00542223"), {}) + features = {item["id"]: item for item in result.cdsl["features"]} + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + shell = features["f_F7"] + fillet = features["f_F8"] + + self.assertEqual(sketches["sketch_F4"]["workplane"]["origin_mm"], [0.0, 0.0, -50.0]) + self.assertEqual(features["f_F5"]["params"]["path"]["segment"]["points"][0], [-40.0, -50.0]) + self.assertEqual(features["f_F5"]["params"]["path"]["segment"]["start_tangent"], [27.94, 92.49]) + self.assertEqual(shell["selectors"][1]["geometry"], {"normal": [0.0, 0.0, 1.0], "plane_offset_mm": -50.0}) + self.assertEqual(shell["atomic_id"], "shell") + self.assertEqual(len(shell["selectors"]), 4) + self.assertEqual( + [selector["owner_feature_id"] for selector in shell["selectors"][:3]], + ["f_F6.c1.f_F5", "f_F5", "f_F6.c2.f_F5"], + ) + self.assertEqual(fillet["atomic_id"], "fillet") + self.assertEqual(fillet["selectors"][0]["geometry"]["source_circle_radius_mm"], 17.5) + self.assertNotIn("F7", {item.get("feature_id") for item in result.diagnostics}) + self.assertNotIn("F8", {item.get("feature_id") for item in result.diagnostics}) + + def test_curve_point_plane_uses_its_projected_attachment_origin(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0019/00192744.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00192744"), {}) + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + plane = sketches["sketch_F4"]["workplane"] + self.assertEqual(plane["origin_mm"], [0.0, -2000.0, -4000.0]) + self.assertAlmostEqual(_global(plane, [0.0, 8944.27])[1], -10000.0, places=2) + self.assertAlmostEqual(_global(plane, [0.0, 8944.27])[2], 0.0, places=2) + + def test_delete_pattern_copies_lower_to_explicit_exclusions(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0019/00192744.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00192744"), {}) + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F6"]["params"]["excluded_instance_indices"], [2]) + self.assertEqual(features["f_F11"]["params"]["excluded_instance_indices"], [2]) + self.assertEqual(result.status, "converted_partial") + self.assertEqual( + [(item["feature_id"], item["capability"]) for item in result.diagnostics if item.get("code") == "unsupported_engine_capability"], + [("F15", "transform")], + ) + def test_feature_face_profile_is_not_reused_as_original_sketch(self): source = SOURCE.replace('qSketchRegion(id + "F0", true)', 'makeQuery(id+"F1.opExtrude","CAP_FACE",FACE,{"isStart":false})') result = lower_model(parse_featurescript(source, "face-profile"), {}) @@ -197,5 +703,16 @@ class LoweringTests(unittest.TestCase): self.assertTrue((directory / name).exists(), name) self.assertEqual(json.loads((directory / "candidate.cdsl.json").read_text())["part_id"], "00000173") + def test_force_rebuild_refreshes_the_candidate_from_source(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); source = root / "00000173.txt"; source.write_text(SOURCE) + sample = Sample("00000173", {"featurescript": str(source)}, {"featurescript": "x"}) + directory = root / "out/samples/00000173"; directory.mkdir(parents=True) + (directory / "candidate.cdsl.json").write_text(json.dumps({"part_id": "stale"}), encoding="utf-8") + (directory / "status.json").write_text(json.dumps({"sample_id": sample.sample_id}), encoding="utf-8") + status = rebuild_one(sample, root / "out", force=True) + self.assertEqual(status["rebuild_status"], "rebuilt") + self.assertEqual(json.loads((directory / "candidate.cdsl.json").read_text())["part_id"], "00000173") + if __name__ == "__main__": unittest.main() diff --git a/cadfs_to_cdsl/tests/test_parser.py b/cadfs_to_cdsl/tests/test_parser.py index 127197ad..0b388338 100644 --- a/cadfs_to_cdsl/tests/test_parser.py +++ b/cadfs_to_cdsl/tests/test_parser.py @@ -4,6 +4,7 @@ import unittest from cadfs_to_cdsl.featurescript_lexer import lex from cadfs_to_cdsl.featurescript_parser import parse_featurescript from cadfs_to_cdsl.ir import Call +from cadfs_to_cdsl.lowering import _queries, _source_refs from cadfs_to_cdsl.query_parser import parse_query from cadfs_to_cdsl.units import length_mm @@ -52,6 +53,18 @@ class ParserTests(unittest.TestCase): self.assertEqual((value.owner_feature, value.source_sketch, value.source_entity), ("F1", "F0", "E0")) self.assertFalse(value.is_start) + def test_block_scoped_query_aliases_do_not_use_the_last_assignment(self): + source = r''' + { var Q0; + { var subQ0=sQuery(id + "F2.wireOp", EDGE, "E2"); Q0=makeQuery(id + "F4.opExtrude", "SWEPT_FACE", FACE, {"derivedFrom":subQ0}); } + var Q1; + { var subQ0=sQuery(id + "F2.wireOp", EDGE, "E4"); Q1=makeQuery(id + "F4.opExtrude", "SWEPT_FACE", FACE, {"derivedFrom":subQ0}); } + chamfer(context, id + "F5", {"entities":qUnion([Q0, Q1]), "width":5 * mm}); } + ''' + model = parse_featurescript(source, "scoped-query") + queries = _queries(model.features[0].params["entities"]) + self.assertEqual([_source_refs(query)[0] for query in queries], [("F2", "E2"), ("F2", "E4")]) + def test_safe_units(self): self.assertEqual(length_mm("2 * inch"), 50.8) self.assertEqual(length_mm("25.4 / 2 * mm"), 12.7) diff --git a/cadfs_to_cdsl/tests/test_reports.py b/cadfs_to_cdsl/tests/test_reports.py new file mode 100644 index 00000000..f89b5a46 --- /dev/null +++ b/cadfs_to_cdsl/tests/test_reports.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import json, tempfile, unittest +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from cadfs_to_cdsl.reports import write_json + + +class ReportTests(unittest.TestCase): + def test_parallel_json_writes_use_unique_temporary_paths(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "summary.json" + with ThreadPoolExecutor(max_workers=8) as executor: + list(executor.map(lambda index: write_json(path, {"index": index}), range(64))) + + payload = json.loads(path.read_text(encoding="utf-8")) + self.assertIn(payload["index"], range(64)) + self.assertEqual(list(path.parent.glob(".summary.json.*.tmp")), []) + + +if __name__ == "__main__": unittest.main() diff --git a/cadfs_to_cdsl/tests/test_selector_binding.py b/cadfs_to_cdsl/tests/test_selector_binding.py index 33fc7ca2..0e5ad462 100644 --- a/cadfs_to_cdsl/tests/test_selector_binding.py +++ b/cadfs_to_cdsl/tests/test_selector_binding.py @@ -1,8 +1,11 @@ from __future__ import annotations +from pathlib import Path import unittest -from cadfs_to_cdsl.selector_binding import _score +from cadfs_to_cdsl.featurescript_parser import parse_featurescript +from cadfs_to_cdsl.lowering import lower_model +from cadfs_to_cdsl.selector_binding import _score, bind_candidate_selectors class SelectorBindingTests(unittest.TestCase): @@ -13,12 +16,62 @@ class SelectorBindingTests(unittest.TestCase): ) self.assertEqual(score, 1.0) - def test_axis_direction_remains_orientation_sensitive(self) -> None: + def test_rotational_face_axis_direction_is_orientation_independent(self) -> None: score = _score( {"axis_direction": [0.0, 0.0, 1.0]}, {"axis_direction": [0.0, 0.0, -1.0]}, ) + self.assertEqual(score, 1.0) + + def test_axis_origin_distinguishes_parallel_cylinders(self) -> None: + score = _score( + {"axis_origin_mm": [0.0, 25.4, 33.37]}, + {"axis_origin_mm": [33.38, 25.4, 0.0]}, + ) self.assertEqual(score, 0.0) def test_empty_snapshot_score_is_not_treated_as_a_match(self) -> None: self.assertEqual(_score({}, {"normal": [0.0, 0.0, 1.0]}), 0.0) + + def test_swept_face_area_lower_bound_rejects_coplanar_fragment(self) -> None: + expected = {"normal": [0.0, 1.0, 0.0], "plane_offset_mm": 54.69, "minimum_area_mm2": 285.0} + self.assertIsNone(_score(expected, {"normal": [0.0, 1.0, 0.0], "plane_offset_mm": 54.69, "area_mm2": 0.64})) + self.assertEqual(_score(expected, {"normal": [0.0, 1.0, 0.0], "plane_offset_mm": 54.69, "area_mm2": 463.7}), 1.0) + + def test_reversed_cap_normal_reverses_its_plane_offset(self) -> None: + score = _score( + {"normal": [0.0, 0.0, 1.0], "plane_offset_mm": 10.0}, + { + "normal": [0.0, 0.0, 1.0], + "plane_normal": [0.0, 0.0, -1.0], + "plane_offset_mm": -10.0, + }, + ) + + self.assertEqual(score, 1.0) + + def test_intersection_vertex_binds_pattern_and_current_body_prefixes(self) -> None: + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0042/00423838.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + candidate = lower_model(parse_featurescript(feature.read_text(), "00423838"), {}) + bound, evidence = bind_candidate_selectors(candidate.cdsl) + + f7 = next(item for item in bound["features"] if item["id"] == "f_F7") + reference = f7["params"]["end_condition"]["reference"] + components = reference["intersection_of"] + self.assertEqual(len(components), 3) + self.assertEqual([item["binding_feature_id"] for item in components], ["f_F4", "f_F4", "f_F6"]) + # 绑定发生在 F4 完整 pattern 前缀;owner 保留 instance 4 的语义来源, + # snapshot 则指向该前缀的 active Compound B-rep。 + self.assertEqual([item["owner_feature_id"] for item in components[:2]], ["f_F4.c4.f_F1", "f_F4.c4.f_F1"]) + self.assertTrue(all(item["snapshot_id"].startswith("body:f_F4:") for item in components[:2])) + self.assertEqual(components[2]["match_mode"], "all") + # 同一圆柱面可能被前缀 boolean 切成不同数量的 B-rep face;关键是 + # match_mode=all 保留每个可匹配片段,而不是把它收缩为任意一个面。 + matched = components[2]["matched_selectors"] + self.assertTrue(matched) + self.assertTrue(all(item["owner_feature_id"] == "f_F6" for item in matched)) + self.assertTrue(all(item["snapshot_id"].startswith("body:f_F6:") for item in matched)) + f7_evidence = next(item for item in evidence if item["feature_id"] == "f_F7") + self.assertEqual(len(f7_evidence["resolved"]), len(matched) + 2) diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/compare.py b/onshape_to_cdsl/src/onshape_to_cdsl/compare.py index f489e60e..1f99500c 100644 --- a/onshape_to_cdsl/src/onshape_to_cdsl/compare.py +++ b/onshape_to_cdsl/src/onshape_to_cdsl/compare.py @@ -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())}, } diff --git a/onshape_to_cdsl/tests/test_pipeline.py b/onshape_to_cdsl/tests/test_pipeline.py index 42aed6ed..854dca9d 100644 --- a/onshape_to_cdsl/tests/test_pipeline.py +++ b/onshape_to_cdsl/tests/test_pipeline.py @@ -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():