diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 512be910..06435b4e 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -5,25 +5,31 @@ from __future__ import annotations import math from typing import Any, Iterable -from build123d import Axis, Compound, Edge, Face, Location, Plane, ShapeList, Shell, Solid, Vector, Wire, export_step -from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse +from build123d import AngularDirection, Axis, Compound, Edge, Face, Location, Plane, ShapeList, Shell, Solid, Vector, Wire, export_step +from OCP.BOPAlgo import BOPAlgo_Splitter +from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse from OCP.BRep import BRep_Tool -from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer +from OCP.BRepExtrema import BRepExtrema_DistShapeShape +from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet from OCP.BRepOffset import BRepOffset_Skin -from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid +from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections +from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform 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.TopAbs import TopAbs_FACE, TopAbs_SHELL +from OCP.TopExp import TopExp_Explorer from OCP.TopTools import TopTools_ListOfShape from OCP.TopoDS import TopoDS -from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Vec +from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec -from .parametric_bend import build_bend_solid from .parametric_thread import build_thread_solid -from .runtime_types import AxisSpec, BendSpec, HoleSpec, PlaneSpec, ThreadSpec, TopologyRecord, Vector3, canonical_plane_signature +from .runtime_types import ( + AxisSpec, BendSpec, HoleSpec, PlaneSpec, ThreadSpec, TopologyDelta, + TopologyDeltaRelation, TopologyRecord, Vector3, canonical_plane_signature, +) def _vector(value: list[float] | tuple[float, float, float]) -> Vector: @@ -68,6 +74,9 @@ def _arc_midpoint(edge: dict[str, Any], start: Vector, end: Vector, center: Vect class Build123dGeometryAdapter: """All B-rep construction and mutation lives in this adapter.""" + CONTACT_FUSE_TOLERANCE_MM = 1e-7 + COINCIDENT_FUSE_TOLERANCE_MM = 1e-3 + @staticmethod def plane(spec: PlaneSpec) -> Plane: # 将运行时平面定义 PlaneSpec 转换为 build123d 的 Plane。 @@ -83,20 +92,48 @@ class Build123dGeometryAdapter: # 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。 built: list[Edge] = [] for edge in edges: + if edge.get("type") == "circle": + center = _vector(edge["center_mm"]) + x_dir = _vector(edge.get("x_dir_mm") or [1, 0, 0]) + normal = _vector(edge.get("normal") or [0, 0, 1]) + radius = float(edge.get("radius_mm") or 0.0) + if radius <= 0.0 or x_dir.length <= 1e-9 or normal.length <= 1e-9: + raise ValueError("circle contour edge has a degenerate frame") + direction = AngularDirection.CLOCKWISE if bool(edge.get("clockwise")) else AngularDirection.COUNTER_CLOCKWISE + built.append(Edge.make_circle(radius, Plane(origin=center, x_dir=x_dir, z_dir=normal), angular_direction=direction)) + continue 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") parameters = edge.get("parameters") start_tangent = edge.get("start_tangent_mm") end_tangent = edge.get("end_tangent_mm") + if len(points) < 2: + raise ValueError("bspline contour edge needs at least 2 points") if (start_tangent is None) != (end_tangent is None): raise ValueError("bspline contour edge requires both endpoint tangents") + if len(points) == 2: + if math.dist( + (points[0].X, points[0].Y, points[0].Z), + (points[1].X, points[1].Y, points[1].Z), + ) <= 1e-5: + raise ValueError("two-point bspline contour edge endpoints must be distinct") + if bool(edge.get("periodic")) or start_tangent is None or end_tangent is None: + raise ValueError("two-point bspline contour edge requires non-periodic endpoint tangents") + if not isinstance(parameters, list) or len(parameters) != 2: + raise ValueError("two-point bspline contour edge requires explicit parameters") + try: + parameter_values = [float(value) for value in parameters] + except (OverflowError, TypeError, ValueError) as exc: + raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing") from exc + if not all(math.isfinite(value) for value in parameter_values) or parameter_values[1] - parameter_values[0] <= 1e-5: + raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing") + else: + parameter_values = [float(value) for value in parameters] if parameters is not None else None 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, + parameters=parameter_values, scale=False, )) continue @@ -166,6 +203,222 @@ class Build123dGeometryAdapter: faces.append(face.make_holes(holes) if holes else face) return faces + @staticmethod + def _split_images(splitter: BOPAlgo_Splitter, edge: Edge) -> list[Edge]: + """Return OCC split history, keeping an unchanged input as one image.""" + images = [Edge.cast(shape) for shape in splitter.Modified(edge.wrapped)] + return images or [edge] + + @staticmethod + def _imprint_support_face(edges: list[Edge], plane_spec: PlaneSpec) -> Face: + """Build a finite support face around all source curves. + + A fixed world-aligned box would silently make the result depend on a + sketch's orientation. Projecting every edge bounding-box corner into + the explicit workplane creates a deterministic support boundary for + the OCC splitter. Any selected face that touches that boundary is + rejected later as an unbounded IMPRINT region. + """ + if not edges: + raise ValueError("planar_imprint has no source edges") + origin = _vector(plane_spec.origin_mm) + x_dir = _vector(plane_spec.x_dir) + y_dir = _vector(plane_spec.y_dir) + coordinates: list[tuple[float, float]] = [] + for edge in edges: + box = edge.bounding_box() + for x in (box.min.X, box.max.X): + for y in (box.min.Y, box.max.Y): + for z in (box.min.Z, box.max.Z): + offset = Vector(x, y, z) - origin + coordinates.append((offset.dot(x_dir), offset.dot(y_dir))) + if not coordinates: + raise ValueError("planar_imprint cannot bound source geometry") + u_values, v_values = zip(*coordinates) + u_min, u_max = min(u_values), max(u_values) + v_min, v_max = min(v_values), max(v_values) + span = max(u_max - u_min, v_max - v_min, 1.0) + margin = max(span * 0.1, 1.0) + corners = [ + origin + x_dir * (u_min - margin) + y_dir * (v_min - margin), + origin + x_dir * (u_max + margin) + y_dir * (v_min - margin), + origin + x_dir * (u_max + margin) + y_dir * (v_max + margin), + origin + x_dir * (u_min - margin) + y_dir * (v_max + margin), + ] + return Face(Wire([ + Edge.make_line(corners[index], corners[(index + 1) % len(corners)]) + for index in range(len(corners)) + ])) + + @staticmethod + def _intersection_parameters(source: Edge, anchors: list[Edge]) -> list[float]: + """Return unique exact intersection points ordered by source direction.""" + parameters: list[float] = [] + for anchor in anchors: + distance = BRepExtrema_DistShapeShape(source.wrapped, anchor.wrapped) + distance.Perform() + if not distance.IsDone() or distance.Value() > 1e-6: + continue + for index in range(1, distance.NbSolution() + 1): + point = Vector(distance.PointOnShape1(index)) + parameter = float(source.param_at_point(point)) + if not math.isfinite(parameter): + continue + if not any(abs(parameter - existing) <= 1e-7 for existing in parameters): + parameters.append(parameter) + return sorted(parameters) + + @staticmethod + def _selected_imprint_edges( + splitter: BOPAlgo_Splitter, + source: Edge, + anchors: list[Edge], + fragment: dict[str, Any] | None, + ) -> list[Edge]: + """Resolve a logical source or one ordered split fragment exactly.""" + images = Build123dGeometryAdapter._split_images(splitter, source) + if not fragment: + # A bare IMPRINT source query denotes every builder image of the + # same logical FeatureScript edge. The caller proves that every + # requested-side candidate is bounded; it must not pick an + # arbitrary image just because OCC introduced vertices at contact + # points. + return images + intersections = Build123dGeometryAdapter._intersection_parameters(source, anchors) + if not intersections: + raise ValueError("planar_imprint fragment source and anchor do not intersect") + order = fragment.get("intersection_index") + if order is None: + if len(intersections) != 1: + raise ValueError("planar_imprint fragment intersection is not unique") + anchor_parameter = intersections[0] + elif isinstance(order, int) and 0 <= order < len(intersections): + anchor_parameter = intersections[order] + else: + raise ValueError("planar_imprint fragment intersection index is invalid") + # FeatureScript's topology disambiguation uses -1 for the directed + # successor of a vertex and +1 for its predecessor. Comparing raw + # parameters would break at a periodic curve's 0/1 seam, so resolve + # the image by the exact split endpoint and its source-aligned tangent. + forward = float(fragment.get("side")) < 0.0 + anchor_point = source.position_at(anchor_parameter) + source_tangent = source.tangent_at(anchor_parameter) + if source_tangent.length <= 1e-9: + raise ValueError("planar_imprint source edge has no directed tangent") + if len(images) == 1 and bool(source.wrapped.Closed()): + # One exact vertex does not divide a periodic OCC edge. Both + # directed choices therefore refer to its sole logical fragment. + return images + candidates: list[Edge] = [] + for edge in images: + endpoint = edge.position_at(0 if forward else 1) + tangent = edge.tangent_at(0 if forward else 1) + if (endpoint - anchor_point).length > 1e-6: + continue + if tangent.length <= 1e-9 or source_tangent.normalized().dot(tangent.normalized()) < 1.0 - 1e-7: + continue + candidates.append(edge) + if len(candidates) != 1: + raise ValueError("planar_imprint fragment side does not resolve one split edge") + return candidates + + @staticmethod + def _face_uses_boundary(face: Face, boundary_edges: list[Edge]) -> bool: + return any( + edge.wrapped.IsSame(boundary.wrapped) + for edge in face.edges() + for boundary in boundary_edges + ) + + def _faces_from_planar_imprint(self, sketch: dict[str, Any]) -> list[Face]: + """Materialize exact bounded IMPRINT regions with OCC planar splitting.""" + source_entries = sketch.get("imprint_entities_mm") or [] + selections = sketch.get("imprint_selections") or [] + if not source_entries or not selections: + raise ValueError("planar_imprint is missing resolved source entities or selections") + source_edges: dict[str, list[Edge]] = {} + all_edges: list[Edge] = [] + for entry in source_entries: + source_id = str(entry.get("id") or "") + raw_edges = entry.get("edges") or [] + if not source_id or not raw_edges or source_id in source_edges: + raise ValueError("planar_imprint source entities are invalid") + edges = list(self._wire(raw_edges).edges()) + if not edges: + raise ValueError(f"planar_imprint source entity {source_id!r} has no OCC edge") + source_edges[source_id] = edges + all_edges.extend(edges) + plane_spec = PlaneSpec.from_mapping(sketch.get("workplane") or {}) + support = self._imprint_support_face(all_edges, plane_spec) + splitter = BOPAlgo_Splitter() + splitter.AddArgument(support.wrapped) + for edge in all_edges: + splitter.AddTool(edge.wrapped) + splitter.Perform() + if splitter.HasErrors(): + raise ValueError("planar_imprint OCC splitter failed") + explorer = TopExp_Explorer(splitter.Shape(), TopAbs_FACE) + regions: list[Face] = [] + while explorer.More(): + face = Face.cast(explorer.Current()) + if face.area > 1e-10: + regions.append(face) + explorer.Next() + if not regions: + raise ValueError("planar_imprint OCC splitter produced no regions") + boundary_edges = [ + image + for edge in support.edges() + for image in self._split_images(splitter, edge) + ] + normal = _vector(plane_spec.normal) + selected: list[Face] = [] + for selection in selections: + source_id = str(selection.get("source_entity_id") or "") + source = source_edges.get(source_id) or [] + if len(source) != 1: + raise ValueError("planar_imprint selection source must resolve to one analytic edge") + fragment = selection.get("fragment") + anchor_id = str((fragment or {}).get("anchor_entity_id") or "") + anchors = source_edges.get(anchor_id) or [] if fragment else [] + if fragment and not anchors: + raise ValueError("planar_imprint fragment anchor is unavailable") + edges = self._selected_imprint_edges(splitter, source[0], anchors, fragment) + face_side = float(selection.get("face_side") or 0.0) + if face_side not in {-1.0, 1.0}: + raise ValueError("planar_imprint face side is invalid") + candidate_faces: list[Face] = [] + for edge in edges: + tangent = edge.tangent_at(0.5) + lateral = normal.cross(tangent) + if lateral.length <= 1e-9: + raise ValueError("planar_imprint selected edge has no in-plane side") + extent = max(edge.length, 1e-4) + probe_distance = max(1e-5, min(extent / 1000.0, 0.01)) + probe = edge.position_at(0.5) + lateral.normalized() * (probe_distance * face_side) + candidates = [face for face in regions if face.is_inside(probe, probe_distance / 10.0)] + if len(candidates) != 1: + raise ValueError("planar_imprint face side does not resolve one region") + face = candidates[0] + if not any(face.wrapped.IsSame(existing.wrapped) for existing in candidate_faces): + candidate_faces.append(face) + unbounded = [ + face for face in candidate_faces + if self._face_uses_boundary(face, boundary_edges) + ] + if unbounded: + if len(candidate_faces) == 1 and self._face_uses_boundary(candidate_faces[0], boundary_edges): + raise ValueError("planar_imprint selected region is unbounded") + raise ValueError("planar_imprint source side includes an unbounded region") + # A bare source query can legitimately select every bounded face + # adjacent to its OCC split descendants. This is a set-valued + # FeatureScript IMPRINT result, not an invitation to choose one + # segment by length, order, or proximity. + for face in candidate_faces: + if not any(face.wrapped.IsSame(existing.wrapped) for existing in selected): + selected.append(face) + return selected + def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Face]: # 从草图数据解析出可拉伸/旋转的轮廓面,按三种数据来源依次回退。 # 1. 单圆 contour 不应先被 sketch_solver 展开成四段圆弧。圆弧分段会 @@ -174,6 +427,8 @@ class Build123dGeometryAdapter: # 只含闭合整圆的轮廓,保留每个圆一条原生 circle edge;同心圆仍由 # _faces_from_circles 的包含关系生成带孔面。 profile = sketch.get("profile") or {} + if profile.get("type") == "planar_imprint": + return self._faces_from_planar_imprint(sketch) contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None if isinstance(contours, list) and contours and all( isinstance(contour, dict) @@ -227,8 +482,8 @@ class Build123dGeometryAdapter: 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: - """由多条简单闭合草图轮廓生成实体放样。""" + def _loft_wires(self, sketches: list[dict[str, Any]]) -> list[Wire]: + """Resolve the bounded closed-wire CDSL loft contract once.""" wires: list[Wire] = [] for index, sketch in enumerate(sketches): # Solid.make_loft 接收 Wire;复用 faces_for_sketch 保持放样、 @@ -242,7 +497,35 @@ class Build123dGeometryAdapter: wires.append(faces[0].outer_wire()) if len(wires) < 2: raise ValueError("loft requires at least two profile sketches") - return Solid.make_loft(wires) + return wires + + def loft(self, sketches: list[dict[str, Any]]) -> Solid: + result, _delta = self.loft_with_topology_delta(sketches) + return result + + def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Solid, TopologyDelta]: + """Build a simple closed-wire solid loft through one OCC builder.""" + wires = self._loft_wires(sketches) + builder = BRepOffsetAPI_ThruSections(True, False) + builder.CheckCompatibility(True) + for wire in wires: + builder.AddWire(wire.wrapped) + builder.Build() + if not builder.IsDone(): + raise ValueError("OCC loft operation did not complete") + result = Solid(builder.Shape()) + if not result.is_valid or not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9: + raise ValueError("OCC loft operation did not produce a valid solid") + relations: list[TopologyDeltaRelation] = [] + for wire, output, role in ( + (wires[0], builder.FirstShape(), "loft.start"), + (wires[-1], builder.LastShape(), "loft.end"), + ): + if not output.IsNull() and output.ShapeType() == TopAbs_FACE: + relations.append(TopologyDeltaRelation( + "generated", "face", wire.wrapped, (output,), output_role=role, + )) + return result, TopologyDelta(operation="loft", relations=tuple(relations)) 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.""" @@ -285,13 +568,62 @@ class Build123dGeometryAdapter: 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 同向时始终优先使用它。 + def face_normal(face: Face) -> Vector3: + normal = face.normal_at() + return (float(normal.X), float(normal.Y), float(normal.Z)) + + @staticmethod + def extrude_with_topology_delta(face: Face, direction: Vector3) -> tuple[Solid, TopologyDelta]: + """Extrude one B-rep face and retain its two builder-proven cap faces.""" + vector = _vector(direction) + if vector.length <= 1e-9: + raise ValueError("extrude direction must be non-zero") + builder = BRepPrimAPI_MakePrism( + face.wrapped, gp_Vec(vector.X, vector.Y, vector.Z), True, True, + ) + if not builder.IsDone(): + raise ValueError("OCC extrude operation did not complete") + result = Solid(builder.Shape()) + if not result.is_valid or not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9: + raise ValueError("OCC extrude operation did not produce a valid solid") + relations: list[TopologyDeltaRelation] = [] + for output, role in ((builder.FirstShape(), "extrude.start"), (builder.LastShape(), "extrude.end")): + if not output.IsNull() and output.ShapeType() == TopAbs_FACE: + relations.append(TopologyDeltaRelation( + "generated", "face", face.wrapped, (output,), output_role=role, + )) + return result, TopologyDelta(operation="extrude", relations=tuple(relations)) + + @staticmethod + def _single_face_from_shape(shape: Any) -> Face | None: + """Return one face only when an OCC builder output contains exactly one. + + ``LocOpe_DPrism.FirstShape`` and ``LastShape`` are shells in the OCP + binding, even for the single cap faces they represent. Requiring one + contained face keeps those roles tied to the builder output rather + than guessing a cap from a coincident planar result face. + """ + explorer = TopExp_Explorer(shape, TopAbs_FACE) + faces: list[Face] = [] + while explorer.More(): + faces.append(Face.cast(explorer.Current())) + explorer.Next() + return faces[0] if len(faces) == 1 else None + + @staticmethod + def extrude_taper_with_topology_delta( + face: Face, + direction: Vector3, + taper_deg: float, + ) -> tuple[Solid, TopologyDelta | None]: + """Extrude a drafted face and retain exact caps from ``LocOpe_DPrism``. + + The native tapered-extrude fallback has no history interface. Only + the narrow ``LocOpe_DPrism`` path can expose a cap role, and only when + each of its ``FirstShape``/``LastShape`` outputs contains exactly one + face. All other valid draft results deliberately retain no topology + delta instead of inferring one from geometry. + """ vector = _vector(direction) normal = face.normal_at() if ( @@ -309,8 +641,31 @@ class Build123dGeometryAdapter: 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) + relations: list[TopologyDeltaRelation] = [] + for cap_shape, role in ( + (prism.FirstShape(), "extrude.start"), + (prism.LastShape(), "extrude.end"), + ): + cap = Build123dGeometryAdapter._single_face_from_shape(cap_shape) + if cap is not None: + relations.append(TopologyDeltaRelation( + "generated", "face", face.wrapped, (cap.wrapped,), output_role=role, + )) + return result, TopologyDelta(operation="extrude_taper", relations=tuple(relations)) + return Solid.extrude_taper(face, vector, taper_deg), None + + @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 同向时始终优先使用它。 + result, _topology_delta = Build123dGeometryAdapter.extrude_taper_with_topology_delta( + face, direction, taper_deg, + ) + return result @staticmethod def extrude_trimmed(face: Face, target: Any, direction: Vector3) -> Any: @@ -587,10 +942,38 @@ class Build123dGeometryAdapter: @staticmethod def fuse(body: Any | None, solid: Any) -> Any: + """Fuse bodies without retaining a builder history.""" + result, _delta = Build123dGeometryAdapter.fuse_with_topology_delta( + body, solid, record_history=False, + ) + return result + + @staticmethod + def fuse_with_topology_delta( + body: Any | None, solid: Any, *, record_history: bool = True, + ) -> tuple[Any, TopologyDelta | None]: + """Fuse one explicit body pair and retain history when it stays exact. + + The established fallback sequence changes the kernel result: a normal + build123d fuse or fuzzy OCC fuse has a different history object. Such + results remain executable, but must not inherit relationships from the + discarded first builder. Multi-member inputs are likewise outside the + one-builder proof boundary. + """ # 布尔并:没有既有主体时,直接以该实体作为新主体。 # 实参类型放宽为 Any:build123d 的布尔结果可能是 Solid 或 Compound。 if body is None: - return solid + return solid, None + if ( + not record_history + or len(Build123dGeometryAdapter.body_solids(body)) != 1 + or len(Build123dGeometryAdapter.body_solids(solid)) != 1 + ): + return Build123dGeometryAdapter._fuse_without_history(body, solid), None + return Build123dGeometryAdapter._fuse_with_history(body, solid) + + @staticmethod + def _fuse_without_history(body: Any, solid: Any) -> Any: # build123d.Shape.fuse 未启用 OBB 加速器;镜像后的重叠实体在该路径 # 会偶发返回反向、无效的 B-rep。直接采用 OCC 的稳定布尔配置,保留 # 一般 add/replay 的同一 union 语义。 @@ -603,6 +986,30 @@ class Build123dGeometryAdapter: raise ValueError("OCC union operation did not complete") result = Solid(operation.Shape()) if result.is_valid: + # OBB 对多个相交的曲面 sweep 偶尔会把交叠区单独保留为一个 + # Solid。普通 fuse 若能以更少的有效实体表示相同并集,应优先 + # 使用它;不相交结果仍保留 OBB 的多实体 body 语义。 + if len(Build123dGeometryAdapter.body_solids(result)) > 1: + fallback = Build123dGeometryAdapter._coerce_single_or_compound(body.fuse(solid)) + if fallback is not None and fallback.is_valid and ( + len(Build123dGeometryAdapter.body_solids(fallback)) + < len(Build123dGeometryAdapter.body_solids(result)) + ): + return fallback + # 两个输入在数学上已经接触时,曲面 sweep 的近似交界可能只因 + # 内核容差留下重叠成员。仅在这种零距离情形重试 fuzzy boolean; + # 有实际间隙的独立 body 不参与该修复,不能被错误地桥接合并。 + if body.distance_to(solid) <= Build123dGeometryAdapter.CONTACT_FUSE_TOLERANCE_MM: + operation = BRepAlgoAPI_Fuse() + operation.SetRunParallel(True); operation.SetUseOBB(True) + operation.SetFuzzyValue(Build123dGeometryAdapter.COINCIDENT_FUSE_TOLERANCE_MM) + operation.SetArguments(arguments); operation.SetTools(tools); operation.Build() + fuzzy = Solid(operation.Shape()) if operation.IsDone() else None + if fuzzy is not None and fuzzy.is_valid and ( + len(Build123dGeometryAdapter.body_solids(fuzzy)) + < len(Build123dGeometryAdapter.body_solids(result)) + ) and fuzzy.volume + 1e-6 >= max(float(body.volume), float(solid.volume)): + return fuzzy return result # 保留 build123d 的既有调用作为内核版本差异下的兼容回退;无效结果 # 不能悄然进入后续 feature history。 @@ -611,6 +1018,39 @@ class Build123dGeometryAdapter: return fallback raise ValueError("OCC union operation produced an invalid shape") + @staticmethod + def _fuse_with_history(body: Any, solid: Any) -> tuple[Any, TopologyDelta | None]: + """Run the primary fuse algorithm with its own exact history object.""" + arguments = TopTools_ListOfShape(); arguments.Append(body.wrapped) + tools = TopTools_ListOfShape(); tools.Append(solid.wrapped) + operation = BRepAlgoAPI_Fuse() + operation.SetRunParallel(True); operation.SetUseOBB(True); operation.SetToFillHistory(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 not result.is_valid: + return Build123dGeometryAdapter._fuse_without_history(body, solid), None + if len(Build123dGeometryAdapter.body_solids(result)) > 1: + fallback = Build123dGeometryAdapter._coerce_single_or_compound(body.fuse(solid)) + if fallback is not None and fallback.is_valid and ( + len(Build123dGeometryAdapter.body_solids(fallback)) + < len(Build123dGeometryAdapter.body_solids(result)) + ): + return fallback, None + if body.distance_to(solid) <= Build123dGeometryAdapter.CONTACT_FUSE_TOLERANCE_MM: + fuzzy = BRepAlgoAPI_Fuse() + fuzzy.SetRunParallel(True); fuzzy.SetUseOBB(True) + fuzzy.SetFuzzyValue(Build123dGeometryAdapter.COINCIDENT_FUSE_TOLERANCE_MM) + fuzzy.SetArguments(arguments); fuzzy.SetTools(tools); fuzzy.Build() + fuzzy_result = Solid(fuzzy.Shape()) if fuzzy.IsDone() else None + if fuzzy_result is not None and fuzzy_result.is_valid and ( + len(Build123dGeometryAdapter.body_solids(fuzzy_result)) + < len(Build123dGeometryAdapter.body_solids(result)) + ) and fuzzy_result.volume + 1e-6 >= max(float(body.volume), float(solid.volume)): + return fuzzy_result, None + return result, Build123dGeometryAdapter._builder_topology_delta(operation, (body, solid), "union") + @staticmethod def combine(body: Any | None, solid: Any) -> Any: # 保留独立 result body:不得调用 fuse,否则相交实体会被内核合并。 @@ -621,10 +1061,50 @@ class Build123dGeometryAdapter: @staticmethod def cut(body: Any, tool: Any) -> Any: # 从主体上减去工具实体。 + # Compound 内的独立实体分别切除再组合,与整体差集的集合语义一致。 + # 对包含抽壳薄壁的多个成员,直接对整个 Compound 做 OCC boolean 会在 + # 内核中长时间求解,且不会改善任何成员间不存在的拓扑关系。 + members = Build123dGeometryAdapter.body_solids(body) + if len(members) > 1: + result = None + for member in members: + cut_member = Build123dGeometryAdapter._coerce_single_or_compound(member.cut(tool)) + # 多实体差集允许 cutter 完全移除其中一个成员;其他成员仍是 + # 当前 feature 的有效结果。只有所有成员均被移除才是空切除。 + if cut_member is not None: + result = Build123dGeometryAdapter.combine(result, cut_member) + if result is None: + raise ValueError("OCC cut operation produced no shape") + return result return Build123dGeometryAdapter._coerce_single_or_compound( body.cut(tool), empty_error="OCC cut operation produced no shape", ) + @staticmethod + def cut_with_topology_delta(body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]: + """Subtract single explicit bodies while preserving exact OCC history. + + The established multi-member path cuts each member independently to + bound OCC work. It has no single builder history for the aggregate, + so it deliberately keeps the executable result but returns no delta + rather than composing an unproven history graph. + """ + if len(Build123dGeometryAdapter.body_solids(body)) != 1 or len(Build123dGeometryAdapter.body_solids(tool)) != 1: + return Build123dGeometryAdapter.cut(body, tool), None + arguments = TopTools_ListOfShape(); arguments.Append(body.wrapped) + tools = TopTools_ListOfShape(); tools.Append(tool.wrapped) + operation = BRepAlgoAPI_Cut() + operation.SetRunParallel(True); operation.SetUseOBB(True); operation.SetToFillHistory(True) + operation.SetArguments(arguments); operation.SetTools(tools); operation.Build() + if not operation.IsDone(): + raise ValueError("OCC cut operation did not complete") + result = Build123dGeometryAdapter._coerce_single_or_compound( + Solid(operation.Shape()), empty_error="OCC cut operation produced no shape", + ) + return result, Build123dGeometryAdapter._builder_topology_delta( + operation, (body, tool), "subtract", + ) + @staticmethod def sphere(radius_mm: float, center_mm: Vector3) -> Solid: # 以给定球心与半径生成球体实体。 @@ -656,6 +1136,147 @@ class Build123dGeometryAdapter: left.intersect(right), empty_error="boolean intersection produced no solid", ) + @staticmethod + def intersect_with_topology_delta(left: Any, right: Any) -> tuple[Any, TopologyDelta | None]: + """Intersect single bodies through one OCC builder and retain history.""" + if len(Build123dGeometryAdapter.body_solids(left)) != 1 or len(Build123dGeometryAdapter.body_solids(right)) != 1: + return Build123dGeometryAdapter.intersect(left, right), None + arguments = TopTools_ListOfShape(); arguments.Append(left.wrapped) + tools = TopTools_ListOfShape(); tools.Append(right.wrapped) + operation = BRepAlgoAPI_Common() + operation.SetToFillHistory(True) + operation.SetArguments(arguments); operation.SetTools(tools); operation.Build() + if not operation.IsDone(): + raise ValueError("OCC intersection operation did not complete") + result = Build123dGeometryAdapter._coerce_single_or_compound( + Solid(operation.Shape()), empty_error="boolean intersection produced no solid", + ) + return result, Build123dGeometryAdapter._builder_topology_delta( + operation, (left, right), "intersect", + ) + + @staticmethod + def transform(body: Any, transform: dict[str, Any]) -> Any: + """Apply one explicit body transform without exposing kernel history.""" + result, _delta = Build123dGeometryAdapter.transform_with_topology_delta(body, transform) + return result + + @staticmethod + def transform_with_topology_delta(body: Any, transform: dict[str, Any]) -> tuple[Any, TopologyDelta]: + """Apply one body transform and retain exact OCC subshape history.""" + kind = str(transform.get("type") or "") + conversion = gp_Trsf() + if kind == "translation": + offset = transform.get("translation_mm") + if not isinstance(offset, list) or len(offset) != 3: + raise ValueError("translation transform requires translation_mm") + conversion.SetTranslation(gp_Vec(*(float(value) for value in offset))) + elif kind == "rotation": + axis = AxisSpec.from_mapping(transform.get("axis") or {}) + angle_deg = transform.get("angle_deg") + if not isinstance(angle_deg, (int, float)): + raise ValueError("rotation transform requires angle_deg") + conversion.SetRotation( + gp_Ax1(gp_Pnt(*axis.origin_mm), gp_Dir(*axis.direction)), + math.radians(float(angle_deg)), + ) + elif kind == "uniform_scale": + center = transform.get("center_mm") + scale_factor = transform.get("scale_factor") + if not isinstance(center, list) or len(center) != 3: + raise ValueError("uniform_scale transform requires center_mm") + if not isinstance(scale_factor, (int, float)) or not math.isfinite(float(scale_factor)) or float(scale_factor) <= 0: + raise ValueError("uniform_scale transform requires a finite positive scale_factor") + conversion.SetScale(gp_Pnt(*(float(value) for value in center)), float(scale_factor)) + else: + raise ValueError(f"unsupported body transform type {kind!r}") + operation = BRepBuilderAPI_Transform(body.wrapped, conversion, True) + operation.Build() + if not operation.IsDone(): + raise ValueError("OCC body transform did not complete") + result = Solid(operation.Shape()) + if not result.is_valid: + raise ValueError("OCC body transform produced an invalid shape") + return result, Build123dGeometryAdapter._builder_topology_delta(operation, (body,), kind) + + @staticmethod + def _builder_topology_delta(operation: Any, sources: Iterable[Any], operation_name: str) -> TopologyDelta: + """Translate OCC builder history into adapter-neutral opaque relations.""" + relations: list[TopologyDeltaRelation] = [] + for source in sources: + for kind, shapes in ( + ("face", list(source.faces())), + ("edge", list(source.edges())), + ("vertex", list(source.vertices())), + ): + for shape in shapes: + source_value = shape.wrapped + is_deleted = bool( + operation.IsDeleted(source_value) + if hasattr(operation, "IsDeleted") else operation.IsRemoved(source_value) + ) + if is_deleted: + relations.append(TopologyDeltaRelation("deleted", kind, source_value)) + modified = tuple(operation.Modified(source_value)) + generated = tuple(operation.Generated(source_value)) + same_modified = ( + len(modified) == 1 and bool(modified[0].IsSame(source_value)) + ) + if modified: + relations.append(TopologyDeltaRelation( + "preserved" if same_modified and not generated else "modified", + kind, source_value, modified, + )) + elif not generated and not is_deleted: + # A no-op transform can retain the original OCC object. + # The registry still requires it to appear in the result + # snapshot before treating this as a continuation. + relations.append(TopologyDeltaRelation("preserved", kind, source_value, (source_value,))) + if generated: + relations.append(TopologyDeltaRelation("generated", kind, source_value, generated)) + return TopologyDelta(operation=operation_name, relations=tuple(relations)) + + @staticmethod + def _shell_topology_delta( + operation: Any, source: Solid, closing_faces: Iterable[Face], + ) -> TopologyDelta: + """Annotate exact shell history with only builder-proven output roles.""" + base_delta = Build123dGeometryAdapter._builder_topology_delta(operation, (source,), "shell") + closing_values = tuple(face.wrapped for face in closing_faces) + closing_edge_values = tuple( + edge.wrapped for face in closing_faces for edge in face.edges() + ) + + def is_member(value: Any, candidates: tuple[Any, ...]) -> bool: + return any(bool(value.IsSame(candidate)) for candidate in candidates) + + def output_role(relation: TopologyDeltaRelation) -> str | None: + if relation.kind == "face": + is_closing = is_member(relation.source_value, closing_values) + if is_closing and relation.event in {"preserved", "modified", "generated"}: + return "shell.closing_descendant" + if not is_closing and relation.event == "generated": + return "shell.offset_face" + if not is_closing and relation.event in {"preserved", "modified"}: + return "shell.body_face" + if ( + relation.kind == "edge" and relation.event == "generated" + and is_member(relation.source_value, closing_edge_values) + ): + return "shell.wall" + return None + + return TopologyDelta( + operation=base_delta.operation, + relations=tuple( + TopologyDeltaRelation( + relation.event, relation.kind, relation.source_value, relation.result_values, + output_role=output_role(relation), + ) + for relation in base_delta.relations + ), + ) + def hole_tool(self, spec: HoleSpec, starts: Iterable[Vector3], inward: Vector3, through_depth_mm: float) -> Solid: """Build a neutral ``HoleSpec`` into one OCC cutting tool.""" # 将孔规格 HoleSpec 转成一个可直接切除的 OCC 工具体。 @@ -712,6 +1333,18 @@ class Build123dGeometryAdapter: sheet thickness on ``spec.frame.normal`` and the width axis on ``spec.frame.x_dir``. """ + # ``bend_add`` 生成器是可选的几何实现。不能因该模块未随部署产物 + # 提交而让所有非钣金 CDSL 在 adapter import 阶段失效;真正执行 + # 折弯时仍须报出精确的缺失依赖,不能退化为虚构实体。 + try: + from .parametric_bend import build_bend_solid + except ModuleNotFoundError as error: + if error.name != f"{__package__}.parametric_bend": + raise + raise RuntimeError( + "bend_add requires cdsl_engine.parametric_bend.build_bend_solid, " + "but the generator module is not present in this checkout" + ) from error solid = build_bend_solid(spec) frame = spec.frame plane = Plane( @@ -726,6 +1359,26 @@ class Build123dGeometryAdapter: # 对指定边以给定半径做圆角。 return body.fillet(radius_mm, list(edges)) + @staticmethod + def fillet_with_topology_delta( + body: Any, radius_mm: float, edges: Iterable[Edge], + ) -> tuple[Any, TopologyDelta | None]: + """Fillet a single body and retain its direct OCC builder history.""" + selected = list(edges) + if len(Build123dGeometryAdapter.body_solids(body)) != 1: + return Build123dGeometryAdapter.fillet(body, radius_mm, selected), None + builder = BRepFilletAPI_MakeFillet(body.wrapped) + for edge in selected: + builder.Add(radius_mm, edge.wrapped) + builder.Build() + if builder.IsDone(): + result = Solid(builder.Shape()) + if result.is_valid: + return result, Build123dGeometryAdapter._builder_topology_delta(builder, (body,), "fillet") + # Preserve build123d's existing fallback/error semantics when OCC's + # direct builder cannot construct this dress-up. + return Build123dGeometryAdapter.fillet(body, radius_mm, selected), None + @staticmethod def tangent_edges(body: Any, seeds: Iterable[Edge], *, angular_tolerance: float = 1e-6) -> list[Edge]: """Expand selected edges through actual tangent, vertex-adjacent chains. @@ -796,6 +1449,25 @@ class Build123dGeometryAdapter: return result return body.chamfer(distance_mm, distance_2_mm, selected, face=face) + @staticmethod + def chamfer_with_topology_delta( + body: Any, distance_mm: float, distance_2_mm: float | None, + edges: Iterable[Edge], face: Face | None = None, + ) -> tuple[Any, TopologyDelta | None]: + """Retain history for the equal-distance single-body chamfer subset.""" + selected = list(edges) + if distance_2_mm is not None or face is not None or len(Build123dGeometryAdapter.body_solids(body)) != 1: + return Build123dGeometryAdapter.chamfer(body, distance_mm, distance_2_mm, selected, face=face), None + 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, Build123dGeometryAdapter._builder_topology_delta(builder, (body,), "chamfer") + return Build123dGeometryAdapter.chamfer(body, distance_mm, distance_2_mm, selected, face=face), None + @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. @@ -920,6 +1592,15 @@ class Build123dGeometryAdapter: @staticmethod def shell(body: Any, faces: Iterable[Face], thickness_mm: float, *, inward: bool = True) -> Any: + result, _delta = Build123dGeometryAdapter.shell_with_topology_delta( + body, faces, thickness_mm, inward=inward, + ) + return result + + @staticmethod + def shell_with_topology_delta( + body: Any, faces: Iterable[Face], thickness_mm: float, *, inward: bool = True, + ) -> tuple[Any, TopologyDelta]: # 对单个实体移除指定面并偏置其余面,生成薄壁实体。多 body 的目标 # 选择与结果合并由 runtime 处理;OCC 的 MakeThickSolidByJoin 只接受 # 一个 Solid,不能把 Compound 直接交给内核并猜测其 body 生命周期。 @@ -953,7 +1634,7 @@ class Build123dGeometryAdapter: result = Solid(builder.Shape()) if not result.is_valid: raise ValueError("OCC shell operation produced an invalid shape") - return result + return result, Build123dGeometryAdapter._shell_topology_delta(builder, solids[0], selected) @staticmethod def sweep( @@ -964,6 +1645,22 @@ class Build123dGeometryAdapter: make_solid: bool = True, is_frenet: bool = False, transition: Any = None, + ) -> Solid: + result, _delta = Build123dGeometryAdapter.sweep_with_topology_delta( + section, spine, inner_wires=inner_wires, make_solid=make_solid, + is_frenet=is_frenet, transition=transition, + ) + return result + + @staticmethod + def _sweep_without_topology_delta( + section: Face | Wire, + spine: Edge | Wire, + *, + inner_wires: list[Wire] | None = None, + make_solid: bool = True, + is_frenet: bool = False, + transition: Any = None, ) -> Solid: # 沿路径线扫掠截面生成实体(build123d 原生扫掠,路径可为直线/曲线/螺旋边)。 # 默认值对齐 build123d Solid.sweep:make_solid=True 封盖成体;is_frenet=True @@ -983,6 +1680,54 @@ class Build123dGeometryAdapter: raise ValueError("OCC sweep operation did not produce a solid") return result + @staticmethod + def sweep_with_topology_delta( + section: Face | Wire, + spine: Edge | Wire, + *, + inner_wires: list[Wire] | None = None, + make_solid: bool = True, + is_frenet: bool = False, + transition: Any = None, + ) -> tuple[Solid, TopologyDelta | None]: + """Sweep one simple profile through its direct pipe-shell builder. + + The existing native path remains authoritative for hollow profiles, + transition variants and non-solid output. Those cases can still + execute, but their final builder provenance is not available through + this bounded contract. + """ + if ( + not isinstance(section, Face) + or section.inner_wires() + or inner_wires + or not make_solid + or transition is not None + ): + return Build123dGeometryAdapter._sweep_without_topology_delta( + section, spine, inner_wires=inner_wires, make_solid=make_solid, + is_frenet=is_frenet, transition=transition, + ), None + path = spine if isinstance(spine, Wire) else Wire.combine([spine])[0] + builder = BRepOffsetAPI_MakePipeShell(path.wrapped) + builder.SetMode(bool(is_frenet)) + builder.Add(section.outer_wire().wrapped, False, False) + builder.Build() + if not builder.IsDone(): + raise ValueError("OCC sweep operation did not complete") + if not builder.MakeSolid(): + raise ValueError("OCC sweep operation did not produce a solid") + result = Solid(builder.Shape()) + if not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9 or not result.is_valid: + raise ValueError("OCC sweep operation did not produce a valid solid") + relations: list[TopologyDeltaRelation] = [] + for output, role in ((builder.FirstShape(), "sweep.start"), (builder.LastShape(), "sweep.end")): + if not output.IsNull() and output.ShapeType() == TopAbs_FACE: + relations.append(TopologyDeltaRelation( + "generated", "face", section.wrapped, (output,), output_role=role, + )) + return result, TopologyDelta(operation="sweep", relations=tuple(relations)) + @staticmethod def sweep_path( points: Iterable[Vector3], @@ -1005,6 +1750,21 @@ class Build123dGeometryAdapter: 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") + direction = vertices[-1] - vertices[0] + tolerance = 1e-9 * max(1.0, direction.length) + collinear_points = direction.length > tolerance and all( + (point - vertices[0]).cross(direction).length <= tolerance + for point in vertices[1:-1] + ) + collinear_tangents = tangents is None or all( + tangent.cross(direction).length <= tolerance and tangent.dot(direction) > tolerance + for tangent in tangents + ) + if collinear_points and collinear_tangents: + # OCC 对完全共线的插值 B-spline 做实体 sweep 时可能无限求解。 + # 此处的点列和端切线没有曲率信息,几何上严格等价于一条直线; + # 仅在同向条件成立时退化,反向切线仍保留 B-spline 语义。 + return Edge.make_line(vertices[0], vertices[-1]) return Edge.make_spline(vertices, tangents=tangents, parameters=parameters, scale=False) @staticmethod @@ -1384,6 +2144,20 @@ class Build123dGeometryAdapter: "curve_type": str(edge.geom_type).split(".")[-1].lower(), "adjacent_face_count": len(edge_faces[index]), } + if geometry["curve_type"] == "circle": + # ``Edge.center()`` is a point on a periodic circle, not its + # geometric centre. Preserve the OCC circle data separately + # so a provenance-backed rotational selector can distinguish + # concentric full circles at different axial locations. + try: + circle_center = edge.arc_center + radius = float(edge.radius) + values = (circle_center.X, circle_center.Y, circle_center.Z, radius) + except (AttributeError, TypeError, ValueError): + values = () + if values and all(math.isfinite(float(value)) for value in values) and radius > 0: + geometry["circle_center_mm"] = [circle_center.X, circle_center.Y, circle_center.Z] + geometry["radius_mm"] = radius if vertices: geometry["start_mm"] = list(vertices[0]) geometry["end_mm"] = list(vertices[-1]) diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index c0a94d4a..c09e8b3e 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -12,17 +12,20 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Iterable -from .runtime_types import CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeDiagnostic +from .runtime_types import ( + CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeDiagnostic, + pattern_instance_member_id, transform_copy_member_id, +) from .operation_contracts import materialized_feature_contracts -_SELECTOR_REQUIRED = frozenset({"extrude_add_blind_with_hole", "loft_add_with_cap_face", "fillet", "chamfer", "shell"}) +_SELECTOR_REQUIRED = frozenset({"extrude_add_blind_with_hole", "extrude_from_face", "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_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface", + "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face", "extrude_surface", "extrude_cut_through", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", @@ -36,11 +39,18 @@ _ACTIVE_BODY_REQUIRED = frozenset({ "thread_cut", }) _BODY_MUTATING_ATOMICS = frozenset({ - "extrude_add_blind", "extrude_add_blind_with_hole", "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_from_face", "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", "bend_add", *_HOLE_ATOMICS, "fillet", "chamfer", "shell", "boolean_bodies", }) +# ``ExecutionSession.body_members`` only contains independently selectable +# body outputs. A normal additive/cut/dress-up feature replaces the active +# aggregate, while ``result_mode: new_body`` and ``keep_tools`` are the two +# contracts that preserve a previous member. Circular patterns over those +# members can additionally expose a proven COPY instance; replayed/fused +# patterns remain ineligible because no exact instance ownership exists. +_PATTERN_ATOMICS = frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"}) # 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, # thread_cut and bend_add are excluded: pattern translation does not yet move @@ -61,6 +71,167 @@ _EXTENT_TARGET_KINDS = { } +def _mappings(value: Any): + """Yield nested feature mappings for capability-only contract checks.""" + if isinstance(value, dict): + yield value + for child in value.values(): + yield from _mappings(child) + elif isinstance(value, list): + for child in value: + yield from _mappings(child) + + +def _transform_member_sources(params: dict[str, Any]) -> set[str]: + """Return internal body-member keys named by a transform contract.""" + source_ids = {str(value) for value in params.get("source_feature_ids") or ()} + source_ids.update( + pattern_instance_member_id( + str(reference.get("pattern_feature_id") or ""), + str(reference.get("source_feature_id") or ""), + int(reference.get("instance_index") or 0), + ) + for reference in params.get("pattern_instance_refs") or () + if isinstance(reference, dict) + ) + source_ids.update( + transform_copy_member_id( + str(reference.get("transform_feature_id") or ""), + str(reference.get("source_feature_id") or ""), + ) + for reference in params.get("transform_copy_refs") or () + if isinstance(reference, dict) + ) + return source_ids + + +def _pattern_instance_member_sources(params: dict[str, Any], parameter: str) -> set[str]: + """Project structured pattern refs to internal body-member keys.""" + return { + pattern_instance_member_id( + str(reference.get("pattern_feature_id") or ""), + str(reference.get("source_feature_id") or ""), + int(reference.get("instance_index") or 0), + ) + for reference in params.get(parameter) or () + if isinstance(reference, dict) + } + + +def _next_body_graph( + node: FeaturePlanNode, + members: set[str], + has_active_body: bool, + nodes_by_id: dict[str, FeaturePlanNode], +) -> tuple[set[str], bool]: + """Project the explicit runtime body-member lifecycle without geometry. + + The capability phase cannot know whether two B-reps intersect, but it can + mirror the ownership contract used by ``ExecutionSession``. This keeps a + historical feature's successful execution separate from its continued + availability as an independently selectable body. In particular, this + must never turn an absorbed feature or pattern replay into ``session.body``. + """ + atomic_id = node.atomic_id + feature_id = node.feature_id + + if atomic_id == "boolean_bodies": + target_ids = {str(value) for value in node.params.get("target_feature_ids") or ()} + target_ids.update(_pattern_instance_member_sources(node.params, "target_pattern_instance_refs")) + tool_ids = {str(value) for value in node.params.get("tool_feature_ids") or ()} + tool_ids.update(_pattern_instance_member_sources(node.params, "tool_pattern_instance_refs")) + next_members = members - target_ids - tool_ids + next_members.add(feature_id) + if bool(node.params.get("keep_tools")): + next_members.update(tool_ids) + return next_members, True + + if atomic_id == "transform_bodies": + source_ids = _transform_member_sources(node.params) + next_members = set(members) + if not bool(node.params.get("make_copy")): + next_members.difference_update(source_ids) + next_members.add(feature_id) + elif len(node.params.get("source_feature_ids") or ()) > 1: + # Multi-source COPY outputs have no aggregate body-member owner. + # Keep each transformed source addressable by its exact origin. + next_members.update( + transform_copy_member_id(feature_id, source_id) + for source_id in source_ids + ) + else: + next_members.add(feature_id) + return next_members, True + + if atomic_id == "delete_bodies": + source_ids = {str(value) for value in node.params.get("target_feature_ids") or ()} + next_members = members - source_ids + return next_members, bool(next_members) + + if atomic_id in _PATTERN_ATOMICS: + source_ids = {str(value) for value in node.params.get("source_feature_ids") or ()} + if ( + atomic_id == "pattern_mirror" + and source_ids + and source_ids <= members + and all( + (source := nodes_by_id.get(source_id)) is not None + and source.params.get("result_mode") == "new_body" + for source_id in source_ids + ) + ): + # A mirror produces an independently addressable COPY only when + # every source is an explicit NEW body. A hole, dress-up, or + # ordinary additive feature may be the current aggregate's + # successor, not a standalone body: its mirror must remain a + # feature replay and cannot be exposed as a body member. + next_members = set(members) + next_members.update( + pattern_instance_member_id(node.feature_id, source_id, 1) + for source_id in source_ids + ) + return next_members, True + if ( + atomic_id == "pattern_circular" + and str(node.params.get("operation_mode") or "add") == "add" + and source_ids + and source_ids <= members + ): + count = int(node.params.get("pattern_count") or 0) + excluded = {int(value) for value in node.params.get("excluded_instance_indices") or ()} + next_members = set(members) + for instance in range(1, count): + if instance in excluded: + continue + next_members.update( + pattern_instance_member_id(node.feature_id, source_id, instance) + for source_id in source_ids + ) + return next_members, True + # Replay/fused pattern output has no member-level contract. It creates + # active geometry, but cannot prove which replay instance a later body + # query names. + return set(), has_active_body + + if atomic_id not in _BODY_MUTATING_ATOMICS: + return members, has_active_body + + if atomic_id in {"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "revolve_cut"} or ( + atomic_id == "extrude_from_face" and node.params.get("operation") == "cut" + ): + # Primary cuts execute per explicit member in the runtime so a later + # body query still addresses the same CADFS NEW/COPY lifecycle node. + return set(members), True + + if atomic_id in _PRIMARY_ATOMICS and "cut" not in atomic_id and node.params.get("operation") != "cut" and node.params.get("result_mode") == "new_body": + return members | {feature_id}, True + + # All remaining body-mutating executors register their output as the sole + # explicit member. This includes fused additive features, cuts and + # dress-ups, whose source-member topology no longer has an identity. + return {feature_id}, True + + def _has_explicit_axis(axis: Any) -> bool: return isinstance(axis, dict) and axis.get("origin_mm") is not None and axis.get("direction") is not None @@ -171,6 +342,13 @@ def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]: def _has_closed_region(sketch: dict[str, Any]) -> bool: """Mirror the adapter's input contract without importing the geometry kernel.""" + profile = sketch.get("profile") or {} + if profile.get("type") == "planar_imprint": + # The exact bounded-region proof happens in the OCC splitter. At this + # stage the typed contract proves only that region selection work is + # possible; an unbounded or ambiguous runtime result remains a stable + # feature execution diagnostic rather than a guessed sketch contour. + return bool(sketch.get("imprint_entities_mm") and sketch.get("imprint_selections")) regions = sketch.get("contour_regions_mm") or [] if any(len(region.get("outer") or []) >= 1 for region in regions if isinstance(region, dict)): return True @@ -254,6 +432,7 @@ class CapabilityAnalyzer: results: list[CapabilityResult] = [] completed: set[str] = set() body_available = False + body_members: set[str] = set() for node in plan: blockers: list[RuntimeDiagnostic] = [] contract = self.contracts.get(node.atomic_id) @@ -267,7 +446,9 @@ class CapabilityAnalyzer: for dependency in node.depends_on: if dependency not in completed: blockers.append(self._blocker(node.feature_id, "dependency_unavailable", "Feature dependency did not become executable", dependency=dependency)) - if node.atomic_id in _ACTIVE_BODY_REQUIRED: + if node.atomic_id in _ACTIVE_BODY_REQUIRED or ( + node.atomic_id == "extrude_from_face" and node.params.get("operation") == "cut" + ): required.append("active_body") if not body_available: blockers.append(self._blocker( @@ -362,6 +543,74 @@ class CapabilityAnalyzer: "Loft currently requires exactly one outer profile without holes", sketch_id=sketch_id, )) + for selector in _mappings(params): + if selector.get("output_role") is not None: + blockers.append(self._blocker( + node.feature_id, + "unsupported_output_role_selector_context", + "Feature output role selectors are only supported in feature.selectors", + )) + for selector_index, selector in enumerate(node.selectors): + if selector.get("output_role") is None: + continue + required.append("selector:feature_output_role") + if contract is None or contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face": + blockers.append(self._blocker( + node.feature_id, + "unsupported_output_role_selector", + "This feature contract cannot consume a feature output role selector", + selector_index=selector_index, + )) + if selector.get("kind") != "face" or not isinstance(selector.get("owner_feature_id"), str): + blockers.append(self._blocker( + node.feature_id, + "invalid_output_role_selector", + "A feature output role selector requires kind face and owner_feature_id", + selector_index=selector_index, + )) + if selector.get("source") != "runtime_snapshot": + blockers.append(self._blocker( + node.feature_id, + "invalid_output_role_selector", + "A feature output role selector requires runtime_snapshot evidence", + selector_index=selector_index, + )) + if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")): + blockers.append(self._blocker( + node.feature_id, + "invalid_output_role_selector", + "A feature output role selector cannot mix stable or geometry evidence", + selector_index=selector_index, + )) + role_source = selector.get("output_role_source") + if role_source is not None: + source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None + source_role = role_source.get("output_role") if isinstance(role_source, dict) else None + source = nodes_by_id.get(str(source_owner or "")) + source_params = (source.params if source is not None else {}) or {} + if not isinstance(source_owner, str) or not isinstance(source_role, str): + blockers.append(self._blocker( + node.feature_id, "invalid_output_role_source", + "An output role source requires owner_feature_id and output_role", + selector_index=selector_index, + )) + elif selector.get("output_role") != "shell.offset_face" or node.atomic_id != "shell": + blockers.append(self._blocker( + node.feature_id, "unsupported_output_role_source", + "Output role sources are currently supported only for shell.offset_face", + selector_index=selector_index, + )) + elif source_role not in {"extrude.start", "extrude.end"} or ( + source is None + or source.atomic_id != "extrude_add_blind" + or source_params.get("result_mode") != "new_body" + or (source_params.get("end_condition") or {}).get("type") != "blind" + ): + blockers.append(self._blocker( + node.feature_id, "unsupported_output_role_source", + "shell.offset_face requires a direct new_body blind extrusion cap source", + selector_index=selector_index, + )) if node.atomic_id == "sweep_add": path = params.get("path") segment = path.get("segment") if isinstance(path, dict) else None @@ -389,18 +638,29 @@ class CapabilityAnalyzer: )) if node.atomic_id == "boolean_bodies": target_ids = params.get("target_feature_ids") + target_instance_refs = params.get("target_pattern_instance_refs") tool_ids = params.get("tool_feature_ids") + tool_instance_refs = params.get("tool_pattern_instance_refs") 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: + target_members: set[str] = set() + tool_members: set[str] = set() + for parameter, instance_parameter, feature_ids, instance_refs, selected_members in ( + ("target_feature_ids", "target_pattern_instance_refs", target_ids, target_instance_refs, target_members), + ("tool_feature_ids", "tool_pattern_instance_refs", tool_ids, tool_instance_refs, tool_members), + ): + if feature_ids is None: + feature_ids = [] + if instance_refs is None: + instance_refs = [] + if not isinstance(feature_ids, list) or not isinstance(instance_refs, list) or not (feature_ids or instance_refs): blockers.append(self._blocker( node.feature_id, "missing_boolean_bodies", - "booleanBodies requires explicit target and tool feature ids", parameter=parameter, + "booleanBodies requires explicit target and tool body references", parameter=parameter, )) continue for feature_id in feature_ids: @@ -408,18 +668,222 @@ class CapabilityAnalyzer: if source is None: blockers.append(self._blocker( node.feature_id, "boolean_body_unavailable", - "booleanBodies source feature does not exist", feature_id=feature_id, + "booleanBodies source feature does not exist", source_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, + "booleanBodies source feature did not become executable", source_feature_id=feature_id, )) - if isinstance(target_ids, list) and isinstance(tool_ids, list) and set(target_ids) & set(tool_ids): + elif source.feature_id not in body_members: + blockers.append(self._blocker( + node.feature_id, "boolean_body_unavailable", + "Selected source no longer has an independently selectable body output", + source_feature_id=feature_id, + )) + else: + selected_members.add(str(feature_id)) + for index, reference in enumerate(instance_refs): + if not isinstance(reference, dict): + blockers.append(self._blocker( + node.feature_id, "invalid_pattern_instance_ref", + "Pattern instance body reference must be an object", parameter=instance_parameter, index=index, + )) + continue + instance = reference.get("instance_index") + if not isinstance(instance, int): + blockers.append(self._blocker( + node.feature_id, "invalid_pattern_instance_ref", + "Pattern instance body reference requires an integer instance_index", + parameter=instance_parameter, index=index, + )) + continue + member_id = pattern_instance_member_id( + str(reference.get("pattern_feature_id") or ""), + str(reference.get("source_feature_id") or ""), + instance, + ) + if member_id not in body_members: + blockers.append(self._blocker( + node.feature_id, "pattern_instance_unavailable", + "Pattern instance has no independently selectable body output", + pattern_feature_id=reference.get("pattern_feature_id"), + source_feature_id=reference.get("source_feature_id"), + instance_index=reference.get("instance_index"), + )) + continue + selected_members.add(member_id) + if target_members & tool_members: blockers.append(self._blocker( node.feature_id, "boolean_body_overlap", "booleanBodies targets and tools must be disjoint", )) + if node.atomic_id == "shell" and params.get("target_feature_id") is not None: + target_feature_id = params.get("target_feature_id") + required.append("shell:explicit_target_body") + target = nodes_by_id.get(str(target_feature_id or "")) + if not isinstance(target_feature_id, str) or not target_feature_id: + blockers.append(self._blocker( + node.feature_id, "invalid_shell_target_body", + "shell target_feature_id must name one preceding body member", + )) + elif target is None: + blockers.append(self._blocker( + node.feature_id, "shell_target_body_unavailable", + "shell target body feature does not exist", + target_feature_id=target_feature_id, + )) + elif target.feature_id not in completed: + blockers.append(self._blocker( + node.feature_id, "shell_target_body_unavailable", + "shell target body feature did not become executable", + target_feature_id=target_feature_id, + )) + elif target.feature_id not in body_members: + blockers.append(self._blocker( + node.feature_id, "shell_target_body_unavailable", + "shell target no longer has an independently selectable body output", + target_feature_id=target_feature_id, + )) + if node.atomic_id in {"transform_bodies", "delete_bodies"}: + parameter = "source_feature_ids" if node.atomic_id == "transform_bodies" else "target_feature_ids" + source_ids = params.get(parameter) + pattern_instances = params.get("pattern_instance_refs") if node.atomic_id == "transform_bodies" else [] + transform_copies = params.get("transform_copy_refs") if node.atomic_id == "transform_bodies" else [] + if source_ids is None: + source_ids = [] + if pattern_instances is None: + pattern_instances = [] + if transform_copies is None: + transform_copies = [] + if ( + not isinstance(source_ids, list) + or not isinstance(pattern_instances, list) + or not isinstance(transform_copies, list) + or not (source_ids or pattern_instances or transform_copies) + ): + blockers.append(self._blocker( + node.feature_id, "missing_body_sources", + f"{node.atomic_id} requires explicit source body feature ids", parameter=parameter, + )) + else: + for source_id in source_ids: + source = nodes_by_id.get(str(source_id)) + if source is None: + blockers.append(self._blocker( + node.feature_id, "body_source_unavailable", + "Selected body source feature does not exist", source_feature_id=source_id, + )) + elif source.feature_id not in completed: + blockers.append(self._blocker( + node.feature_id, "body_source_unavailable", + "Selected body source did not become executable", source_feature_id=source_id, + )) + elif source.feature_id not in body_members: + blockers.append(self._blocker( + node.feature_id, "body_source_unavailable", + "Selected source no longer has an independently selectable body output", + source_feature_id=source_id, + )) + for reference in pattern_instances: + if not isinstance(reference, dict): + blockers.append(self._blocker( + node.feature_id, "invalid_pattern_instance_ref", + "Pattern instance body reference must be an object", + )) + continue + pattern_id = str(reference.get("pattern_feature_id") or "") + source_id = str(reference.get("source_feature_id") or "") + instance = reference.get("instance_index") + pattern = nodes_by_id.get(pattern_id) + if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}: + blockers.append(self._blocker( + node.feature_id, "pattern_instance_unavailable", + "Pattern instance owner is not a preceding circular or mirror pattern", + pattern_feature_id=pattern_id, + )) + continue + if pattern.feature_id not in completed: + blockers.append(self._blocker( + node.feature_id, "pattern_instance_unavailable", + "Pattern instance owner did not become executable", + pattern_feature_id=pattern_id, + )) + continue + count = int(pattern.params.get("pattern_count") or 0) + excluded = {int(value) for value in pattern.params.get("excluded_instance_indices") or ()} + surviving_instance = ( + isinstance(instance, int) + and ( + (pattern.atomic_id == "pattern_mirror" and instance == 1) + or ( + pattern.atomic_id == "pattern_circular" + and 1 <= instance < count + and instance not in excluded + ) + ) + ) + if ( + not surviving_instance + or source_id not in {str(value) for value in pattern.params.get("source_feature_ids") or ()} + ): + blockers.append(self._blocker( + node.feature_id, "pattern_instance_unavailable", + "Pattern instance reference is not a surviving source copy", + pattern_feature_id=pattern_id, source_feature_id=source_id, instance_index=instance, + )) + continue + member_id = pattern_instance_member_id(pattern_id, source_id, instance) + if member_id not in body_members: + blockers.append(self._blocker( + node.feature_id, "pattern_instance_unavailable", + "Pattern instance has no independently selectable body output", + pattern_feature_id=pattern_id, source_feature_id=source_id, instance_index=instance, + )) + for reference in transform_copies: + if not isinstance(reference, dict): + blockers.append(self._blocker( + node.feature_id, "invalid_transform_copy_ref", + "Transform COPY body reference must be an object", + )) + continue + transform_id = str(reference.get("transform_feature_id") or "") + source_id = str(reference.get("source_feature_id") or "") + transform = nodes_by_id.get(transform_id) + if transform is None or transform.atomic_id != "transform_bodies": + blockers.append(self._blocker( + node.feature_id, "transform_copy_unavailable", + "Transform COPY owner is not a preceding body transform", + transform_feature_id=transform_id, + )) + continue + if transform.feature_id not in completed: + blockers.append(self._blocker( + node.feature_id, "transform_copy_unavailable", + "Transform COPY owner did not become executable", + transform_feature_id=transform_id, + )) + continue + transform_sources = transform.params.get("source_feature_ids") or [] + if ( + not bool(transform.params.get("make_copy")) + or not isinstance(transform_sources, list) + or len(transform_sources) < 2 + or source_id not in {str(value) for value in transform_sources} + ): + blockers.append(self._blocker( + node.feature_id, "transform_copy_unavailable", + "Transform COPY reference is not a source-qualified multi-body copy", + transform_feature_id=transform_id, source_feature_id=source_id, + )) + continue + member_id = transform_copy_member_id(transform_id, source_id) + if member_id not in body_members: + blockers.append(self._blocker( + node.feature_id, "transform_copy_unavailable", + "Transform COPY source has no independently selectable body output", + transform_feature_id=transform_id, source_feature_id=source_id, + )) if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES): end_condition = params.get("end_condition") or {"type": "blind"} end_type = end_condition.get("type") @@ -429,7 +893,7 @@ class CapabilityAnalyzer: # Solid.extrude_taper。双向、到面和非实体 profile 的中性面 # 语义尚无 CDSL 表达,必须保留为明确能力缺口。 if ( - node.atomic_id not in {"extrude_add_blind", "extrude_cut_blind"} + node.atomic_id not in {"extrude_add_blind", "extrude_cut_blind", "extrude_from_face"} or end_type != "blind" ): blockers.append(self._blocker( @@ -469,7 +933,7 @@ class CapabilityAnalyzer: node.feature_id, "missing_offset_distance", "Offset-from-surface requires a non-zero captured offset distance", )) - if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}: + if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"} or bool(params.get("two_sided")): reverse_condition = params.get("reverse_end_condition") or {"type": "blind"} reverse_type = reverse_condition.get("type") required.append(f"extent:reverse:{reverse_type}") @@ -501,7 +965,7 @@ 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"}: + if node.atomic_id in {"extrude_add_blind_with_hole", "extrude_from_face", "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( @@ -604,10 +1068,9 @@ class CapabilityAnalyzer: results.append(CapabilityResult(node.feature_id, node.atomic_id, status, tuple(required), tuple(blockers))) if status == "executable": completed.add(node.feature_id) - if node.atomic_id in _BODY_MUTATING_ATOMICS: - body_available = True + body_members, body_available = _next_body_graph(node, body_members, body_available, nodes_by_id) body_producers = { - "extrude_add_blind", "extrude_add_blind_with_hole", "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_from_face", "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", "bend_add", diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index c72dd4a7..1b107c86 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -103,6 +103,12 @@ "required": ["distance_mm"], "additionalProperties": false }, + "extrudeFromFaceParams": { + "type": "object", + "properties": {"distance_mm": {"$ref": "#/$defs/number"}, "operation": {"enum": ["add", "cut"]}, "reverse": {"type": "boolean"}, "reverse_distance_mm": {"$ref": "#/$defs/number"}, "two_sided": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}, "reverse_end_condition": {"$ref": "#/$defs/endCondition"}, "draft": {"$ref": "#/$defs/extrudeDraft"}, "result_mode": {"enum": ["fuse", "new_body"]}}, + "required": ["distance_mm", "operation"], + "additionalProperties": false + }, "extrudeDraft": { "type": "object", "properties": {"angle_deg": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 90}, "pull_direction": {"type": "boolean"}}, @@ -346,7 +352,7 @@ }, "shellParams": { "type": "object", - "properties": {"thickness_mm": {"type": "number", "exclusiveMinimum": 0}, "inward": {"type": "boolean"}}, + "properties": {"thickness_mm": {"type": "number", "exclusiveMinimum": 0}, "inward": {"type": "boolean"}, "target_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, "required": ["thickness_mm"], "additionalProperties": false }, @@ -355,10 +361,74 @@ "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}$"}}, + "target_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}}, "tool_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, + "tool_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}}, "keep_tools": {"type": "boolean"} }, - "required": ["operation", "target_feature_ids", "tool_feature_ids"], + "required": ["operation"], + "allOf": [ + {"anyOf": [{"required": ["target_feature_ids"]}, {"required": ["target_pattern_instance_refs"]}]}, + {"anyOf": [{"required": ["tool_feature_ids"]}, {"required": ["tool_pattern_instance_refs"]}]} + ], + "additionalProperties": false + }, + "bodyTransform": { + "type": "object", + "properties": { + "type": {"enum": ["translation", "rotation", "uniform_scale"]}, + "translation_mm": {"$ref": "#/$defs/point3"}, + "axis": {"$ref": "#/$defs/axis"}, + "angle_deg": {"type": "number"}, + "center_mm": {"$ref": "#/$defs/point3"}, + "scale_factor": {"type": "number", "exclusiveMinimum": 0} + }, + "required": ["type"], + "allOf": [ + {"if": {"properties": {"type": {"const": "translation"}}, "required": ["type"]}, "then": {"required": ["translation_mm"]}}, + {"if": {"properties": {"type": {"const": "rotation"}}, "required": ["type"]}, "then": {"required": ["axis", "angle_deg"]}}, + {"if": {"properties": {"type": {"const": "uniform_scale"}}, "required": ["type"]}, "then": {"required": ["center_mm", "scale_factor"]}} + ], + "additionalProperties": false + }, + "patternInstanceBodyRef": { + "type": "object", + "properties": { + "pattern_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, + "source_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, + "instance_index": {"type": "integer", "minimum": 1} + }, + "required": ["pattern_feature_id", "source_feature_id", "instance_index"], + "additionalProperties": false + }, + "transformCopyBodyRef": { + "type": "object", + "properties": { + "transform_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, + "source_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"} + }, + "required": ["transform_feature_id", "source_feature_id"], + "additionalProperties": false + }, + "transformBodiesParams": { + "type": "object", + "properties": { + "source_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, + "pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}}, + "transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}}, + "transform": {"$ref": "#/$defs/bodyTransform"}, + "make_copy": {"type": "boolean"} + }, + "required": ["transform", "make_copy"], + "anyOf": [{"required": ["source_feature_ids"]}, {"required": ["pattern_instance_refs"]}, {"required": ["transform_copy_refs"]}], + "additionalProperties": false + }, + "deleteBodiesParams": { + "type": "object", + "properties": { + "target_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}} + }, + "required": ["target_feature_ids"], "additionalProperties": false }, "linearPatternParams": { @@ -436,12 +506,30 @@ "required": ["hole_type", "diameter_mm", "depth_mm", "end_condition"], "additionalProperties": false }, + "featureOutputRole": { + "enum": [ + "extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", + "shell.offset_face", "shell.closing_descendant", "shell.body_face" + ] + }, + "outputRoleSource": { + "type": "object", + "properties": { + "owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_.:-]{1,160}$"}, + "output_role": {"$ref": "#/$defs/featureOutputRole"} + }, + "required": ["owner_feature_id", "output_role"], + "additionalProperties": false + }, "selectorRef": { "type": "object", "properties": { "kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]}, "stable_id": {"type": "string", "minLength": 1}, + "output_role": {"$ref": "#/$defs/featureOutputRole"}, + "output_role_source": {"$ref": "#/$defs/outputRoleSource"}, "owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_.:-]{1,160}$"}, + "owner_match_required": {"type": "boolean"}, "geometry": {"type": "object"}, "source": {"enum": ["solidworks", "inferred_from_step", "runtime_snapshot", "viewer_selection"]}, "snapshot_id": {"type": "string", "minLength": 1}, @@ -451,7 +539,8 @@ "intersection_of": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}}, "confidence": {"type": "number", "minimum": 0, "maximum": 1} }, - "required": ["kind", "stable_id", "source", "confidence"], + "required": ["kind", "source", "confidence"], + "anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}], "additionalProperties": false }, "analyticSegment": { @@ -465,8 +554,8 @@ "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"}}, - "parameters": {"type": "array", "minItems": 3, "items": {"type": "number"}}, + "points": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/point2"}}, + "parameters": {"type": "array", "minItems": 2, "items": {"type": "number"}}, "periodic": {"type": "boolean"}, "parameterization": {"enum": ["chord", "centripetal"]}, "clockwise": {"type": "boolean"}, @@ -479,7 +568,8 @@ {"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"]}} + {"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["start", "end", "points"]}}, + {"if": {"properties": {"type": {"const": "bspline"}, "points": {"maxItems": 2}}}, "then": {"required": ["start_tangent", "end_tangent", "parameters"], "properties": {"periodic": {"const": false}}}} ], "additionalProperties": false }, @@ -522,7 +612,46 @@ "required": ["type", "contours"], "additionalProperties": false }, - "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", "bend_add", "fillet", "chamfer", "shell", "boolean_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]}, + "imprintSourceEntity": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 160}, + "curve": {"$ref": "#/$defs/analyticSegment"} + }, + "required": ["id", "curve"], + "additionalProperties": false + }, + "imprintFragment": { + "type": "object", + "properties": { + "anchor_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}, + "side": {"enum": [-1, 1]}, + "intersection_index": {"type": "integer", "minimum": 0} + }, + "required": ["anchor_entity_id", "side"], + "additionalProperties": false + }, + "imprintSelection": { + "type": "object", + "properties": { + "source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}, + "face_side": {"enum": [-1, 1]}, + "fragment": {"$ref": "#/$defs/imprintFragment"} + }, + "required": ["source_entity_id", "face_side"], + "additionalProperties": false + }, + "planarImprintProfile": { + "type": "object", + "properties": { + "type": {"const": "planar_imprint"}, + "source_entities": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/imprintSourceEntity"}}, + "selections": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSelection"}} + }, + "required": ["type", "source_entities", "selections"], + "additionalProperties": false + }, + "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_from_face", "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", "bend_add", "fillet", "chamfer", "shell", "boolean_bodies", "transform_bodies", "delete_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]}, "feature": { "type": "object", "properties": { @@ -545,6 +674,7 @@ {"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": "extrude_from_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeFromFaceParams"}}}}, {"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"}}}}, @@ -565,6 +695,8 @@ {"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": "transform_bodies"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/transformBodiesParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "delete_bodies"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/deleteBodiesParams"}}}}, {"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"}}}}, @@ -573,12 +705,13 @@ {"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}} ] }, - "profile_type": {"enum": ["circle", "polygon", "analytic_contours"]}, + "profile_type": {"enum": ["circle", "polygon", "analytic_contours", "planar_imprint"]}, "profile": { "oneOf": [ {"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false}, {"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false}, - {"$ref": "#/$defs/analyticProfile"} + {"$ref": "#/$defs/analyticProfile"}, + {"$ref": "#/$defs/planarImprintProfile"} ] }, "sketch": { diff --git a/backend/engine/cdsl_engine/operation_contracts.py b/backend/engine/cdsl_engine/operation_contracts.py index 41ba9d07..a4a3de93 100644 --- a/backend/engine/cdsl_engine/operation_contracts.py +++ b/backend/engine/cdsl_engine/operation_contracts.py @@ -30,7 +30,13 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st shape = raw.get("fragment_shape") params_schema = raw.get("author_params_schema") injected_paths = raw.get("server_injected_paths") - if not isinstance(shape, dict) or not isinstance(params_schema, dict) or not isinstance(injected_paths, list): + selector_policy = raw.get("selector_policy") + if ( + not isinstance(shape, dict) + or not isinstance(params_schema, dict) + or not isinstance(injected_paths, list) + or not isinstance(selector_policy, dict) + ): raise ValueError(f"operation contract is incomplete for {atomic_id}") properties = params_schema.get("properties") required = params_schema.get("required") @@ -51,5 +57,7 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st "required_params": materialized_required, "optional_params": [name for name in properties if name not in materialized_required], "requires_sketch": shape.get("sketch") == "required", + "selector_slot": selector_policy.get("slot"), + "selector_token_kind": selector_policy.get("token_kind"), } return derived diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index 4f0f6ce5..af7be0a6 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -1,12 +1,13 @@ { "schema": "cdsl.engine.schema.v1", - "schema_version": "1.3.2", + "schema_version": "1.3.3", "cdsl_json_schema_file": "cdsl_schema.json", "maintenance_rule": "The CDSL-only runtime contract is limited to direct generic profiles and runtime.py EXECUTORS. Legacy macro profiles are importer compatibility syntax and must be lowered by cdsl_importer.legacy_profile_adapter before generic runtime validation.", "coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.", - "runtime_supported_profiles": ["circle", "polygon", "analytic_contours"], + "runtime_supported_profiles": ["circle", "polygon", "analytic_contours", "planar_imprint"], "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"},"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_from_face": {"atomic_id":"extrude_from_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"operation":{"enum":["add","cut"]},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"two_sided":{"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},"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","operation"],"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":["derived_profile_face","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"]}, @@ -62,8 +63,49 @@ }, "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},"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":[]}, + "shell": {"atomic_id":"shell","contract_version":"3.1","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"},"target_feature_id":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"}},"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":"snapshot_bound","slot":"params.target_feature_id","token_kind":"body","min_items":0,"max_items":1,"snapshot_bound":true},"semantic_preflight":["requires_active_solid","selected_faces_exist","shell_target_body_exists"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, + "boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.1","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},"target_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"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},"tool_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"}},"required":["operation"],"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":0,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[]}, + "transform_bodies": { + "atomic_id": "transform_bodies", + "contract_version": "3.3", + "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}, + "pattern_instance_refs": {"type": "array", "items": {"type": "object", "properties": {"pattern_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "instance_index": {"type": "integer", "minimum": 1}}, "required": ["pattern_feature_id", "source_feature_id", "instance_index"], "additionalProperties": false}, "minItems": 1, "maxItems": 64, "uniqueItems": true}, + "transform_copy_refs": {"type": "array", "items": {"type": "object", "properties": {"transform_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}}, "required": ["transform_feature_id", "source_feature_id"], "additionalProperties": false}, "minItems": 1, "maxItems": 64, "uniqueItems": true}, + "transform": { + "type": "object", + "properties": { + "type": {"enum": ["translation", "rotation", "uniform_scale"]}, + "translation_mm": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, + "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}, + "angle_deg": {"type": "number"}, + "center_mm": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, + "scale_factor": {"type": "number", "exclusiveMinimum": 0} + }, + "required": ["type"], + "allOf": [ + {"if": {"properties": {"type": {"const": "translation"}}, "required": ["type"]}, "then": {"required": ["translation_mm"]}}, + {"if": {"properties": {"type": {"const": "rotation"}}, "required": ["type"]}, "then": {"required": ["axis", "angle_deg"]}}, + {"if": {"properties": {"type": {"const": "uniform_scale"}}, "required": ["type"]}, "then": {"required": ["center_mm", "scale_factor"]}} + ], + "additionalProperties": false + }, + "make_copy": {"type": "boolean"} + }, + "required": ["transform", "make_copy"], + "anyOf": [{"required": ["source_feature_ids"]}, {"required": ["pattern_instance_refs"]}, {"required": ["transform_copy_refs"]}], + "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_bodies_exist", "pattern_instance_bodies_exist", "transform_copy_bodies_exist", "body_transform"], + "candidate_verifiers": [] + }, + "delete_bodies": {"atomic_id":"delete_bodies","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true}},"required":["target_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},"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":[]} @@ -81,7 +123,10 @@ "constraints": ["vertices contains at least three [u, v] points"] }, "analytic_contours": { - "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." + "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. A non-periodic two-point B-spline is permitted only with explicit, strictly increasing parameters and both endpoint tangents, giving the OCP interpolation contract sufficient curvature information; a two-point B-spline without those fields is invalid rather than a line fallback. B-splines may explicitly use chord or centripetal parameterization; CADFS closed skFitSpline uses centripetal. Imported construction B-splines remain non-executable audit geometry." + }, + "planar_imprint": { + "summary": "An exact planar arrangement derived from one sketch's original analytic entities. Each selected region retains an IMPRINT source edge, its face side, and optional INTERSECT vertex order and fragment side. The adapter splits a bounded support face with OCC and rejects non-unique or unbounded selections; it never samples curves into a polygon or substitutes an unrelated sketch contour." } } } diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index cbe258da..c6688523 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -12,8 +12,9 @@ from .build123d_adapter import Build123dGeometryAdapter from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract from .runtime_types import ( AxisSpec, BendSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec, - ThreadSpec, Vector3, + ThreadSpec, TopologyDelta, TopologyDeltaRelation, Vector3, RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry, + pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit, ) from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches @@ -21,12 +22,12 @@ from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches ALL_ATOMIC_IDS = frozenset({ "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", + "extrude_cut_through", "extrude_from_face", "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", "shell", "pattern_linear", "pattern_mirror", - "pattern_circular", "boolean_bodies", + "pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies", "thread_add", "thread_cut", "bend_add", }) @@ -86,10 +87,15 @@ class GeometryAdapter(Protocol): 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_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Any, TopologyDelta | None]: ... 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_with_topology_delta(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> tuple[Any, TopologyDelta | None]: ... def sweep_path(self, points: list[Vector3], *, start_tangent: Vector3 | None = None, end_tangent: Vector3 | None = None, parameters: list[float] | None = None) -> Any: ... + def face_normal(self, face: Any) -> Vector3: ... def extrude(self, face: Any, direction: Vector3) -> Any: ... + def extrude_with_topology_delta(self, face: Any, direction: Vector3) -> tuple[Any, TopologyDelta]: ... + def extrude_taper_with_topology_delta(self, face: Any, direction: Vector3, taper_deg: float) -> tuple[Any, TopologyDelta | None]: ... 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]: ... @@ -98,9 +104,14 @@ class GeometryAdapter(Protocol): 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 intersect_with_topology_delta(self, left: Any, right: Any) -> tuple[Any, TopologyDelta | None]: ... + def transform(self, body: Any, transform: dict[str, Any]) -> Any: ... + def transform_with_topology_delta(self, body: Any, transform: dict[str, Any]) -> tuple[Any, TopologyDelta]: ... def fuse(self, body: Any | None, solid: Any) -> Any: ... + def fuse_with_topology_delta(self, body: Any | None, solid: Any) -> tuple[Any, TopologyDelta | None]: ... def combine(self, body: Any | None, solid: Any) -> Any: ... def cut(self, body: Any, tool: Any) -> Any: ... + def cut_with_topology_delta(self, body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]: ... def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ... def thread_solid(self, spec: ThreadSpec) -> Any: ... def bend_solid(self, spec: BendSpec) -> Any: ... @@ -114,10 +125,13 @@ class GeometryAdapter(Protocol): 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 fillet_with_topology_delta(self, body: Any, radius_mm: float, edges: list[Any]) -> tuple[Any, TopologyDelta | None]: ... 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 chamfer_with_topology_delta(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> tuple[Any, TopologyDelta | None]: ... 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 shell_with_topology_delta(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> tuple[Any, TopologyDelta]: ... def export(self, body: Any, path: str) -> None: ... @@ -143,6 +157,8 @@ class ExecutionSession: *, replay_node: FeaturePlanNode | None = None, body_members: dict[str, Any] | None = None, + topology_delta: TopologyDelta | None = None, + topology_predecessors: list[TopologyRecord] | None = None, ) -> None: # #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的 # 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id": @@ -153,7 +169,11 @@ class ExecutionSession: 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)) + self.topology.replace_body_topology( + feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id), + topology_delta=topology_delta, + additional_predecessors=topology_predecessors or (), + ) else: # 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让 # 已登记的本轮成员成为下一个成员的 predecessor,进而把 pattern copy @@ -163,7 +183,10 @@ class ExecutionSession: 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.replace_body_topologies( + feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta, + additional_predecessors=topology_predecessors or (), + ) 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,), @@ -184,6 +207,12 @@ class ExecutionSession: )) return surface_id + def clear_body(self) -> None: + """Clear the active solid after an explicit deleteBodies result.""" + self.body = None + self.body_id = None + self.body_members = {} + def _record_selector_resolution(self, resolution: SelectorResolution) -> SelectorResolution: evidence = resolution.as_dict() evidence["feature_id"] = self.active_feature_id @@ -441,14 +470,31 @@ def _extent_vectors( sketch: dict[str, Any], session: ExecutionSession, ) -> list[ExtentVector]: + return _extent_vectors_from_normal( + node, faces, vector_unit(_normal_from_sketch(sketch), field_name="sketch normal"), session, + ) + + +def _extent_vectors_from_normal( + node: FeaturePlanNode, + faces: list[Any], + profile_normal: Vector3, + session: ExecutionSession, +) -> list[ExtentVector]: + """Resolve extents from an explicit profile normal. + + A derived profile can be an actual B-rep face rather than a sketch. Its + outward normal is just as authoritative as a sketch workplane normal, so + both profile sources share the same bounded extent semantics. + """ params = node.params - normal = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal") + normal = vector_unit(profile_normal, field_name="profile normal") if bool(params.get("reverse")): normal = vector_scale(normal, -1) end_condition = params.get("end_condition") or {"type": "blind"} condition = end_condition.get("type", "blind") distance = abs(float(params.get("distance_mm") or 0.0)) - if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}: + if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"} or bool(params.get("two_sided")): reverse_condition = params.get("reverse_end_condition") or {"type": "blind"} reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0)) if reverse_distance <= 0: @@ -525,6 +571,97 @@ def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any ) +def _cut_explicit_body_members(session: ExecutionSession, tool: Any) -> dict[str, Any]: + """Apply a cut to each independently owned body without erasing ownership. + + A CADFS NEW body stays independently addressable even when a later REMOVE + feature affects several active bodies. Cutting the aggregate first loses + that identity, so this path uses the equivalent per-member set difference + and drops only members that the tool removes completely. + """ + members: dict[str, Any] = {} + for feature_id, body in session.body_members.items(): + result = session.adapter.cut(body, tool) + if abs(float(result.volume)) > 1e-12: + members[feature_id] = result + return members + + +def _extruded_tool( + node: FeaturePlanNode, + faces: list[Any], + profile_normal: Vector3, + session: ExecutionSession, +) -> tuple[Any, TopologyDelta | None]: + """Build one extrude tool, retaining caps only from one exact builder result.""" + extents = _extent_vectors_from_normal(node, faces, profile_normal, 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 + topology_delta: TopologyDelta | None = None + solids: list[Any] = [] + for face in faces: + for extent in extents: + if draft is not None: + if len(faces) == 1 and len(extents) == 1: + solid, topology_delta = session.adapter.extrude_taper_with_topology_delta( + face, extent.vector, taper_deg, + ) + solids.append(solid) + else: + solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg)) + elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1: + solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector) + solids.append(solid) + 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)) + tool = None + for solid in solids: + tool = session.adapter.fuse(tool, solid) + if tool is None: + raise ValueError("extrude produced no solid") + return tool, topology_delta + + +def _apply_primary_tool( + node: FeaturePlanNode, + session: ExecutionSession, + tool: Any, + *, + cutting: bool, + topology_delta: TopologyDelta | None = None, +) -> FeatureResult: + """Apply a profile-derived tool while preserving only final-snapshot topology evidence.""" + if cutting: + if session.body is None: + raise ValueError("cut feature has no body") + members = _cut_explicit_body_members(session, tool) + if not members: + session.clear_body() + return session.result(node) + body = session.adapter.cut(session.body, tool) + topology_delta = None + elif node.params.get("result_mode") == "new_body": + body = session.adapter.combine(session.body, tool) + members = {**session.body_members, node.feature_id: tool} + else: + body = session.adapter.fuse(session.body, tool) + members = {node.feature_id: body} + # A fuse rebuilds subshape identity. Builder evidence belongs only to + # an unchanged standalone/new-body prism snapshot. + if session.body is not None: + topology_delta = None + session.register_body( + node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta, + ) + return session.result(node) + + def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, sketch: dict[str, Any] | None = None) -> FeatureResult: # 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。 @@ -545,28 +682,16 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske 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])] + topology_delta: TopologyDelta | None = None # 3. 按特征类型生成子实体: if node.atomic_id.startswith("extrude_"): # 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量, # 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在 # 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 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)) + tool, topology_delta = _extruded_tool( + node, faces, _normal_from_sketch(selected_sketch), session, + ) else: # 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。 axis = _revolve_axis(node, session) @@ -581,32 +706,26 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske # 侧实现,使三方合同一致。 if bool(node.params.get("reverse")): angle = -angle - solids = [session.adapter.revolve(face, angle, axis) for face in faces] - # 4. 将所有子实体做布尔并(fuse)合并为一个工具体(tool)。 - tool = None - for solid in solids: - tool = session.adapter.fuse(tool, solid) - if tool is None: - raise ValueError("primary feature produced no solid") - # 5. 与当前主体做布尔操作: - if "cut" in node.atomic_id: - # 切除类特征:要求已有主体,从主体上减去工具体(cut)。 - 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, body_members=members) - return session.result(node) + tool = None + for solid in (session.adapter.revolve(face, angle, axis) for face in faces): + tool = session.adapter.fuse(tool, solid) + if tool is None: + raise ValueError("revolve produced no solid") + return _apply_primary_tool( + node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta, + ) + + +def _execute_extrude_from_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 "derived profile face is unresolved") + face = resolved[0].record.value + tool, topology_delta = _extruded_tool(node, [face], session.adapter.face_normal(face), session) + return _apply_primary_tool( + node, session, tool, cutting=node.params.get("operation") == "cut", topology_delta=topology_delta, + ) def _execute_revolve_surface(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: @@ -667,22 +786,24 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: ExecutionSession) -> # 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)) + target_ids = _member_sources( + node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs", + ) + tool_ids = _member_sources( + node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs", + ) 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 "") + topology_delta: TopologyDelta | None = None if operation == "union": - result = session.adapter.fuse(target, tool) + result, topology_delta = session.adapter.fuse_with_topology_delta(target, tool) elif operation == "subtract": - result = session.adapter.cut(target, tool) + result, topology_delta = session.adapter.cut_with_topology_delta(target, tool) elif operation == "intersect": - result = session.adapter.intersect(target, tool) + result, topology_delta = session.adapter.intersect_with_topology_delta(target, tool) else: raise ValueError(f"unsupported booleanBodies operation {operation!r}") members = { @@ -693,7 +814,147 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: ExecutionSession) -> 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) + session.register_body( + node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta, + ) + return session.result(node) + + +def _pattern_instance_sources( + node: FeaturePlanNode, + session: ExecutionSession, + parameter: str = "pattern_instance_refs", +) -> list[str]: + """Resolve CDSL pattern-instance refs to their internal body-member keys.""" + resolved: list[str] = [] + for reference in node.params.get(parameter) or (): + if not isinstance(reference, dict): + raise ValueError("pattern instance reference must be an object") + pattern_id = str(reference.get("pattern_feature_id") or "") + source_id = str(reference.get("source_feature_id") or "") + instance = reference.get("instance_index") + if not pattern_id or not source_id or not isinstance(instance, int): + raise ValueError("pattern instance reference is incomplete") + pattern = session.nodes.get(pattern_id) + if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}: + raise ValueError(f"pattern instance owner is unavailable: {pattern_id}") + params = pattern.params + if source_id not in {str(value) for value in params.get("source_feature_ids") or ()}: + raise ValueError("pattern instance source is not selected by its pattern") + count = int(params.get("pattern_count") or 0) + excluded = {int(value) for value in params.get("excluded_instance_indices") or ()} + if ( + pattern.atomic_id == "pattern_mirror" and instance != 1 + ) or ( + pattern.atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded) + ): + raise ValueError("pattern instance is outside the pattern's surviving instances") + member_id = pattern_instance_member_id(pattern_id, source_id, instance) + if member_id not in session.body_members: + raise ValueError(f"pattern instance body is unavailable: {pattern_id}/{source_id}/{instance}") + if member_id not in resolved: + resolved.append(member_id) + return resolved + + +def _transform_copy_sources(node: FeaturePlanNode, session: ExecutionSession) -> list[str]: + """Resolve source-qualified outputs of preceding multi-body COPY transforms.""" + resolved: list[str] = [] + for reference in node.params.get("transform_copy_refs") or (): + if not isinstance(reference, dict): + raise ValueError("transform COPY reference must be an object") + transform_id = str(reference.get("transform_feature_id") or "") + source_id = str(reference.get("source_feature_id") or "") + if not transform_id or not source_id: + raise ValueError("transform COPY reference is incomplete") + transform = session.nodes.get(transform_id) + params = transform.params if transform is not None else {} + sources = params.get("source_feature_ids") or [] + if ( + transform is None + or transform.atomic_id != "transform_bodies" + or not bool(params.get("make_copy")) + or not isinstance(sources, list) + or len(sources) < 2 + or source_id not in {str(value) for value in sources} + ): + raise ValueError(f"transform COPY owner/source is unavailable: {transform_id}/{source_id}") + member_id = transform_copy_member_id(transform_id, source_id) + if member_id not in session.body_members: + raise ValueError(f"transform COPY body is unavailable: {transform_id}/{source_id}") + if member_id not in resolved: + resolved.append(member_id) + return resolved + + +def _member_sources( + node: FeaturePlanNode, + session: ExecutionSession, + parameter: str, + *, + pattern_instance_parameter: str | None = None, + allow_transform_copies: bool = False, +) -> list[str]: + source_ids = [str(value) for value in node.params.get(parameter) or []] + if pattern_instance_parameter is not None: + source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter)) + if allow_transform_copies: + source_ids.extend(_transform_copy_sources(node, session)) + if not source_ids: + raise ValueError(f"{node.atomic_id} requires explicit {parameter}") + missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members] + if missing: + raise ValueError(f"{node.atomic_id} source bodies are unavailable: " + ", ".join(missing)) + return source_ids + + +def _execute_transform_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: + # FeatureScript transform targets explicit bodies. Do not move the + # aggregate session body, because it may include unrelated members. + source_ids = _member_sources( + node, session, "source_feature_ids", pattern_instance_parameter="pattern_instance_refs", allow_transform_copies=True, + ) + make_copy = bool(node.params.get("make_copy")) + direct_sources = node.params.get("source_feature_ids") or [] + if make_copy and isinstance(direct_sources, list) and len(direct_sources) > 1: + # The aggregate is only an export compound. Each source transform has + # its own B-rep builder and is the only output a later COPY query may + # select. Do not attach an aggregate topology delta to source members. + members = dict(session.body_members) + members.update({ + transform_copy_member_id(node.feature_id, source_id): session.adapter.transform( + session.body_members[source_id], dict(node.params.get("transform") or {}), + ) + for source_id in source_ids + }) + session.register_body( + node.feature_id, _combine_members(session, members), body_members=members, + ) + return session.result(node) + source = _combine_members(session, {feature_id: session.body_members[feature_id] for feature_id in source_ids}) + transformed, topology_delta = session.adapter.transform_with_topology_delta( + source, dict(node.params.get("transform") or {}), + ) + members = dict(session.body_members) + if not make_copy: + for feature_id in source_ids: + members.pop(feature_id) + members[node.feature_id] = transformed + session.register_body( + node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta, + ) + return session.result(node) + + +def _execute_delete_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: + # Deletion is a body-graph operation, never a Boolean subtraction. A + # selected member can be disjoint or overlap another independent body. + source_ids = _member_sources(node, session, "target_feature_ids") + members = {feature_id: body for feature_id, body in session.body_members.items() if feature_id not in set(source_ids)} + if members: + session.register_body(node.feature_id, _combine_members(session, members), body_members=members) + else: + session.clear_body() return session.result(node) @@ -707,8 +968,14 @@ def _execute_loft_add(node: FeaturePlanNode, session: ExecutionSession) -> Featu if sketch is None: raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved") profiles.append(sketch) - solid = session.adapter.loft(profiles) - session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) + solid, topology_delta = session.adapter.loft_with_topology_delta(profiles) + body = session.adapter.fuse(session.body, solid) + # Fusing a loft into an existing body replaces its subshapes through a + # different builder. Only an initial direct loft can expose this builder's + # cap evidence for the final B-rep snapshot. + if session.body is not None: + topology_delta = None + session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) return session.result(node) @@ -776,12 +1043,18 @@ def _execute_sweep_add(node: FeaturePlanNode, session: ExecutionSession, 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( + solid, topology_delta = session.adapter.sweep_with_topology_delta( 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) + is_new_body = node.params.get("result_mode") == "new_body" + body = session.adapter.combine(session.body, solid) if is_new_body else session.adapter.fuse(session.body, solid) + # A union rebuilds topology, so the pipe-shell builder cannot prove the + # final aggregate's relations. The independent-body path retains its exact + # subshape identity and may expose evidence for the new member. + if session.body is not None and not is_new_body: + topology_delta = None + session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) return session.result(node) @@ -1077,19 +1350,33 @@ def _shell_target(node: FeaturePlanNode, session: ExecutionSession) -> tuple[Any 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] + target = members[0] + else: + 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") + target = members[member_index] + target_feature_id = node.params.get("target_feature_id") + if target_feature_id is not None: + if not isinstance(target_feature_id, str) or not target_feature_id: + raise ValueError("shell target_feature_id is invalid") + declared = session.body_members.get(target_feature_id) + if declared is None: + raise ValueError("shell target body is no longer an independently selectable member") + declared_solids = session.adapter.body_solids(declared) + if len(declared_solids) != 1: + raise ValueError("shell target body must resolve to exactly one active solid") + if not declared_solids[0].is_same(target): + raise ValueError("shell target body does not match the resolved face member") + return target, [record.value for record in records] def _replace_shell_target(session: ExecutionSession, target: Any, replacement: Any) -> Any: @@ -1118,8 +1405,13 @@ def _execute_shell(node: FeaturePlanNode, session: ExecutionSession) -> FeatureR 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) + result, topology_delta = session.adapter.shell_with_topology_delta( + 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, + topology_delta=topology_delta, + ) return session.result(node) @@ -1134,11 +1426,11 @@ def _execute_fillet(node: FeaturePlanNode, session: ExecutionSession) -> Feature if radius <= 0: raise ValueError("fillet radius_mm must be > 0") # 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。 - body = session.adapter.fillet( + body, topology_delta = session.adapter.fillet_with_topology_delta( session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))), ) # 4. 登记新主体并返回结果。 - session.register_body(node.feature_id, body, replay_node=node) + session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) return session.result(node) @@ -1164,8 +1456,9 @@ def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> Featur # 4. 解析目标边(支持相切传播),执行倒角。 edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))) diagnostics: list[RuntimeDiagnostic] = [] + topology_delta: TopologyDelta | None = None try: - body = session.adapter.chamfer(session.body, distance, distance_2, edges) + body, topology_delta = session.adapter.chamfer_with_topology_delta(session.body, distance, distance_2, edges) except ValueError as error: # 显式 surfaceEntities 可以在后续实体上留下曲面分区边界。若标准 # OCC 倒角因环域宽度不足而拒绝,只允许在该 shell 给出同轴边界证据 @@ -1185,7 +1478,7 @@ def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> Featur detail={"distance_mm": distance, "surface_count": len(session.surface_members)}, )) # 5. 登记新主体并返回结果。 - session.register_body(node.feature_id, body, replay_node=node) + session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) return session.result(node, diagnostics=diagnostics) @@ -1533,6 +1826,31 @@ 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") + source_ids = [str(value) for value in node.params.get("source_feature_ids") or ()] + if ( + source_ids + and all(source_id in session.body_members for source_id in source_ids) + and all( + (source := session.nodes.get(source_id)) is not None + and source.params.get("result_mode") == "new_body" + for source_id in source_ids + ) + ): + # Only a direct NEW body has a standalone source identity after a + # mirror. A hole, dress-up, or ordinary additive source is merely an + # aggregate successor and must use the feature-replay path below. + # Keeping this condition identical to capability preflight prevents a + # downstream COPY body query from selecting an arbitrary aggregate. + members = dict(session.body_members) + body = session.body + for source_id in source_ids: + mirrored = session.adapter.mirror(session.body_members[source_id], resolution.record.value) + members[pattern_instance_member_id(node.feature_id, source_id, 1)] = mirrored + body = session.adapter.fuse(body, mirrored) + if body is None: + raise ValueError("mirror pattern produced no body") + session.register_body(node.feature_id, body, replay_node=node, body_members=members) + return session.result(node) if node.params.get("mirror_current_body"): # CADFS SWEPT_BODY 表示被后续 feature 持续修改的同一实体。这里复制 # 当前 B-rep 再镜像并合并,不能重放其初始 additive feature,否则会 @@ -1738,6 +2056,108 @@ def _pattern_operation_node(node: FeaturePlanNode, operation_mode: str) -> Featu ) +def _circular_source_is_axisymmetric(node: FeaturePlanNode, session: ExecutionSession, axis: AxisSpec) -> bool: + """Whether rotating a direct circular extrusion creates no new geometry.""" + if node.atomic_id not in {"extrude_add_blind", "extrude_add_two_sided"}: + return False + sketch = session.sketches.get(str(node.sketch_id)) + if sketch is None: + return False + profile = sketch.get("profile") or {} + circle = profile if profile.get("type") == "circle" else None + if circle is None: + contours = profile.get("contours") or [] + segments = (contours[0] or {}).get("segments") if len(contours) == 1 else [] + circle = segments[0] if isinstance(segments, list) and len(segments) == 1 and segments[0].get("type") == "circle" else None + center = (circle or {}).get("center") + if not isinstance(center, list) or len(center) != 2: + return False + try: + plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) + except (TypeError, ValueError): + return False + if abs(vector_dot(plane.normal, axis.direction)) < 1 - 1e-7: + return False + world_center = vector_add( + plane.origin_mm, + vector_add(vector_scale(plane.x_dir, float(center[0])), vector_scale(plane.y_dir, float(center[1]))), + ) + offset = vector_subtract(world_center, axis.origin_mm) + radial = vector_subtract(offset, vector_scale(axis.direction, vector_dot(offset, axis.direction))) + return math.sqrt(vector_dot(radial, radial)) <= 1e-6 + + +def _advance_copy_topology_records( + records: list[TopologyRecord], topology_delta: TopologyDelta | None, +) -> list[TopologyRecord]: + """Carry COPY provenance through one exact adapter-history operation. + + Pattern copies are separate CDSL results even when their solids fuse into + a single final body. The temporary records here are never selector + candidates themselves. They only retain instance ownership while opaque + OCC history proves a unique subshape continuation to the final snapshot. + """ + if topology_delta is None: + return [] + advanced: list[TopologyRecord] = [] + for record in records: + values: list[Any] = [] + for relation in topology_delta.relations: + if ( + relation.kind != record.kind + or relation.event not in {"preserved", "modified"} + or not TopologyRegistry._same_topology_value(record.value, relation.source_value) + ): + continue + for value in relation.result_values: + if not any(TopologyRegistry._same_topology_value(value, known) for known in values): + values.append(value) + # A split/merge has no unique COPY owner in the present selector + # contract. Keep the executable model, but do not make a claim that a + # later COPY selector can bind one arbitrary descendant. + if len(values) != 1: + continue + advanced.append(TopologyRecord( + record_id=record.record_id, + kind=record.kind, + feature_id=record.feature_id, + body_id=record.body_id, + geometry=dict(record.geometry), + value=values[0], + owner_feature_ids=record.owners, + output_roles=record.output_roles, + output_role_sources=record.output_role_sources, + )) + return advanced + + +def _copy_snapshot_topology_delta(records: list[TopologyRecord]) -> TopologyDelta | None: + """Bridge traced final COPY handles into the one registered body snapshot.""" + if not records: + return None + return TopologyDelta( + operation="pattern_circular_copy_snapshot", + relations=tuple( + # ``record.value`` has already passed through every transform/fuse + # builder in this pattern and is an actual final-B-rep handle. The + # identity relation merely connects that evidence to the fresh + # adapter snapshot; it is not a geometric rebinding shortcut. + TopologyDeltaRelation("preserved", record.kind, record.value, (record.value,)) + for record in records + ), + ) + + +def _has_usable_pattern_body(session: ExecutionSession, body: Any | None) -> bool: + """Reject a formally valid but empty OCC boolean result before publishing it.""" + if body is None or not session.adapter.body_solids(body): + return False + try: + return abs(float(body.volume)) > 1e-12 + except (AttributeError, TypeError, ValueError): + return False + + def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult: # 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征 # 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。 @@ -1759,6 +2179,51 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, sources = session.replay_sources(params.get("source_feature_ids") or []) if not sources: raise ValueError("circular pattern source features have no replay definitions") + source_ids = [source.feature_id for source in sources] + pre_pattern_members = dict(session.body_members) + if operation_mode == "add" and all(source_id in session.body_members for source_id in source_ids): + # A pattern over explicit NEW/kept body members has a stronger contract + # than replay: each copy is an independently addressable rigid image of + # the named source member. Keep the instance keys in the body graph so + # a later CADFS COPY(BODY) transform/delete can name exactly one copy. + members = dict(session.body_members) + body = session.body + traced_copy_records: list[TopologyRecord] = [] + for instance in range(1, count): + if instance in excluded: + continue + angle_deg = sweep_angle_deg * instance / count + transform = { + "type": "rotation", + "axis": {"origin_mm": list(axis.origin_mm), "direction": list(axis.direction)}, + "angle_deg": angle_deg, + } + for source_id in source_ids: + member_id = pattern_instance_member_id(node.feature_id, source_id, instance) + owner_id = f"{node.feature_id}.c{instance}.{source_id}" + source_body = session.body_members[source_id] + copy, transform_delta = session.adapter.transform_with_topology_delta(source_body, transform) + source_records = session.adapter.topology_records( + source_body, owner_id, f"body:{node.feature_id}:copy:{instance}:{source_id}:source", + ) + copy_records = _advance_copy_topology_records(source_records, transform_delta) + members[member_id] = copy + body, fuse_delta = session.adapter.fuse_with_topology_delta(body, copy) + traced_copy_records = _advance_copy_topology_records( + [*traced_copy_records, *copy_records], fuse_delta, + ) + if _has_usable_pattern_body(session, body): + session.register_body( + node.feature_id, body, replay_node=node, body_members=members, + topology_delta=_copy_snapshot_topology_delta(traced_copy_records), + topology_predecessors=traced_copy_records, + ) + return session.result(node) + # An OCC boolean may report IsDone/valid for an empty result when a + # copied fused body contains coincident internal topology. The normal + # pattern contract can replay the source feature contribution instead; + # it is the only sound fallback because it keeps source operation, + # sketch frame, and body lifecycle semantics intact. for instance in range(1, count): if instance in excluded: continue @@ -1766,6 +2231,11 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, angle_deg = sweep_angle_deg * instance / count angle_rad = math.radians(angle_deg) for source in sources: + # 与阵列轴同心、法向平行的圆形实体拉伸在任意环形实例中均与 + # 原实体完全重合。重复执行它会把同一 B-rep 再次交给 OCC fuse, + # 后续非轴对称 source 可能因此丢失已生成的实体分支。 + if _circular_source_is_axisymmetric(source, session, axis): + continue dependency = pattern_transform_blocker(source) if dependency: raise ValueError(f"circular pattern source uses an unsupported {dependency}") @@ -1793,7 +2263,23 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, # 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) + # Replaying a fused sole-body source may be more robust than copying its + # full aggregate B-rep (for example, when a rotationally invariant base + # would otherwise be unioned with itself). If that replay still has one + # physical body, the direct source remains a proven alias of the current + # member. Preserve it for a following parts-scoped operation such as + # shell; do not extend this alias across multi-body patterns or multiple + # source members. + members = {node.feature_id: session.body} + if ( + len(source_ids) == 1 + and len(pre_pattern_members) == 1 + and source_ids[0] in pre_pattern_members + and _has_usable_pattern_body(session, session.body) + and len(session.adapter.body_solids(session.body)) == 1 + ): + members[source_ids[0]] = session.body + session.register_body(node.feature_id, session.body, replay_node=node, body_members=members) return session.result(node) @@ -1931,6 +2417,7 @@ EXECUTORS: dict[str, ExecutorFunction] = { "extrude_cut_blind": _primary_executor, "extrude_cut_two_sided": _primary_executor, "extrude_cut_through": _primary_executor, + "extrude_from_face": lambda node, session, _sketch: _execute_extrude_from_face(node, session), "loft_add": _loft_executor, "loft_add_with_cap_face": _loft_cap_face_executor, "sweep_add": _sweep_executor, @@ -1946,6 +2433,8 @@ EXECUTORS: dict[str, ExecutorFunction] = { "chamfer": _chamfer_executor, "shell": _shell_executor, "boolean_bodies": lambda node, session, sketch: _execute_boolean_bodies(node, session), + "transform_bodies": lambda node, session, sketch: _execute_transform_bodies(node, session), + "delete_bodies": lambda node, session, sketch: _execute_delete_bodies(node, session), "pattern_linear": _linear_pattern_executor, "pattern_mirror": _mirror_pattern_executor, "pattern_circular": _circular_pattern_executor, @@ -2040,5 +2529,6 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) - "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()], + "topology_deltas": list(session.topology.topology_deltas()), "selector_resolution": session.selector_resolutions, } diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index 944fa9dc..2a5d1d6c 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -15,6 +15,26 @@ from typing import Any, Iterable Vector3 = tuple[float, float, float] + +def pattern_instance_member_id(pattern_feature_id: str, source_feature_id: str, instance_index: int) -> str: + """Return the runtime-only body-member key for one proven pattern copy. + + CDSL keeps the three source fields separately, so callers never need to + manufacture this internal key. The body graph uses the same derivation in + runtime and capability preflight. + """ + return f"pattern:{pattern_feature_id}:{source_feature_id}:copy:{instance_index}" + + +def transform_copy_member_id(transform_feature_id: str, source_member_id: str) -> str: + """Return the runtime-only key for one source of a multi-body COPY. + + A multi-source ``transform_bodies`` COPY has several independently + addressable outputs. CDSL records the transform and its selected source as + separate fields; the opaque key stays internal to the body graph. + """ + return f"transform:{transform_feature_id}:{source_member_id}:copy" + # y_dir 与 x_dir / normal 点积的绝对值不超过该值时,认为 y_dir 是正交的, # 予以保留;否则视为偏斜数据,正交化并显式警告。 _Y_DIR_ORTHOGONALITY_TOL = 1e-6 @@ -558,6 +578,14 @@ class TopologyRecord: geometry: dict[str, Any] = field(default_factory=dict) value: Any = None owner_feature_ids: tuple[str, ...] = () + # Builder-produced roles describe a particular result subshape. They are + # deliberately separate from the geometric signature: equal geometry does + # not prove that two faces have the same feature-output meaning. + output_roles: tuple[str, ...] = () + # Generated roles may carry the direct feature-output role that the kernel + # operation transformed. This is semantic provenance, not a stable-id + # shortcut: the resolver still requires the exact active result snapshot. + output_role_sources: tuple[tuple[str, str, str], ...] = () @property def owners(self) -> tuple[str, ...]: @@ -575,9 +603,57 @@ class TopologyRecord: output["body_id"] = self.body_id if self.owner_feature_ids: output["owner_feature_ids"] = list(self.owner_feature_ids) + if self.output_roles: + output["output_roles"] = list(self.output_roles) + if self.output_role_sources: + output["output_role_sources"] = [ + {"output_role": role, "owner_feature_id": owner, "source_output_role": source_role} + for role, owner, source_role in self.output_role_sources + ] return output +@dataclass(frozen=True) +class TopologyDeltaRelation: + """One opaque kernel-history relationship for a topology subshape. + + Geometry adapters retain ownership of the values in this structure. They + are intentionally opaque to the runtime: a build123d/OCC adapter may use + ``TopoDS_Shape`` values while another adapter can use its native handles. + The registry only asks whether a handle is exactly the same topology item; + it never uses this contract to score nearby geometry. + """ + + event: str + kind: str + source_value: Any + result_values: tuple[Any, ...] = () + output_role: str | None = None + + def __post_init__(self) -> None: + if self.event not in {"preserved", "modified", "generated", "deleted"}: + raise ValueError(f"unsupported topology delta event {self.event!r}") + if self.kind not in {"face", "edge", "vertex"}: + raise ValueError(f"unsupported topology delta kind {self.kind!r}") + if self.event == "deleted" and (self.result_values or self.output_role is not None): + raise ValueError("deleted topology delta relations cannot have result values or an output role") + if self.output_role is not None and (not isinstance(self.output_role, str) or not self.output_role): + raise ValueError("topology delta output_role must be a non-empty string when provided") + + +@dataclass(frozen=True) +class TopologyDelta: + """Kernel-backed topology history for one adapter operation. + + ``operation`` is evidence only. The runtime transfers durable provenance + solely from a unique exact relationship, never from an operation name or a + geometric resemblance. + """ + + operation: str + relations: tuple[TopologyDeltaRelation, ...] = () + + @dataclass(frozen=True) class SelectorResolution: selector: dict[str, Any] @@ -613,6 +689,7 @@ class TopologyRegistry: self._records: list[TopologyRecord] = [] self._by_feature: dict[str, list[TopologyRecord]] = {} self._active_body_id: str | None = None + self._topology_deltas: list[dict[str, Any]] = [] # #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer # 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何 # 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的 @@ -629,6 +706,10 @@ class TopologyRegistry: def records(self) -> tuple[TopologyRecord, ...]: return tuple(self._records) + def topology_deltas(self) -> tuple[dict[str, Any], ...]: + """Return serializable evidence derived from exact adapter history.""" + return tuple(self._topology_deltas) + def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord: kind = "plane" if isinstance(context, PlaneSpec) else "axis" record = TopologyRecord( @@ -643,13 +724,18 @@ class TopologyRegistry: def replace_body_topology( self, feature_id: str, body_id: str, records: Iterable[TopologyRecord], - *, active_body_id: str | None = None, + *, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None, + additional_predecessors: Iterable[TopologyRecord] = (), ) -> None: - self.replace_body_topologies(feature_id, [(body_id, records)], active_body_id=active_body_id) + self.replace_body_topologies( + feature_id, [(body_id, records)], active_body_id=active_body_id, topology_delta=topology_delta, + additional_predecessors=additional_predecessors, + ) def replace_body_topologies( self, feature_id: str, bodies: Iterable[tuple[str, Iterable[TopologyRecord]]], - *, active_body_id: str | None = None, + *, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None, + additional_predecessors: Iterable[TopologyRecord] = (), ) -> None: """Record a fresh B-rep snapshot after a feature mutates the body. @@ -665,7 +751,7 @@ class TopologyRegistry: mutation's predecessor lookup scoped to every solid of the previous body, while each member keeps its own ``body:{feature}:{index}`` id. """ - previous = [ + active_previous = [ record for record in self._records if self._active_body_id is not None and record.body_id is not None and ( @@ -673,13 +759,32 @@ class TopologyRegistry: or record.body_id.startswith(f"{self._active_body_id}:") ) ] + # A pattern COPY can carry a chain of exact transform/boolean builder + # histories before its final aggregate snapshot is registered. These + # temporary records are valid predecessors only for that documented + # kernel-history bridge. They are deliberately excluded from geometric + # fallback matching: an equal-looking final face never proves that it + # belongs to one particular copy instance. + transient_previous = list(additional_predecessors) + previous = [*active_previous, *transient_previous] # 同一 source feature 的 pattern copy 可以产生完全相同的几何面。它们 # 必须保留为多个实例,不能在跨 body 的全局 predecessor 匹配中互相消费。 # pattern 的 Compound 成员顺序是稳定的:已有实例以同一 member index # 延续,新增实例只会出现在末尾。按该 index 限定后继匹配。 current = [(body_id, list(records)) for body_id, records in bodies] + current_records = [record for _body_id, records in current for record in records] + ( + exact_predecessors, + exact_successors, + kernel_covered_predecessors, + exact_output_roles, + exact_output_role_sources, + delta_evidence, + ) = self._exact_delta_links( + topology_delta, previous, current_records, + ) previous_member_ids = { - suffix for record in previous + suffix for record in active_previous for suffix in [str(record.body_id).rsplit(":", 1)[-1]] if suffix.isdigit() } @@ -689,13 +794,36 @@ class TopologyRegistry: for body_id, records in current: member_id = str(body_id).rsplit(":", 1)[-1] local_predecessors = [ - record for record in previous + record for record in active_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) + exact_predecessor_id = exact_predecessors.get(record.record_id) + # Member order is a useful isolation boundary for geometric + # fallback matching, especially for coincident pattern + # copies. It is not a provenance boundary when a kernel + # builder explicitly relates one source subshape to one + # result subshape: boolean/delete lifecycle can remove an + # earlier member and shift a surviving source to another + # member index. A unique OCC continuation remains exact + # evidence across that index change. + predecessor = next( + (prior for prior in previous if prior.record_id == exact_predecessor_id), + None, + ) + if predecessor is None and record.record_id not in exact_predecessors: + 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: + output_roles = set(record.output_roles) + output_roles.update(exact_output_roles.get(record.record_id, ())) + output_role_sources = set(record.output_role_sources) + output_role_sources.update(exact_output_role_sources.get(record.record_id, ())) + # A feature-output role can survive a later operation only + # through the same unique kernel continuation used for owner + # provenance. Geometry equivalence alone never carries it. + if predecessor is not None and record.record_id in exact_predecessors: + output_roles.update(predecessor.output_roles) + if predecessor is not None and record.record_id not in exact_predecessors: consumed_predecessors.add(predecessor.record_id) registered.append(TopologyRecord( record_id=record.record_id, @@ -705,16 +833,33 @@ class TopologyRegistry: geometry=dict(record.geometry), value=record.value, owner_feature_ids=owners, + output_roles=tuple(sorted(output_roles)), + output_role_sources=tuple(sorted(output_role_sources)), )) for record in registered: self.register(record) + for predecessor_id, successor_ids in exact_successors.items(): + known = self._successors.setdefault(predecessor_id, []) + for successor_id in successor_ids: + if successor_id not in known: + known.append(successor_id) + if delta_evidence is not None: + self._topology_deltas.append({ + "feature_id": feature_id, + "operation": topology_delta.operation, + "relations": delta_evidence, + }) # #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续 # selector 的 stable_id 引用解析到 active body 内的新形态。多条演化 # 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段 # 也有圆角过渡带的新边,前者的端点与原边重合、漂移更小);漂移并列 # (如竖直边被完整消费成两条等距直段)属于本质歧义,保守不登记。 for prior in previous: - if prior.record_id in consumed_predecessors: + if ( + prior.record_id in consumed_predecessors + or prior.record_id in exact_successors + or prior.record_id in kernel_covered_predecessors + ): continue candidates = sorted( ( @@ -730,6 +875,126 @@ class TopologyRegistry: self._successors[prior.record_id] = [best[1]] self._active_body_id = active_body_id or body_id + @staticmethod + def _same_topology_value(left: Any, right: Any) -> bool: + """Compare adapter handles only through their exact topology identity.""" + left_value = getattr(left, "wrapped", left) + right_value = getattr(right, "wrapped", right) + if left_value is right_value: + return True + for candidate, other in ((left_value, right_value), (right_value, left_value)): + for method_name in ("IsSame", "is_same"): + method = getattr(candidate, method_name, None) + if callable(method): + try: + return bool(method(other)) + except (AttributeError, TypeError, ValueError): + continue + return False + + @classmethod + def _exact_delta_links( + cls, + topology_delta: TopologyDelta | None, + previous: list[TopologyRecord], + current: list[TopologyRecord], + ) -> tuple[ + dict[str, str], + dict[str, list[str]], + set[str], + dict[str, tuple[str, ...]], + dict[str, tuple[tuple[str, str, str], ...]], + list[dict[str, Any]] | None, + ]: + """Bind opaque kernel history to snapshots without geometric guessing. + + Ownership transfer is intentionally limited to a single source item and + a single output item. Split/merge history remains useful evidence, but + has no unique owner continuation until a later operation-specific + contract can express it. + """ + if topology_delta is None: + return {}, {}, set(), {}, {}, None + candidate_sources: dict[str, set[str]] = {} + kernel_covered_predecessors: set[str] = set() + output_roles: dict[str, set[str]] = {} + output_role_sources: dict[str, set[tuple[str, str, str]]] = {} + relation_links: list[tuple[str, str] | None] = [] + evidence: list[dict[str, Any]] = [] + for relation in topology_delta.relations: + sources = [ + record for record in previous + if record.kind == relation.kind and cls._same_topology_value(record.value, relation.source_value) + ] + outputs = [ + record for record in current + if record.kind == relation.kind + and any(cls._same_topology_value(record.value, value) for value in relation.result_values) + ] + item = { + "event": relation.event, + "kind": relation.kind, + "source_record_ids": [record.record_id for record in sources], + "result_record_ids": [record.record_id for record in outputs], + "proof": "kernel_history", + } + if relation.output_role is not None: + item["output_role"] = relation.output_role + role_is_unique = len(relation.result_values) == 1 and len(outputs) == 1 + item["output_role_status"] = ( + "unique_result_snapshot" if role_is_unique else "non_unique_or_missing_result_snapshot" + ) + if role_is_unique: + output_roles.setdefault(outputs[0].record_id, set()).add(relation.output_role) + for source in sources: + for source_role in source.output_roles: + for source_owner in source.owners: + output_role_sources.setdefault(outputs[0].record_id, set()).add( + (relation.output_role, source_owner, source_role) + ) + if len(sources) == 1: + kernel_covered_predecessors.add(sources[0].record_id) + can_transfer = ( + relation.event in {"preserved", "modified"} + and len(relation.result_values) == 1 + and len(sources) == 1 + and len(outputs) == 1 + ) + if can_transfer: + source_id, result_id = sources[0].record_id, outputs[0].record_id + candidate_sources.setdefault(result_id, set()).add(source_id) + relation_links.append((source_id, result_id)) + else: + relation_links.append(None) + evidence.append(item) + predecessors = { + result_id: next(iter(source_ids)) + for result_id, source_ids in candidate_sources.items() + if len(source_ids) == 1 + } + successors: dict[str, list[str]] = {} + for result_id, source_id in predecessors.items(): + successors.setdefault(source_id, []).append(result_id) + for item, relation, link in zip(evidence, topology_delta.relations, relation_links): + if link is not None: + _source_id, result_id = link + item["status"] = ( + "unique_exact_continuation" + if len(candidate_sources[result_id]) == 1 else "ambiguous_exact_continuation" + ) + elif relation.event in {"preserved", "modified"}: + item["status"] = "non_unique_or_incomplete" + else: + item["status"] = "recorded_without_owner_transfer" + return ( + predecessors, + successors, + kernel_covered_predecessors, + {record_id: tuple(sorted(roles)) for record_id, roles in output_roles.items()}, + {record_id: tuple(sorted(sources)) for record_id, sources in output_role_sources.items()}, + evidence, + ) + @staticmethod def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: try: @@ -871,7 +1136,7 @@ class TopologyRegistry: if not selector_geometry: return 0.0 scores: list[float] = [] - for key in ("center_mm", "normal", "origin_mm", "direction", "plane_normal", "start_mm", "end_mm"): + for key in ("center_mm", "circle_center_mm", "normal", "origin_mm", "direction", "plane_normal", "start_mm", "end_mm"): if key in selector_geometry: score = cls._vector_score(selector_geometry[key], record_geometry.get(key)) if score is None: @@ -901,6 +1166,12 @@ class TopologyRegistry: except (TypeError, ValueError): return None scores.append(max(0.0, 1.0 - delta / 1e-4)) + if "radius_mm" in selector_geometry: + try: + delta = abs(float(selector_geometry["radius_mm"]) - float(record_geometry.get("radius_mm"))) + except (TypeError, ValueError): + return None + scores.append(max(0.0, 1.0 - delta / 1e-4)) if "area_mm2" in selector_geometry: try: expected_area = float(selector_geometry["area_mm2"]) @@ -977,6 +1248,101 @@ class TopologyRegistry: detail={"minimum_score": minimum_score}, ), ) + output_role = str(selector.get("output_role") or "").strip() + if output_role: + if not owner: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_owner_required", + message="A feature output role selector requires owner_feature_id", + detail={"output_role": output_role}, + ), + ) + if active_body_id is None: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_active_body_required", + message="A feature output role selector requires an active body snapshot", + detail={"output_role": output_role}, + ), + ) + if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")): + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_mixed_evidence", + message="A feature output role selector cannot mix stable or geometry evidence", + detail={"output_role": output_role}, + ), + ) + role_candidates = [record for record in candidates if output_role in record.output_roles] + role_source = selector.get("output_role_source") + if role_source is not None: + source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None + source_role = role_source.get("output_role") if isinstance(role_source, dict) else None + if not isinstance(source_owner, str) or not isinstance(source_role, str): + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_source_invalid", + message="An output role selector source requires owner_feature_id and output_role", + ), + ) + if output_role != "shell.offset_face" or source_role not in {"extrude.start", "extrude.end"}: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_source_unsupported", + message="Output role sources are currently supported only for shell.offset_face from an extrusion cap", + ), + ) + role_candidates = [ + record for record in role_candidates + if (output_role, source_owner, source_role) in record.output_role_sources + ] + public_candidates = tuple( + {"score": 1.0, **record.public_dict()} for record in role_candidates + ) + if len(role_candidates) == 1: + return SelectorResolution( + selector=selector, + status="resolved", + record=role_candidates[0], + candidates=public_candidates, + ) + if len(role_candidates) > 1: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=public_candidates, + diagnostic=RuntimeDiagnostic( + code="selector_output_role_ambiguous", + message="More than one active topology record has the requested output role", + detail={"output_role": output_role, "candidate_count": len(role_candidates)}, + ), + ) + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_not_found", + message="No active topology record has the requested output role", + detail={"output_role": output_role, "candidate_count": 0}, + ), + ) stable_id = str(selector.get("stable_id") or "").strip() if stable_id: # #8 selector 持久性:stable_id 是跨 body 演化的持久标识符,精确 @@ -1041,6 +1407,17 @@ class TopologyRegistry: record=record, candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},), ) + if not geometry: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_stable_id_inactive", + message="The stable selector record is not active and has no geometry signature for rebinding", + detail={"stable_id": stable_id}, + ), + ) if len(exact) > 1: return SelectorResolution( selector=selector, diff --git a/backend/engine/cdsl_engine/semantic_validation.py b/backend/engine/cdsl_engine/semantic_validation.py index c52af824..6b300977 100644 --- a/backend/engine/cdsl_engine/semantic_validation.py +++ b/backend/engine/cdsl_engine/semantic_validation.py @@ -15,10 +15,23 @@ from typing import Any from jsonschema import Draft202012Validator +from .operation_contracts import materialized_feature_contracts + _ID = re.compile(r"^[A-Za-z0-9_-]{1,80}$") +def _mappings(value: Any): + """Yield nested mapping values without treating selector-like data as text.""" + if isinstance(value, dict): + yield value + for child in value.values(): + yield from _mappings(child) + elif isinstance(value, list): + for child in value: + yield from _mappings(child) + + @lru_cache(maxsize=1) def _schema() -> dict[str, Any]: path = Path(__file__).with_name("cdsl_schema.json") @@ -32,6 +45,12 @@ def _validator() -> Draft202012Validator: return Draft202012Validator(_schema()) +@lru_cache(maxsize=1) +def _operation_contracts() -> dict[str, dict[str, Any]]: + path = Path(__file__).with_name("profile_schema.json") + return materialized_feature_contracts(json.loads(path.read_text(encoding="utf-8"))) + + def _schema_error(document: dict[str, Any]) -> str | None: validator = _validator() errors = sorted(validator.iter_errors(document), key=lambda error: (list(error.absolute_path), error.message)) @@ -67,10 +86,29 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: sketch_ids = {str(sketch.get("id") or "") for sketch in sketches} if len(sketch_ids) != len(sketches) or not all(_ID.fullmatch(item) for item in sketch_ids): raise ValueError("Sketch ids must be unique valid CDSL identifiers") + for sketch in sketches: + profile = sketch.get("profile") or {} + if profile.get("type") != "planar_imprint": + continue + entities = profile.get("source_entities") or [] + entity_ids = [str(entity.get("id") or "") for entity in entities if isinstance(entity, dict)] + if len(entity_ids) != len(entities) or len(set(entity_ids)) != len(entity_ids) or not all(entity_ids): + raise ValueError(f"Sketch {sketch.get('id')} planar_imprint source entity ids must be unique") + known = set(entity_ids) + for index, selection in enumerate(profile.get("selections") or []): + source = str(selection.get("source_entity_id") or "") + fragment = selection.get("fragment") or {} + anchor = str(fragment.get("anchor_entity_id") or "") if fragment else "" + if source not in known: + raise ValueError(f"Sketch {sketch.get('id')} planar_imprint selection {index} has an unknown source entity") + if fragment and (anchor not in known or anchor == source): + raise ValueError(f"Sketch {sketch.get('id')} planar_imprint selection {index} has an invalid fragment anchor") feature_ids: set[str] = set() + preceding_features: dict[str, dict[str, Any]] = {} deferred: list[str] = [] unresolved: list[dict[str, Any]] = [] + contracts = _operation_contracts() for feature in cdsl.get("features") or []: fid = str(feature.get("id") or "") if not _ID.fullmatch(fid) or fid in feature_ids: @@ -85,14 +123,166 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: raise ValueError(f"Feature {fid} must declare execution_status") if feature.get("execution_status") == "deferred": deferred.append(fid) - for index, selector in enumerate(feature.get("selectors") or []): + contract = contracts.get(str(feature.get("atomic_id") or "")) or {} + feature_selectors = feature.get("selectors") or [] + output_role_selector_ids = {id(selector) for selector in feature_selectors if isinstance(selector, dict)} + for index, selector in enumerate(feature_selectors): owner = selector.get("owner_feature_id") 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 selector.get("output_role") is not None: + if contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face": + raise ValueError(f"Feature {fid} selector {index} cannot consume a feature output role") + if selector.get("kind") != "face" or not owner or owner not in feature_ids: + raise ValueError(f"Feature {fid} selector {index} output role requires a preceding face owner_feature_id") + if selector.get("source") != "runtime_snapshot": + raise ValueError(f"Feature {fid} selector {index} output role must use runtime_snapshot evidence") + if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")): + raise ValueError(f"Feature {fid} selector {index} output role cannot mix stable or geometry evidence") + role_source = selector.get("output_role_source") + if role_source is not None: + source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None + source_role = role_source.get("output_role") if isinstance(role_source, dict) else None + source_feature = preceding_features.get(str(source_owner or "")) + source_params = (source_feature or {}).get("params") or {} + if not source_owner or source_owner not in feature_ids: + raise ValueError(f"Feature {fid} selector {index} output role source requires a preceding owner_feature_id") + if selector.get("output_role") != "shell.offset_face" or feature.get("atomic_id") != "shell": + raise ValueError( + f"Feature {fid} selector {index} output role source is only supported for shell.offset_face" + ) + if source_role not in {"extrude.start", "extrude.end"}: + raise ValueError( + f"Feature {fid} selector {index} shell.offset_face source must be an extrude cap role" + ) + if ( + source_feature is None + or source_feature.get("atomic_id") != "extrude_add_blind" + or source_params.get("result_mode") != "new_body" + or (source_params.get("end_condition") or {}).get("type") != "blind" + ): + raise ValueError( + f"Feature {fid} selector {index} shell.offset_face source requires a preceding direct new_body blind extrusion" + ) + elif selector.get("output_role_source") is not None: + raise ValueError(f"Feature {fid} selector {index} output role source requires output_role") + for selector in _mappings(feature): + # ``output_role_source`` is provenance metadata nested inside a + # feature selector, not a selector on its own. + if ( + selector.get("output_role") is not None + and selector.get("kind") is not None + and id(selector) not in output_role_selector_ids + ): + raise ValueError(f"Feature {fid} output role selectors are only supported in feature.selectors") + if feature.get("atomic_id") == "shell": + target_feature_id = (feature.get("params") or {}).get("target_feature_id") + if target_feature_id is not None and target_feature_id not in feature_ids: + raise ValueError(f"Feature {fid} shell target_feature_id requires a preceding body feature") + if feature.get("atomic_id") == "transform_bodies": + params = feature.get("params") or {} + references = params.get("pattern_instance_refs") or [] + for index, reference in enumerate(references): + if not isinstance(reference, dict): + raise ValueError(f"Feature {fid} pattern instance reference {index} is invalid") + pattern_id = str(reference.get("pattern_feature_id") or "") + source_id = str(reference.get("source_feature_id") or "") + pattern = preceding_features.get(pattern_id) + pattern_atomic_id = pattern.get("atomic_id") if pattern is not None else None + if pattern is None or pattern_atomic_id not in {"pattern_circular", "pattern_mirror"}: + raise ValueError(f"Feature {fid} pattern instance reference {index} has a forward or unsupported pattern owner") + if source_id not in {str(value) for value in (pattern.get("params") or {}).get("source_feature_ids") or ()}: + raise ValueError(f"Feature {fid} pattern instance reference {index} names a source outside its pattern") + source = preceding_features.get(source_id) + if ( + pattern_atomic_id == "pattern_mirror" + and (source is None or (source.get("params") or {}).get("result_mode") != "new_body") + ): + raise ValueError( + f"Feature {fid} pattern instance reference {index} requires a preceding new_body mirror source" + ) + instance = reference.get("instance_index") + count = int((pattern.get("params") or {}).get("pattern_count") or 0) + excluded = {int(value) for value in (pattern.get("params") or {}).get("excluded_instance_indices") or ()} + if not isinstance(instance, int) or ( + pattern_atomic_id == "pattern_mirror" and instance != 1 + ) or ( + pattern_atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded) + ): + raise ValueError(f"Feature {fid} pattern instance reference {index} is not a surviving copy") + copy_references = params.get("transform_copy_refs") or [] + for index, reference in enumerate(copy_references): + if not isinstance(reference, dict): + raise ValueError(f"Feature {fid} transform COPY reference {index} is invalid") + transform_id = str(reference.get("transform_feature_id") or "") + source_id = str(reference.get("source_feature_id") or "") + transform = preceding_features.get(transform_id) + transform_params = (transform or {}).get("params") or {} + direct_sources = transform_params.get("source_feature_ids") or [] + if ( + transform is None + or transform.get("atomic_id") != "transform_bodies" + or not bool(transform_params.get("make_copy")) + or not isinstance(direct_sources, list) + or len(direct_sources) < 2 + ): + raise ValueError( + f"Feature {fid} transform COPY reference {index} requires a preceding multi-source make_copy transform" + ) + if source_id not in {str(value) for value in direct_sources}: + raise ValueError( + f"Feature {fid} transform COPY reference {index} names a source outside its transform" + ) + for source_id in params.get("source_feature_ids") or []: + source = preceding_features.get(str(source_id)) + source_params = (source or {}).get("params") or {} + source_direct_ids = source_params.get("source_feature_ids") or [] + if ( + source is not None + and source.get("atomic_id") == "transform_bodies" + and bool(source_params.get("make_copy")) + and isinstance(source_direct_ids, list) + and len(source_direct_ids) > 1 + ): + raise ValueError( + f"Feature {fid} must use transform_copy_refs to select a source of multi-source COPY {source_id}" + ) + if feature.get("atomic_id") == "boolean_bodies": + params = feature.get("params") or {} + for parameter in ("target_pattern_instance_refs", "tool_pattern_instance_refs"): + for index, reference in enumerate(params.get(parameter) or []): + if not isinstance(reference, dict): + raise ValueError(f"Feature {fid} {parameter} entry {index} is invalid") + pattern_id = str(reference.get("pattern_feature_id") or "") + source_id = str(reference.get("source_feature_id") or "") + pattern = preceding_features.get(pattern_id) + pattern_atomic_id = pattern.get("atomic_id") if pattern is not None else None + if pattern is None or pattern_atomic_id not in {"pattern_circular", "pattern_mirror"}: + raise ValueError(f"Feature {fid} {parameter} entry {index} has a forward or unsupported pattern owner") + if source_id not in {str(value) for value in (pattern.get("params") or {}).get("source_feature_ids") or ()}: + raise ValueError(f"Feature {fid} {parameter} entry {index} names a source outside its pattern") + source = preceding_features.get(source_id) + if ( + pattern_atomic_id == "pattern_mirror" + and (source is None or (source.get("params") or {}).get("result_mode") != "new_body") + ): + raise ValueError( + f"Feature {fid} {parameter} entry {index} requires a preceding new_body mirror source" + ) + instance = reference.get("instance_index") + count = int((pattern.get("params") or {}).get("pattern_count") or 0) + excluded = {int(value) for value in (pattern.get("params") or {}).get("excluded_instance_indices") or []} + if not isinstance(instance, int) or ( + pattern_atomic_id == "pattern_mirror" and instance != 1 + ) or ( + pattern_atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded) + ): + raise ValueError(f"Feature {fid} {parameter} entry {index} is not a surviving copy") if feature.get("unresolved"): unresolved.append({"feature_id": fid, "reasons": list(feature["unresolved"])}) feature_ids.add(fid) + preceding_features[fid] = feature return { "schema_version": version, diff --git a/backend/engine/cdsl_engine/sketch_solver.py b/backend/engine/cdsl_engine/sketch_solver.py index dca4f0e3..b21856c1 100644 --- a/backend/engine/cdsl_engine/sketch_solver.py +++ b/backend/engine/cdsl_engine/sketch_solver.py @@ -82,6 +82,12 @@ def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]: normal = workplane.get("normal") or [0, 0, 1] for edge in contours: output = deepcopy(edge) + if edge["type"] == "circle": + output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1]) + output["x_dir_mm"] = _to_3d_vector(workplane, 1.0, 0.0) + output["normal"] = list(normal) + transformed.append(output) + continue output["start_mm"] = _to_3d(workplane, edge["start_mm"][0], edge["start_mm"][1]) output["end_mm"] = _to_3d(workplane, edge["end_mm"][0], edge["end_mm"][1]) if edge["type"] == "arc": @@ -245,10 +251,19 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]: 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") + if len(points) < 2: + raise ValueError("analytic_contours: bspline needs at least 2 interpolation points") converted = [_point(point) for point in points] periodic = bool(segment.get("periodic")) + start_tangent = segment.get("start_tangent") + end_tangent = segment.get("end_tangent") + if len(converted) == 2: + if periodic: + raise ValueError("analytic_contours: two-point bspline cannot be periodic") + if _distance(converted[0], converted[1]) <= _TOLERANCE_MM: + raise ValueError("analytic_contours: two-point bspline endpoints must be distinct") + if start_tangent is None or end_tangent is None: + raise ValueError("analytic_contours: two-point bspline requires both endpoint tangents") if periodic: if _distance(converted[0], converted[-1]) > _TOLERANCE_MM: raise ValueError("analytic_contours: periodic bspline endpoints do not meet") @@ -271,6 +286,8 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]: 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") + if len(converted) == 2 and parameters is None: + raise ValueError("analytic_contours: two-point bspline requires explicit parameters") output: _Ctx = { "type": "bspline", "start_mm": converted[0], @@ -280,8 +297,6 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]: **({"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: @@ -293,13 +308,68 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]: raise ValueError(f"analytic_contours: unsupported segment type {kind!r}") +def _imprint_segment_edges(segment: _Ctx) -> list[_Ctx]: + """Convert an IMPRINT source while retaining its FeatureScript edge identity. + + Closed contour assembly intentionally divides circles into four arcs so + that its loops have explicit vertices. An IMPRINT source id, however, + denotes one logical FeatureScript edge. Splitting that circle before the + OCC arrangement loses the one-to-one source/history mapping and makes a + valid fragment appear to be an ambiguous multi-edge source. + """ + if segment.get("type") != "circle": + return _segment_edges(segment) + center = segment.get("center") or [0.0, 0.0] + radius = float(segment.get("radius_mm") or 0.0) + if radius <= 0: + raise ValueError("planar_imprint: circle radius_mm must be > 0") + output: _Ctx = { + "type": "circle", + "center_mm": [float(center[0]), float(center[1]), 0.0], + "radius_mm": radius, + } + if "clockwise" in segment: + output["clockwise"] = bool(segment["clockwise"]) + return [output] + + def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]: points: list[tuple[float, float]] = [] for edge in edges: start = edge["start_mm"] points.append((float(start[0]), float(start[1]))) if edge.get("type") == "bspline": - points.extend((float(point[0]), float(point[1])) for point in edge["points_mm"][1:-1]) + spline_points = edge["points_mm"] + if len(spline_points) == 2: + # GeomAPI_Interpolate with exactly two endpoint derivatives is + # the cubic Hermite curve over the explicit parameter span. + # There are no intermediate interpolation points to sample, so + # use its analytical points rather than incorrectly treating + # an otherwise valid curved loop as zero-area. + parameters = edge.get("parameters") or [] + start_tangent = edge.get("start_tangent_mm") + end_tangent = edge.get("end_tangent_mm") + if len(parameters) != 2 or start_tangent is None or end_tangent is None: + raise ValueError("analytic_contours: two-point bspline sampling is unresolved") + parameter_span = float(parameters[1]) - float(parameters[0]) + if parameter_span <= _TOLERANCE_MM: + raise ValueError("analytic_contours: two-point bspline parameter span is degenerate") + start_point, end_point = spline_points + for fraction in (0.25, 0.5, 0.75): + squared = fraction * fraction + cubed = squared * fraction + h00 = 2.0 * cubed - 3.0 * squared + 1.0 + h10 = cubed - 2.0 * squared + fraction + h01 = -2.0 * cubed + 3.0 * squared + h11 = cubed - squared + points.append(( + h00 * float(start_point[0]) + h10 * parameter_span * float(start_tangent[0]) + + h01 * float(end_point[0]) + h11 * parameter_span * float(end_tangent[0]), + h00 * float(start_point[1]) + h10 * parameter_span * float(start_tangent[1]) + + h01 * float(end_point[1]) + h11 * parameter_span * float(end_tangent[1]), + )) + continue + points.extend((float(point[0]), float(point[1])) for point in spline_points[1:-1]) continue if edge.get("type") == "ellipse": center, axis = edge["center_mm"], edge["major_axis_mm"] @@ -439,7 +509,38 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[ return entities, [] -CORE_SHAPE_GENERATORS: dict[str, Any] = {"circle": _gen_circle, "polygon": _gen_polygon, "analytic_contours": _gen_analytic_contours} +def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """Prepare source curves for an exact OCC planar-arrangement split. + + Unlike ``analytic_contours``, these curves are intentionally not joined + into a guessed outer wire. FeatureScript's IMPRINT query identifies + regions in the arrangement of all source curves, including open curves + and split fragments, and the geometry adapter chooses those actual B-rep + regions after the split. + """ + source_entities: list[_Ctx] = [] + for source in profile.get("source_entities") or []: + source_id = str(source.get("id") or "") + curve = source.get("curve") or {} + if not source_id: + raise ValueError("planar_imprint: source entity id is missing") + edges = _imprint_segment_edges(curve) + if not edges: + raise ValueError(f"planar_imprint: source entity {source_id!r} has no curve") + source_entities.append({"id": source_id, "edges": edges}) + if len(source_entities) < 2: + raise ValueError("planar_imprint: at least two source entities are required") + meta["_imprint_entities"] = source_entities + meta["_imprint_selections"] = deepcopy(profile.get("selections") or []) + return [], [] + + +CORE_SHAPE_GENERATORS: dict[str, Any] = { + "circle": _gen_circle, + "polygon": _gen_polygon, + "analytic_contours": _gen_analytic_contours, + "planar_imprint": _gen_planar_imprint, +} SHAPE_GENERATORS = CORE_SHAPE_GENERATORS SHAPE_CAPABILITIES: dict[str, _Ctx] = { "circle": {"detectable": True, "arity": "circle", "description": "single circular contour"}, @@ -463,7 +564,10 @@ def resolve_profile(sketch: _Ctx) -> _Ctx: generator = SHAPE_GENERATORS.get(profile.get("type")) if generator is None: raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {profile.get('type')!r}") - meta: _Ctx = {"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": [], "_has_open_contour": False} + meta: _Ctx = { + "id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": [], "_has_open_contour": False, + "_imprint_entities": [], "_imprint_selections": [], + } entities, contour = generator(profile, meta) output = deepcopy(sketch) original_circles = [entity for entity in sketch.get("entities") or [] if entity.get("type") == "circle" and not entity.get("construction")] @@ -480,6 +584,15 @@ def resolve_profile(sketch: _Ctx) -> _Ctx: ] if meta["_has_open_contour"]: output["_open_contour"] = True + if meta["_imprint_entities"]: + output["imprint_entities_mm"] = [ + { + "id": entity["id"], + "edges": _transform_contours(entity["edges"], workplane) if workplane else entity["edges"], + } + for entity in meta["_imprint_entities"] + ] + output["imprint_selections"] = meta["_imprint_selections"] return output diff --git a/backend/tests/test_engine_runtime_foundation.py b/backend/tests/test_engine_runtime_foundation.py index 8a76a50b..250d9118 100644 --- a/backend/tests/test_engine_runtime_foundation.py +++ b/backend/tests/test_engine_runtime_foundation.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import importlib.util import json import math import sys @@ -15,7 +16,10 @@ sys.path.insert(0, str(ROOT / "backend" / "engine")) from cdsl_engine.batch_rebuild import _failure_category, _verification_classification, batch_analyze # noqa: E402 from cdsl_engine.capabilities import CapabilityAnalyzer # noqa: E402 -from cdsl_engine.runtime_types import HoleSpec, PlaneSpec, TopologyRecord, TopologyRegistry # noqa: E402 +from cdsl_engine.runtime_types import ( # noqa: E402 + BendSpec, HoleSpec, PlaneSpec, TopologyDelta, TopologyDeltaRelation, + TopologyRecord, TopologyRegistry, +) from cdsl_engine.sketch_solver import SHAPE_GENERATORS, resolve_all_sketches # noqa: E402 @@ -30,6 +34,35 @@ def _rectangle(minimum: list[float], maximum: list[float]) -> dict: ]} +def _two_body_boolean_cdsl(operation: str, left: dict, right: dict) -> dict: + return { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": f"boolean-{operation}", "meta": {"unit": "mm"}, + "geometry": {"sketches": [ + {"id": "left", "workplane": _workplane(), "profile": left}, + {"id": "right", "workplane": _workplane(), "profile": right}, + ]}, + "features": [ + { + "id": "left_body", "atomic_id": "extrude_add_blind", "depends_on": [], + "sketch_id": "left", "params": {"distance_mm": 4, "result_mode": "new_body"}, + }, + { + "id": "right_body", "atomic_id": "extrude_add_blind", "depends_on": ["left_body"], + "sketch_id": "right", "params": {"distance_mm": 4, "result_mode": "new_body"}, + }, + { + "id": "boolean", "atomic_id": "boolean_bodies", + "depends_on": ["left_body", "right_body"], + "params": { + "operation": operation, "target_feature_ids": ["left_body"], + "tool_feature_ids": ["right_body"], "keep_tools": False, + }, + }, + ], + } + + def _selector_digest(selectors: list[str]) -> str: return hashlib.sha256("\n".join(selectors).encode("utf-8")).hexdigest() @@ -65,6 +98,22 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertTrue(getattr(GeometryAdapter, "_is_protocol", False)) self.assertIn("export", GeometryAdapter.__dict__) + def test_missing_bend_generator_does_not_block_engine_import(self) -> None: + if importlib.util.find_spec("cdsl_engine.parametric_bend") is not None: + self.skipTest("bend generator is installed") + + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + from cdsl_engine.runtime import rebuild_cdsl + + self.assertTrue(callable(rebuild_cdsl)) + spec = BendSpec.from_feature({ + "thickness_mm": 1, + "width_mm": 10, + "chain": [{"leg_mm": 10}], + }) + with self.assertRaisesRegex(RuntimeError, "bend_add requires cdsl_engine.parametric_bend.build_bend_solid"): + Build123dGeometryAdapter.bend_solid(spec) + def test_hole_spec_normalizes_wizard_subtypes_without_occ_dependencies(self) -> None: spec = HoleSpec.from_feature("hole_wizard", { "diameter_mm": 2, "depth_mm": 6, "end_condition": {"type": "blind", "solidworks_code": 0}, @@ -81,7 +130,8 @@ class EngineRuntimeFoundationTests(unittest.TestCase): def test_analytic_contours_create_a_region_with_hole(self) -> None: cdsl = { - "schema": "cad.cdsl.llm.v1", + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "two-point-spline", "meta": {"unit": "mm"}, "geometry": {"sketches": [{ "id": "sketch", "workplane": _workplane(), "profile": {"type": "analytic_contours", "contours": [ @@ -96,7 +146,10 @@ class EngineRuntimeFoundationTests(unittest.TestCase): ]}, ]}, }]}, - "features": [], + "features": [{ + "id": "two_point_add", "atomic_id": "extrude_add_blind", "depends_on": [], + "sketch_id": "two-point-spline", "params": {"distance_mm": 1}, + }], } sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0] region = sketch["contour_regions_mm"][0] @@ -154,6 +207,141 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(len(faces), 2) self.assertTrue(all(len(face.outer_wire().edges()) == 1 for face in faces)) + def test_planar_imprint_selects_an_exact_bounded_split_region(self) -> None: + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + from cdsl_engine.runtime import rebuild_cdsl + from cdsl_engine.semantic_validation import validate_semantic_cdsl + + def line(identifier: str, start: list[float], end: list[float]) -> dict: + return {"id": identifier, "curve": {"type": "line", "start": start, "end": end}} + + profile = { + "type": "planar_imprint", + "source_entities": [ + line("bottom", [-5, -5], [5, -5]), line("right", [5, -5], [5, 5]), + line("top", [5, 5], [-5, 5]), line("left", [-5, 5], [-5, -5]), + line("cut", [-5, 0], [5, 0]), line("divider", [0, -5], [0, 5]), + ], + "selections": [{ + "source_entity_id": "cut", "face_side": 1, + "fragment": {"anchor_entity_id": "divider", "side": -1, "intersection_index": 0}, + }], + } + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "planar-imprint", "meta": {"unit": "mm"}, + "geometry": {"sketches": [{"id": "imprint", "workplane": _workplane(), "profile": profile}]}, + "features": [{ + "id": "imprint_add", "atomic_id": "extrude_add_blind", "depends_on": [], + "sketch_id": "imprint", "params": {"distance_mm": 2}, "execution_status": "supported", + }], + } + validate_semantic_cdsl(cdsl) + sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0] + faces = Build123dGeometryAdapter().faces_for_sketch(sketch) + self.assertEqual(len(faces), 1) + self.assertAlmostEqual(faces[0].area, 25.0, places=6) + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "imprint.step") + self.assertAlmostEqual(float(result["volume_mm3"]), 50.0, places=6) + + def test_planar_imprint_bare_source_collects_its_bounded_split_faces(self) -> None: + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + profile = { + "type": "planar_imprint", + "source_entities": [ + {"id": "bottom", "curve": {"type": "line", "start": [-5, -5], "end": [5, -5]}}, + {"id": "right", "curve": {"type": "line", "start": [5, -5], "end": [5, 5]}}, + {"id": "top", "curve": {"type": "line", "start": [5, 5], "end": [-5, 5]}}, + {"id": "left", "curve": {"type": "line", "start": [-5, 5], "end": [-5, -5]}}, + {"id": "divider", "curve": {"type": "line", "start": [0, -5], "end": [0, 5]}}, + ], + "selections": [{"source_entity_id": "top", "face_side": 1}], + } + sketch = resolve_all_sketches({"geometry": {"sketches": [{ + "id": "imprint", "workplane": _workplane(), "profile": profile, + }]}})["geometry"]["sketches"][0] + faces = Build123dGeometryAdapter().faces_for_sketch(sketch) + self.assertEqual(len(faces), 2) + self.assertAlmostEqual(sum(face.area for face in faces), 100.0, places=6) + + def test_planar_imprint_preserves_a_logical_circle_source_through_a_split(self) -> None: + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + from cdsl_engine.runtime import rebuild_cdsl + + profile = { + "type": "planar_imprint", + "source_entities": [ + {"id": "circle", "curve": {"type": "circle", "center": [0, 0], "radius_mm": 10}}, + {"id": "divider", "curve": {"type": "line", "start": [-10, 0], "end": [10, 0]}}, + ], + "selections": [{ + "source_entity_id": "circle", "face_side": 1, + "fragment": {"anchor_entity_id": "divider", "side": -1, "intersection_index": 0}, + }], + } + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "circle-imprint", "meta": {"unit": "mm"}, + "geometry": {"sketches": [{"id": "imprint", "workplane": _workplane(), "profile": profile}]}, + "features": [{ + "id": "imprint_add", "atomic_id": "extrude_add_blind", "depends_on": [], + "sketch_id": "imprint", "params": {"distance_mm": 2}, "execution_status": "supported", + }], + } + sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0] + circle = next(item for item in sketch["imprint_entities_mm"] if item["id"] == "circle") + self.assertEqual([edge["type"] for edge in circle["edges"]], ["circle"]) + faces = Build123dGeometryAdapter().faces_for_sketch(sketch) + self.assertEqual(len(faces), 1) + self.assertAlmostEqual(faces[0].area, math.pi * 50.0, places=6) + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "circle-imprint.step") + self.assertAlmostEqual(float(result["volume_mm3"]), math.pi * 100.0, places=6) + + def test_planar_imprint_rejects_a_near_miss_fragment_intersection(self) -> None: + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + sketch = { + "id": "near-miss", "workplane": _workplane(), + "profile": { + "type": "planar_imprint", + "source_entities": [ + {"id": "circle", "curve": {"type": "circle", "center": [0, 0], "radius_mm": 10}}, + {"id": "nearby", "curve": {"type": "line", "start": [10.001, -5], "end": [10.001, 5]}}, + ], + "selections": [{ + "source_entity_id": "circle", "face_side": 1, + "fragment": {"anchor_entity_id": "nearby", "side": -1, "intersection_index": 0}, + }], + }, + } + resolved = resolve_all_sketches({"geometry": {"sketches": [sketch]}})["geometry"]["sketches"][0] + with self.assertRaisesRegex(ValueError, "source and anchor do not intersect"): + Build123dGeometryAdapter().faces_for_sketch(resolved) + + def test_planar_imprint_rejects_an_unbounded_arrangement_region(self) -> None: + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + sketch = { + "id": "unbounded", "workplane": _workplane(), + "profile": { + "type": "planar_imprint", + "source_entities": [ + {"id": "cut", "curve": {"type": "line", "start": [-5, 0], "end": [5, 0]}}, + {"id": "divider", "curve": {"type": "line", "start": [0, -5], "end": [0, 5]}}, + ], + "selections": [{ + "source_entity_id": "cut", "face_side": 1, + "fragment": {"anchor_entity_id": "divider", "side": -1, "intersection_index": 0}, + }], + }, + } + resolved = resolve_all_sketches({"geometry": {"sketches": [sketch]}})["geometry"]["sketches"][0] + with self.assertRaisesRegex(ValueError, "selected region is unbounded"): + Build123dGeometryAdapter().faces_for_sketch(resolved) + def test_analytic_ellipse_preserves_its_workplane_orientation_and_volume(self) -> None: from cdsl_engine.runtime import rebuild_cdsl @@ -207,6 +395,55 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertAlmostEqual(edge.tangent_at(0).Y, 1.0, places=6) self.assertAlmostEqual(edge.tangent_at(1).X, -1.0, places=6) + def test_two_point_bspline_profile_requires_and_preserves_endpoint_tangents(self) -> None: + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + cdsl = { + "schema": "cad.cdsl.llm.v1", + "geometry": {"sketches": [{ + "id": "two-point-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], [0, 0]], "parameters": [0, 1], + "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") + 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) + + with self.assertRaisesRegex(ValueError, "parameters must be finite and strictly increasing"): + Build123dGeometryAdapter()._wire([{**spline, "parameters": [0, 0]}]) + + coincident = deepcopy(cdsl) + coincident_spline = coincident["geometry"]["sketches"][0]["profile"]["contours"][0]["segments"][1] + coincident_spline["end"] = [4, 0] + coincident_spline["points"][-1] = [4, 0] + with self.assertRaisesRegex(ValueError, "two-point bspline endpoints must be distinct"): + resolve_all_sketches(coincident) + + incomplete = {**cdsl, "geometry": {"sketches": [{ + **cdsl["geometry"]["sketches"][0], + "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], [0, 0]]}, + ], + }]}, + }]}} + with self.assertRaisesRegex(ValueError, "two-point bspline requires both endpoint tangents"): + resolve_all_sketches(incomplete) + from cdsl_engine.semantic_validation import validate_semantic_cdsl + with self.assertRaisesRegex(ValueError, "CDSL schema violation"): + validate_semantic_cdsl(incomplete) + def test_drafted_ellipse_exports_as_a_solid(self) -> None: from build123d import import_step from cdsl_engine.runtime import rebuild_cdsl @@ -256,6 +493,773 @@ class EngineRuntimeFoundationTests(unittest.TestCase): 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"]) + modified_cap = next( + item for item in rebuilt["topology_records"] + if item["feature_id"] == "cut" and item["kind"] == "face" + and item["geometry"].get("surface_type") == "plane" + and abs(item["geometry"].get("area_mm2", 0) - math.pi * (10 ** 2 - 4 ** 2)) < 1e-5 + ) + self.assertEqual(modified_cap["owner_feature_ids"], ["outer_body"]) + delta = next(item for item in rebuilt["topology_deltas"] if item["operation"] == "subtract") + self.assertTrue(any( + item["event"] == "modified" and item["status"] == "unique_exact_continuation" + for item in delta["relations"] + )) + + def test_boolean_union_and_intersect_capture_exact_kernel_history(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cases = ( + ( + "union", _rectangle([-4, -2], [0, 2]), _rectangle([4, -2], [8, 2]), + 128.0, 2, + ), + ( + "intersect", _rectangle([-4, -2], [2, 2]), _rectangle([-1, -2], [5, 2]), + 48.0, 1, + ), + ) + for operation, left, right, volume, solid_count in cases: + with self.subTest(operation=operation): + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl( + _two_body_boolean_cdsl(operation, left, right), Path(directory) / f"{operation}.step", + ) + + self.assertAlmostEqual(rebuilt["volume_mm3"], volume) + self.assertEqual(rebuilt["solid_count"], solid_count) + delta = next(item for item in rebuilt["topology_deltas"] if item["operation"] == operation) + self.assertTrue(any( + item["status"] == "unique_exact_continuation" + for item in delta["relations"] + )) + self.assertTrue(any( + item["owner_feature_ids"] == ["left_body"] + for item in rebuilt["topology_records"] + if item["feature_id"] == "boolean" and item["kind"] in {"face", "edge", "vertex"} + )) + + def test_boolean_intersection_owner_selector_executes_downstream_fillet(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = _two_body_boolean_cdsl( + "intersect", _rectangle([-4, -2], [2, 2]), _rectangle([-1, -2], [5, 2]), + ) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + intersected = rebuild_cdsl(cdsl, root / "intersected.step") + edge = next( + item for item in intersected["topology_records"] + if item["feature_id"] == "boolean" and item["kind"] == "edge" + and item["owner_feature_ids"] == ["left_body"] + ) + filleted = deepcopy(cdsl) + filleted["features"].append({ + "id": "fillet", "atomic_id": "fillet", "depends_on": ["boolean"], "params": {"radius_mm": 0.25}, + "selectors": [{ + "kind": "edge", "stable_id": "intersection-left-edge", "source": "runtime_snapshot", + "confidence": 1, "owner_feature_id": "left_body", "geometry": edge["geometry"], + }], + }) + rebuilt = rebuild_cdsl(filleted, root / "filleted.step") + + self.assertEqual([item["feature_id"] for item in rebuilt["feature_results"]], [ + "left_body", "right_body", "boolean", "fillet", + ]) + self.assertLess(rebuilt["volume_mm3"], intersected["volume_mm3"]) + + def test_single_body_dressups_capture_kernel_history_only_for_direct_builder_paths(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 / "base.step") + edge = next(item for item in baseline["topology_records"] if item["kind"] == "edge") + for atomic_id, params in (("fillet", {"radius_mm": 1}), ("chamfer", {"distance_mm": 1})): + with self.subTest(atomic_id=atomic_id): + dressed = deepcopy(base) + dressed["features"].append({ + "id": atomic_id, "atomic_id": atomic_id, "depends_on": ["base_add"], "params": params, + "selectors": [{ + "kind": "edge", "stable_id": "base-edge", "source": "runtime_snapshot", "confidence": 1, + "owner_feature_id": "base_add", "geometry": edge["geometry"], + }], + }) + result = rebuild_cdsl(dressed, root / f"{atomic_id}.step") + delta = next(item for item in result["topology_deltas"] if item["operation"] == atomic_id) + self.assertTrue(any( + item["status"] == "unique_exact_continuation" + for item in delta["relations"] + )) + self.assertTrue(any( + item["event"] == "generated" and item["status"] == "recorded_without_owner_transfer" + for item in delta["relations"] + )) + self.assertTrue(any( + item["owner_feature_ids"] == ["base_add"] + for item in result["topology_records"] + if item["feature_id"] == atomic_id and item["kind"] in {"face", "edge"} + )) + + angled = deepcopy(base) + angled["features"].append({ + "id": "angled", "atomic_id": "chamfer", "depends_on": ["base_add"], + "params": {"distance_mm": 1, "distance_2_mm": 0.5}, + "selectors": [{ + "kind": "edge", "stable_id": "base-edge", "source": "runtime_snapshot", "confidence": 1, + "owner_feature_id": "base_add", "geometry": edge["geometry"], + }], + }) + angled_result = rebuild_cdsl(angled, root / "angled.step") + + self.assertFalse(any(item["operation"] == "chamfer" for item in angled_result["topology_deltas"])) + + def test_shell_captures_exact_kernel_history_for_downstream_selector(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 / "base.step") + removed_face = 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": removed_face["record_id"], + "snapshot_id": removed_face["record_id"], "source": "runtime_snapshot", + "confidence": 1, "owner_feature_id": "base_add", "geometry": removed_face["geometry"], + }], + }) + shell_result = rebuild_cdsl(shelled, root / "shell.step") + delta = next(item for item in shell_result["topology_deltas"] if item["operation"] == "shell") + continuation = next( + item for item in delta["relations"] + if item["kind"] == "edge" and item["status"] == "unique_exact_continuation" + ) + source_id = continuation["source_record_ids"][0] + result_id = continuation["result_record_ids"][0] + successor = next(item for item in shell_result["topology_records"] if item["record_id"] == result_id) + + downstream = deepcopy(shelled) + downstream["features"].append({ + "id": "fillet", "atomic_id": "fillet", "depends_on": ["shell"], + "params": {"radius_mm": 0.25}, + "selectors": [{ + "kind": "edge", "stable_id": source_id, "snapshot_id": result_id, + "source": "runtime_snapshot", "confidence": 1, + "owner_feature_id": "base_add", "geometry": successor["geometry"], + }], + }) + downstream_result = rebuild_cdsl(downstream, root / "shell-fillet.step") + + self.assertTrue(any( + item["event"] == "generated" and item["status"] == "recorded_without_owner_transfer" + for item in delta["relations"] + )) + self.assertTrue(any( + item["event"] == "generated" and item.get("output_role") == "shell.offset_face" + for item in delta["relations"] + )) + self.assertTrue(any( + item.get("output_role") == "shell.closing_descendant" + for item in delta["relations"] + )) + self.assertTrue(any( + item.get("output_role") == "shell.wall" + for item in delta["relations"] + )) + self.assertTrue(any( + item["status"] == "unique_exact_continuation" for item in delta["relations"] + )) + self.assertEqual(successor["owner_feature_ids"], ["base_add"]) + self.assertEqual([item["feature_id"] for item in downstream_result["feature_results"]], [ + "base_add", "shell", "fillet", + ]) + self.assertEqual(downstream_result["runtime_diagnostics"], []) + + def test_body_transform_copy_and_explicit_delete_preserve_unselected_members(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "body-transform-delete", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "source", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 2}, + }]}, + "features": [ + {"id": "source_body", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "source", "params": {"distance_mm": 5, "result_mode": "new_body"}}, + {"id": "copied_body", "atomic_id": "transform_bodies", "depends_on": ["source_body"], "params": { + "source_feature_ids": ["source_body"], + "transform": {"type": "translation", "translation_mm": [10, 0, 0]}, + "make_copy": True, + }}, + {"id": "remove_copy", "atomic_id": "delete_bodies", "depends_on": ["copied_body"], "params": {"target_feature_ids": ["copied_body"]}}, + ], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "body-transform-delete.step") + + self.assertEqual(result["solid_count"], 1) + self.assertAlmostEqual(result["volume_mm3"], math.pi * 2 ** 2 * 5) + self.assertEqual(result["bbox_mm"], {"min": [-2.0, -2.0, 0.0], "max": [2.0, 2.0, 5.0]}) + self.assertEqual([item["feature_id"] for item in result["feature_results"]], ["source_body", "copied_body", "remove_copy"]) + + def test_multi_source_transform_copy_preserves_source_qualified_members(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "multi-source-copy", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [ + {"id": "left", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 1}}, + {"id": "right", "workplane": _workplane(), "profile": {"type": "circle", "center": [10, 0], "radius_mm": 1}}, + ]}, + "features": [ + {"id": "left_body", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "left", "params": {"distance_mm": 2, "result_mode": "new_body"}}, + {"id": "right_body", "atomic_id": "extrude_add_blind", "depends_on": ["left_body"], "sketch_id": "right", "params": {"distance_mm": 2, "result_mode": "new_body"}}, + {"id": "copy_pair", "atomic_id": "transform_bodies", "depends_on": ["left_body", "right_body"], "params": { + "source_feature_ids": ["left_body", "right_body"], + "transform": {"type": "translation", "translation_mm": [0, 10, 0]}, "make_copy": True, + }}, + {"id": "move_left_copy", "atomic_id": "transform_bodies", "depends_on": ["copy_pair"], "params": { + "transform_copy_refs": [{"transform_feature_id": "copy_pair", "source_feature_id": "left_body"}], + "transform": {"type": "translation", "translation_mm": [0, 0, 10]}, "make_copy": False, + }}, + ], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "multi-source-copy.step") + + self.assertEqual(result["solid_count"], 4) + self.assertAlmostEqual(result["volume_mm3"], 4 * math.pi * 2, places=5) + self.assertEqual(result["bbox_mm"], {"min": [-1.0, -1.0, 0.0], "max": [11.0, 11.0, 12.0]}) + self.assertEqual([item["feature_id"] for item in result["feature_results"]], [ + "left_body", "right_body", "copy_pair", "move_left_copy", + ]) + + def test_transform_copy_reference_rejects_unselected_source(self) -> None: + from cdsl_engine.semantic_validation import validate_semantic_cdsl + + cdsl = self._base_block() + cdsl["features"][0]["params"]["result_mode"] = "new_body" + cdsl["features"].extend([ + {"id": "second_body", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], "sketch_id": "base", "params": {"distance_mm": 5, "result_mode": "new_body"}}, + {"id": "copy_pair", "atomic_id": "transform_bodies", "depends_on": ["base_add", "second_body"], "params": { + "source_feature_ids": ["base_add", "second_body"], + "transform": {"type": "translation", "translation_mm": [1, 0, 0]}, "make_copy": True, + }}, + {"id": "move", "atomic_id": "transform_bodies", "depends_on": ["copy_pair"], "params": { + "transform_copy_refs": [{"transform_feature_id": "copy_pair", "source_feature_id": "missing_body"}], + "transform": {"type": "translation", "translation_mm": [1, 0, 0]}, "make_copy": False, + }}, + ]) + for feature in cdsl["features"]: + feature["execution_status"] = "supported" + + with self.assertRaisesRegex(ValueError, "names a source outside its transform"): + validate_semantic_cdsl(cdsl) + + def test_body_uniform_scale_uses_its_explicit_center_and_preserves_topology_provenance(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "body-uniform-scale", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "source", "workplane": _workplane(), "profile": {"type": "circle", "center": [10, 0], "radius_mm": 2}, + }]}, + "features": [ + {"id": "source_body", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "source", "params": {"distance_mm": 5, "result_mode": "new_body"}}, + {"id": "scaled_body", "atomic_id": "transform_bodies", "depends_on": ["source_body"], "params": { + "source_feature_ids": ["source_body"], + "transform": {"type": "uniform_scale", "center_mm": [10, 0, 0], "scale_factor": 0.5}, + "make_copy": False, + }}, + ], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "body-uniform-scale.step") + + self.assertEqual(result["solid_count"], 1) + self.assertAlmostEqual(result["volume_mm3"], math.pi * 2 ** 2 * 5 / 8) + self.assertEqual(result["bbox_mm"], {"min": [9.0, -1.0, 0.0], "max": [11.0, 1.0, 2.5]}) + self.assertTrue(any(item["operation"] == "uniform_scale" for item in result["topology_deltas"])) + + def test_kernel_transform_delta_preserves_owner_and_rejects_stale_geometry(self) -> None: + from build123d import Solid + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + adapter = Build123dGeometryAdapter() + source = Solid.make_box(2, 3, 4) + cases = ( + ("translation", {"type": "translation", "translation_mm": [7, -3, 2]}), + ("rotation", { + "type": "rotation", + "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}, + "angle_deg": 90, + }), + ("uniform_scale", {"type": "uniform_scale", "center_mm": [1, 1.5, 2], "scale_factor": 0.5}), + ) + for name, transform in cases: + with self.subTest(transform=name): + moved, delta = adapter.transform_with_topology_delta(source, transform) + registry = TopologyRegistry() + source_records = adapter.topology_records(source, "base", "body:base") + registry.replace_body_topology("base", "body:base", source_records) + registry.replace_body_topology( + "move", "body:move", adapter.topology_records(moved, "move", "body:move"), + topology_delta=delta, + ) + + moved_face = next(record for record in registry.records_for_feature("move") if record.kind == "face") + resolved = registry.resolve({ + "kind": "face", "owner_feature_id": "base", "geometry": moved_face.geometry, + }, active_body_id="body:move") + stale = registry.resolve({ + "kind": "face", "owner_feature_id": "base", "geometry": source_records[0].geometry, + }, active_body_id="body:move") + + self.assertEqual(moved_face.owner_feature_ids, ("base",)) + self.assertEqual(resolved.status, "resolved") + self.assertEqual(stale.status, "not_found") + evidence = registry.topology_deltas()[0] + self.assertEqual(evidence["operation"], name) + self.assertTrue(any( + item["status"] == "unique_exact_continuation" + for item in evidence["relations"] + )) + + def test_non_unique_or_incomplete_kernel_history_does_not_transfer_owner(self) -> None: + first_source = object() + second_source = object() + result = object() + registry = TopologyRegistry() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:first", "face", "base", "body:base", {"center_mm": [0, 0, 0]}, first_source), + TopologyRecord("base:second", "face", "other", "body:base", {"center_mm": [0, 0, 0]}, second_source), + ]) + registry.replace_body_topology("move", "body:move", [ + TopologyRecord("move:face", "face", "move", "body:move", {"center_mm": [7, 0, 0]}, result), + ], topology_delta=TopologyDelta("translation", ( + TopologyDeltaRelation("modified", "face", first_source, (result,)), + TopologyDeltaRelation("modified", "face", second_source, (result,)), + ))) + + moved = registry.records_for_feature("move")[0] + statuses = [item["status"] for item in registry.topology_deltas()[0]["relations"]] + self.assertEqual(moved.owner_feature_ids, ("move",)) + self.assertEqual(statuses, ["ambiguous_exact_continuation", "ambiguous_exact_continuation"]) + self.assertEqual(registry.resolve({ + "kind": "face", "stable_id": "base:first", "owner_feature_id": "base", + }, active_body_id="body:move").status, "not_found") + + def test_kernel_deleted_history_blocks_geometry_drift_successor_guessing(self) -> None: + registry = TopologyRegistry() + source = object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:edge", "edge", "base", "body:base", { + "curve_type": "line", "start_mm": [0, 0, 0], "end_mm": [0, 0, 10], + }, source), + ]) + registry.replace_body_topology("dress_up", "body:dress_up", [ + TopologyRecord("dress_up:edge", "edge", "dress_up", "body:dress_up", { + "curve_type": "line", "start_mm": [0, 0, 1], "end_mm": [0, 0, 9], + }, object()), + ], topology_delta=TopologyDelta("fillet", ( + TopologyDeltaRelation("deleted", "edge", source), + ))) + + self.assertNotIn("base:edge", registry._successors) + self.assertEqual(registry.topology_deltas()[0]["relations"][0]["status"], "recorded_without_owner_transfer") + + def test_inactive_stable_id_without_geometry_does_not_bind_a_unique_active_candidate(self) -> None: + registry = TopologyRegistry() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:edge", "edge", "base", "body:base", {"center_mm": [0, 0, 0]}), + ]) + registry.replace_body_topology("later", "body:later", [ + TopologyRecord("later:edge", "edge", "later", "body:later", {"center_mm": [5, 0, 0]}), + ]) + + resolution = registry.resolve({"kind": "edge", "stable_id": "base:edge"}, active_body_id="body:later") + self.assertEqual(resolution.status, "not_found") + self.assertEqual(resolution.diagnostic.code, "selector_stable_id_inactive") + + def test_output_role_selector_binds_only_unique_active_kernel_evidence(self) -> None: + registry = TopologyRegistry() + source_face = object() + sweep_end = object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:face", "face", "base", "body:base", {"center_mm": [0, 0, 0]}, source_face), + ]) + registry.replace_body_topology("sweep", "body:sweep", [ + TopologyRecord("sweep:end", "face", "sweep", "body:sweep", {"center_mm": [0, 0, 10]}, sweep_end), + ], topology_delta=TopologyDelta("sweep", ( + TopologyDeltaRelation("generated", "face", object(), (sweep_end,), output_role="sweep.end"), + ))) + + resolved = registry.resolve({ + "kind": "face", "owner_feature_id": "sweep", "output_role": "sweep.end", + }, active_body_id="body:sweep") + evidence = registry.topology_deltas()[0]["relations"][0] + + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.record.record_id, "sweep:end") + self.assertEqual(resolved.record.output_roles, ("sweep.end",)) + self.assertEqual(evidence["output_role_status"], "unique_result_snapshot") + + registry.replace_body_topology("later", "body:later", [ + TopologyRecord("later:end", "face", "later", "body:later", {"center_mm": [0, 0, 10]}, object()), + ]) + stale = registry.resolve({ + "kind": "face", "owner_feature_id": "sweep", "output_role": "sweep.end", + }, active_body_id="body:later") + self.assertEqual(stale.status, "not_found") + self.assertEqual(stale.diagnostic.code, "selector_output_role_not_found") + + def test_shell_offset_role_source_selects_one_exact_builder_relation(self) -> None: + registry = TopologyRegistry() + extrude_start = object() + extrude_end = object() + offset_start = object() + offset_end = object() + registry.replace_body_topology("extrude", "body:extrude", [ + TopologyRecord( + "extrude:start", "face", "extrude", "body:extrude", {"center_mm": [0, 0, 0]}, extrude_start, + owner_feature_ids=("extrude",), output_roles=("extrude.start",), + ), + TopologyRecord( + "extrude:end", "face", "extrude", "body:extrude", {"center_mm": [0, 0, 10]}, extrude_end, + owner_feature_ids=("extrude",), output_roles=("extrude.end",), + ), + ]) + registry.replace_body_topology("shell", "body:shell", [ + TopologyRecord("shell:offset-start", "face", "shell", "body:shell", {"center_mm": [0, 0, 1]}, offset_start), + TopologyRecord("shell:offset-end", "face", "shell", "body:shell", {"center_mm": [0, 0, 9]}, offset_end), + ], topology_delta=TopologyDelta("shell", ( + TopologyDeltaRelation("generated", "face", extrude_start, (offset_start,), output_role="shell.offset_face"), + TopologyDeltaRelation("generated", "face", extrude_end, (offset_end,), output_role="shell.offset_face"), + ))) + + ambiguous = registry.resolve({ + "kind": "face", "owner_feature_id": "shell", "output_role": "shell.offset_face", + }, active_body_id="body:shell") + selected = registry.resolve({ + "kind": "face", "owner_feature_id": "shell", "output_role": "shell.offset_face", + "output_role_source": {"owner_feature_id": "extrude", "output_role": "extrude.start"}, + }, active_body_id="body:shell") + + self.assertEqual(ambiguous.status, "ambiguous") + self.assertEqual(selected.status, "resolved") + self.assertEqual(selected.record.record_id, "shell:offset-start") + self.assertEqual( + selected.record.output_role_sources, + (("shell.offset_face", "extrude", "extrude.start"),), + ) + + def test_tapered_prism_exposes_builder_proven_single_cap_roles(self) -> None: + from build123d import Face, Plane, Wire + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + face = Face(Wire.make_circle(10, Plane.XY)) + result, delta = Build123dGeometryAdapter().extrude_taper_with_topology_delta( + face, (0.0, 0.0, 20.0), 10.0, + ) + + self.assertTrue(result.is_valid) + self.assertGreater(result.volume, 0.0) + self.assertIsNotNone(delta) + self.assertEqual( + [(relation.event, relation.kind, relation.output_role) for relation in delta.relations], + [("generated", "face", "extrude.start"), ("generated", "face", "extrude.end")], + ) + + def test_exact_kernel_history_crosses_a_boolean_member_index_shift(self) -> None: + registry = TopologyRegistry() + independent = object() + tool = object() + draft_cap = object() + boolean_result_cap = object() + registry.replace_body_topologies("draft", [ + ("body:draft:0", [TopologyRecord( + "draft:independent", "face", "independent", "body:draft:0", {"center_mm": [-5, 0, 0]}, independent, + )]), + ("body:draft:1", [TopologyRecord( + "draft:tool", "face", "tool", "body:draft:1", {"center_mm": [0, 0, 0]}, tool, + )]), + ("body:draft:2", [TopologyRecord( + "draft:cap", "face", "draft", "body:draft:2", {"center_mm": [5, 0, 0]}, draft_cap, + owner_feature_ids=("draft",), output_roles=("extrude.start",), + )]), + ], active_body_id="body:draft") + registry.replace_body_topologies("boolean", [ + ("body:boolean:0", [TopologyRecord( + "boolean:independent", "face", "boolean", "body:boolean:0", {"center_mm": [-5, 0, 0]}, independent, + )]), + ("body:boolean:1", [TopologyRecord( + "boolean:cap", "face", "boolean", "body:boolean:1", {"center_mm": [5, 0, 0]}, boolean_result_cap, + )]), + ], active_body_id="body:boolean", topology_delta=TopologyDelta("subtract", ( + TopologyDeltaRelation("modified", "face", draft_cap, (boolean_result_cap,)), + ))) + + resolution = registry.resolve({ + "kind": "face", "owner_feature_id": "draft", "output_role": "extrude.start", + }, active_body_id="body:boolean") + + self.assertEqual(resolution.status, "resolved") + self.assertEqual(resolution.record.record_id, "boolean:cap") + self.assertEqual(resolution.record.owner_feature_ids, ("draft",)) + self.assertEqual(resolution.record.output_roles, ("extrude.start",)) + + def test_output_role_semantic_contract_requires_face_runtime_evidence(self) -> None: + from cdsl_engine.semantic_validation import validate_semantic_cdsl + + cdsl = self._base_block() + cdsl["features"][0]["execution_status"] = "supported" + cdsl["features"].append({ + "id": "shell", "atomic_id": "shell", "depends_on": ["base_add"], + "params": {"thickness_mm": 1}, "execution_status": "supported", + "selectors": [{ + "kind": "face", "owner_feature_id": "base_add", "output_role": "sweep.end", + "source": "runtime_snapshot", "confidence": 1, + }], + }) + self.assertTrue(validate_semantic_cdsl(cdsl)["future_rebuild_ready"]) + + invalid = deepcopy(cdsl) + invalid["features"][-1]["selectors"][0]["stable_id"] = "legacy-face" + with self.assertRaisesRegex(ValueError, "cannot mix stable or geometry evidence"): + validate_semantic_cdsl(invalid) + + nested = deepcopy(cdsl) + nested["features"][-1]["selectors"][0]["matched_selectors"] = [{ + "kind": "face", "owner_feature_id": "base_add", "output_role": "sweep.start", + "source": "runtime_snapshot", "confidence": 1, + }] + with self.assertRaisesRegex(ValueError, "only supported in feature.selectors"): + validate_semantic_cdsl(nested) + + bypassed_semantic_validation = deepcopy(cdsl) + bypassed_semantic_validation["features"][-1]["params"]["role_reference"] = { + "kind": "face", "owner_feature_id": "base_add", "output_role": "sweep.start", + "source": "runtime_snapshot", "confidence": 1, + } + analysis = CapabilityAnalyzer( + atomic_ids={"extrude_add_blind", "shell"}, profile_types=SHAPE_GENERATORS, + ).analyze(bypassed_semantic_validation) + self.assertIn( + "unsupported_output_role_selector_context", + [blocker.code for blocker in analysis.feature_results[-1].blockers], + ) + + malformed_source = deepcopy(cdsl) + malformed_source["features"][-1]["selectors"][0]["output_role"] = "shell.offset_face" + malformed_source["features"][-1]["selectors"][0]["output_role_source"] = {"owner_feature_id": "base_add"} + with self.assertRaisesRegex(ValueError, "CDSL schema violation"): + validate_semantic_cdsl(malformed_source) + + unsupported_source = deepcopy(cdsl) + unsupported_source["features"][-1]["selectors"][0]["output_role"] = "shell.offset_face" + unsupported_source["features"][-1]["selectors"][0]["output_role_source"] = { + "owner_feature_id": "base_add", "output_role": "sweep.end", + } + with self.assertRaisesRegex(ValueError, "must be an extrude cap role"): + validate_semantic_cdsl(unsupported_source) + + invalid_shell_target = deepcopy(cdsl) + invalid_shell_target["features"][-1]["params"]["target_feature_id"] = "shell" + with self.assertRaisesRegex(ValueError, "requires a preceding body feature"): + validate_semantic_cdsl(invalid_shell_target) + + def test_transformed_owner_selector_executes_a_downstream_fillet(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = self._base_block() + cdsl["features"].append({ + "id": "move", "atomic_id": "transform_bodies", "depends_on": ["base_add"], + "params": { + "source_feature_ids": ["base_add"], + "transform": {"type": "translation", "translation_mm": [20, 0, 0]}, + "make_copy": False, + }, + }) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + moved = rebuild_cdsl(cdsl, root / "moved.step") + edge = next( + item for item in moved["topology_records"] + if item["kind"] == "edge" and item.get("body_id") == "body:move" + ) + filleted = deepcopy(cdsl) + filleted["features"].append({ + "id": "fillet", "atomic_id": "fillet", "depends_on": ["move"], "params": {"radius_mm": 1}, + "selectors": [{ + "kind": "edge", "stable_id": "moved-edge", "source": "runtime_snapshot", "confidence": 1, + "owner_feature_id": "base_add", "geometry": edge["geometry"], + }], + }) + result = rebuild_cdsl(filleted, root / "filleted.step") + + self.assertEqual([item["feature_id"] for item in result["feature_results"]], ["base_add", "move", "fillet"]) + self.assertLess(result["volume_mm3"], moved["volume_mm3"]) + self.assertTrue(any( + item["operation"] == "translation" for item in result["topology_deltas"] + )) + + def test_body_transform_unavailable_source_is_a_preflight_diagnostic(self) -> None: + cdsl = self._base_block() + cdsl["features"].append({ + "id": "move", "atomic_id": "transform_bodies", "depends_on": ["base_add"], + "params": { + "source_feature_ids": ["missing_body"], + "transform": {"type": "translation", "translation_mm": [1, 0, 0]}, + "make_copy": True, + }, + }) + analysis = CapabilityAnalyzer(atomic_ids={"extrude_add_blind", "transform_bodies"}, profile_types=SHAPE_GENERATORS).analyze(cdsl) + blocker = next(item for item in analysis.feature_results[-1].blockers if item.code == "body_source_unavailable") + self.assertEqual(blocker.code, "body_source_unavailable") + self.assertEqual(blocker.detail, {"source_feature_id": "missing_body"}) + + def test_absorbed_body_source_is_not_selectable_after_a_fused_feature(self) -> None: + cdsl = self._base_block() + cdsl["features"].extend([ + { + "id": "fused_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], + "params": {"distance_mm": 5}, "sketch_id": "base", + }, + { + "id": "move", "atomic_id": "transform_bodies", "depends_on": ["fused_add"], + "params": { + "source_feature_ids": ["base_add"], + "transform": {"type": "translation", "translation_mm": [1, 0, 0]}, + "make_copy": True, + }, + }, + ]) + + analysis = CapabilityAnalyzer( + atomic_ids={"extrude_add_blind", "transform_bodies"}, profile_types=SHAPE_GENERATORS, + ).analyze(cdsl) + + blocker = next(item for item in analysis.feature_results[-1].blockers if item.code == "body_source_unavailable") + self.assertEqual(blocker.detail, {"source_feature_id": "base_add"}) + + def test_boolean_consumed_body_source_is_not_selectable_afterward(self) -> None: + cdsl = self._base_block() + cdsl["features"][0]["params"]["result_mode"] = "new_body" + cdsl["features"].extend([ + { + "id": "tool_body", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], + "params": {"distance_mm": 5, "result_mode": "new_body"}, "sketch_id": "base", + }, + { + "id": "join", "atomic_id": "boolean_bodies", "depends_on": ["base_add", "tool_body"], + "params": { + "operation": "union", "target_feature_ids": ["base_add"], + "tool_feature_ids": ["tool_body"], "keep_tools": False, + }, + }, + { + "id": "move", "atomic_id": "transform_bodies", "depends_on": ["join"], + "params": { + "source_feature_ids": ["base_add"], + "transform": {"type": "translation", "translation_mm": [1, 0, 0]}, + "make_copy": True, + }, + }, + ]) + + analysis = CapabilityAnalyzer( + atomic_ids={"extrude_add_blind", "boolean_bodies", "transform_bodies"}, profile_types=SHAPE_GENERATORS, + ).analyze(cdsl) + + blocker = next(item for item in analysis.feature_results[-1].blockers if item.code == "body_source_unavailable") + self.assertEqual(blocker.detail, {"source_feature_id": "base_add"}) + + def test_boolean_rejects_a_body_source_absorbed_by_a_fused_feature(self) -> None: + cdsl = self._base_block() + cdsl["features"].extend([ + { + "id": "fused_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], + "params": {"distance_mm": 5}, "sketch_id": "base", + }, + { + "id": "selected_tool", "atomic_id": "extrude_add_blind", "depends_on": ["fused_add"], + "params": {"distance_mm": 3, "result_mode": "new_body"}, "sketch_id": "base", + }, + { + "id": "boolean", "atomic_id": "boolean_bodies", "depends_on": ["selected_tool"], + "params": { + "operation": "subtract", "target_feature_ids": ["base_add"], + "tool_feature_ids": ["selected_tool"], "keep_tools": False, + }, + }, + ]) + + analysis = CapabilityAnalyzer( + atomic_ids={"extrude_add_blind", "boolean_bodies"}, profile_types=SHAPE_GENERATORS, + ).analyze(cdsl) + + blocker = next(item for item in analysis.feature_results[-1].blockers if item.code == "boolean_body_unavailable") + self.assertEqual(blocker.detail, {"source_feature_id": "base_add"}) + + def test_deleted_last_body_clears_the_active_body_precondition(self) -> None: + cdsl = self._base_block() + cdsl["features"].extend([ + { + "id": "delete_base", "atomic_id": "delete_bodies", "depends_on": ["base_add"], + "params": {"target_feature_ids": ["base_add"]}, + }, + { + "id": "cut_after_delete", "atomic_id": "extrude_cut_blind", "depends_on": ["delete_base"], + "params": {"distance_mm": 1}, "sketch_id": "base", + }, + ]) + + analysis = CapabilityAnalyzer( + atomic_ids={"extrude_add_blind", "extrude_cut_blind", "delete_bodies"}, profile_types=SHAPE_GENERATORS, + ).analyze(cdsl) + + blockers = {item.code for item in analysis.feature_results[-1].blockers} + self.assertIn("missing_active_body", blockers) + + def test_pattern_replay_does_not_make_its_source_a_body_member(self) -> None: + cdsl = self._base_block() + cdsl["features"].extend([ + { + "id": "array", "atomic_id": "pattern_linear", "depends_on": ["base_add"], + "params": { + "source_feature_ids": ["base_add"], "direction_1": [1, 0, 0], + "spacing_1_mm": 20, "pattern_count_1": 2, + }, + }, + { + "id": "move", "atomic_id": "transform_bodies", "depends_on": ["array"], + "params": { + "source_feature_ids": ["base_add"], + "transform": {"type": "translation", "translation_mm": [1, 0, 0]}, + "make_copy": True, + }, + }, + ]) + + analysis = CapabilityAnalyzer( + atomic_ids={"extrude_add_blind", "pattern_linear", "transform_bodies"}, profile_types=SHAPE_GENERATORS, + ).analyze(cdsl) + + blocker = next(item for item in analysis.feature_results[-1].blockers if item.code == "body_source_unavailable") + self.assertEqual(blocker.detail, {"source_feature_id": "base_add"}) def test_deferred_reference_is_currently_executable_without_a_sketch(self) -> None: cdsl = { @@ -749,6 +1753,20 @@ class EngineRuntimeFoundationTests(unittest.TestCase): result = rebuild_cdsl(cdsl, Path(directory) / "circular-fuse.step") self.assertEqual(result["solid_count"], 1) self.assertAlmostEqual(result["volume_mm3"], 2000.0, places=5) + copy_owners = { + owner + for record in result["topology_records"] + for owner in record.get("owner_feature_ids") or () + if owner.startswith("wedge_pattern.c") + } + self.assertEqual( + copy_owners, + { + "wedge_pattern.c1.wedge_add", + "wedge_pattern.c2.wedge_add", + "wedge_pattern.c3.wedge_add", + }, + ) def test_circular_pattern_skips_explicitly_deleted_copy_instances(self) -> None: from cdsl_engine.runtime import rebuild_cdsl @@ -773,6 +1791,279 @@ class EngineRuntimeFoundationTests(unittest.TestCase): result = rebuild_cdsl(cdsl, Path(directory) / "circular-delete-copy.step") self.assertAlmostEqual(result["volume_mm3"], 1500.0, places=5) + def test_transform_can_move_one_proven_circular_pattern_copy(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-copy-transform", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "boss", "workplane": _workplane(), "profile": {"type": "circle", "center": [10, 0], "radius_mm": 1}, + }]}, + "features": [ + {"id": "boss_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "boss", "params": {"distance_mm": 2, "result_mode": "new_body"}}, + {"id": "boss_pattern", "atomic_id": "pattern_circular", "depends_on": ["boss_add"], "params": { + "source_feature_ids": ["boss_add"], "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}, + "pattern_count": 4, "sweep_angle_deg": 360, + }}, + {"id": "move_copy", "atomic_id": "transform_bodies", "depends_on": ["boss_pattern"], "params": { + "pattern_instance_refs": [{"pattern_feature_id": "boss_pattern", "source_feature_id": "boss_add", "instance_index": 1}], + "transform": {"type": "translation", "translation_mm": [40, 0, 0]}, "make_copy": False, + }}, + ], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "circular-copy-transform.step") + + self.assertEqual(result["solid_count"], 4) + self.assertAlmostEqual(result["volume_mm3"], 4 * math.pi * 2, places=5) + self.assertEqual(result["bbox_mm"]["max"], [41.0, 11.0, 2.0]) + self.assertEqual([item["feature_id"] for item in result["feature_results"]], ["boss_add", "boss_pattern", "move_copy"]) + + def test_boolean_can_union_copies_of_a_proven_fused_body_successor(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "fused-body-pattern-boolean", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [ + {"id": "base", "workplane": _workplane(), "profile": {"type": "circle", "center": [10, 0], "radius_mm": 2}}, + {"id": "boss", "workplane": _workplane(), "profile": {"type": "circle", "center": [12, 0], "radius_mm": 1.5}}, + ]}, + "features": [ + {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base", "params": {"distance_mm": 2, "result_mode": "new_body"}}, + {"id": "boss_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], "sketch_id": "boss", "params": {"distance_mm": 2}}, + {"id": "boss_pattern", "atomic_id": "pattern_circular", "depends_on": ["boss_add"], "params": { + "source_feature_ids": ["boss_add"], "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}, + "pattern_count": 3, "sweep_angle_deg": 360, + }}, + {"id": "join", "atomic_id": "boolean_bodies", "depends_on": ["boss_pattern"], "params": { + "operation": "union", "target_feature_ids": ["boss_add"], "tool_pattern_instance_refs": [ + {"pattern_feature_id": "boss_pattern", "source_feature_id": "boss_add", "instance_index": 1}, + {"pattern_feature_id": "boss_pattern", "source_feature_id": "boss_add", "instance_index": 2}, + ], "keep_tools": False, + }}, + ], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "fused-body-pattern-boolean.step") + + self.assertEqual(result["solid_count"], 3) + self.assertEqual([item["feature_id"] for item in result["feature_results"]], [ + "base_add", "boss_add", "boss_pattern", "join", + ]) + + def test_transform_can_move_one_proven_mirror_pattern_copy(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-copy-transform", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "boss", "workplane": _workplane(), "profile": {"type": "circle", "center": [10, 0], "radius_mm": 1}, + }]}, + "features": [ + {"id": "mirror_plane", "atomic_id": "reference_plane", "depends_on": [], "params": { + "plane": {"origin_mm": [0, 0, 0], "x_dir": [0, 1, 0], "normal": [1, 0, 0]}, + }}, + {"id": "boss_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "boss", "params": { + "distance_mm": 2, "result_mode": "new_body", + }}, + {"id": "mirror", "atomic_id": "pattern_mirror", "depends_on": ["boss_add", "mirror_plane"], "params": { + "source_feature_ids": ["boss_add"], "mirror_current_body": True, + "mirror_plane": {"kind": "plane", "owner_feature_id": "mirror_plane"}, + }, "selectors": [{"kind": "plane", "owner_feature_id": "mirror_plane"}]}, + {"id": "move_copy", "atomic_id": "transform_bodies", "depends_on": ["mirror"], "params": { + "pattern_instance_refs": [{"pattern_feature_id": "mirror", "source_feature_id": "boss_add", "instance_index": 1}], + "transform": {"type": "translation", "translation_mm": [0, 20, 0]}, "make_copy": False, + }}, + ], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "mirror-copy-transform.step") + + self.assertEqual(result["solid_count"], 2) + self.assertAlmostEqual(result["volume_mm3"], 4 * math.pi, places=5) + self.assertEqual(result["bbox_mm"], {"min": [-11.0, -1.0, 0.0], "max": [11.0, 21.0, 2.0]}) + self.assertEqual([item["feature_id"] for item in result["feature_results"]], [ + "mirror_plane", "boss_add", "mirror", "move_copy", + ]) + + def test_boolean_can_union_one_proven_mirror_pattern_copy(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-copy-boolean", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "boss", "workplane": _workplane(), "profile": {"type": "circle", "center": [10, 0], "radius_mm": 1}, + }]}, + "features": [ + {"id": "mirror_plane", "atomic_id": "reference_plane", "depends_on": [], "params": { + "plane": {"origin_mm": [0, 0, 0], "x_dir": [0, 1, 0], "normal": [1, 0, 0]}, + }}, + {"id": "boss_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "boss", "params": { + "distance_mm": 2, "result_mode": "new_body", + }}, + {"id": "mirror", "atomic_id": "pattern_mirror", "depends_on": ["boss_add", "mirror_plane"], "params": { + "source_feature_ids": ["boss_add"], "mirror_current_body": True, + "mirror_plane": {"kind": "plane", "owner_feature_id": "mirror_plane"}, + }, "selectors": [{"kind": "plane", "owner_feature_id": "mirror_plane"}]}, + {"id": "join", "atomic_id": "boolean_bodies", "depends_on": ["mirror"], "params": { + "operation": "union", "target_feature_ids": ["boss_add"], + "tool_pattern_instance_refs": [{ + "pattern_feature_id": "mirror", "source_feature_id": "boss_add", "instance_index": 1, + }], "keep_tools": False, + }}, + ], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "mirror-copy-boolean.step") + + self.assertEqual(result["solid_count"], 2) + self.assertAlmostEqual(result["volume_mm3"], 4 * math.pi, places=5) + self.assertEqual(result["bbox_mm"], {"min": [-11.0, -1.0, 0.0], "max": [11.0, 1.0, 2.0]}) + self.assertEqual([item["feature_id"] for item in result["feature_results"]], [ + "mirror_plane", "boss_add", "mirror", "join", + ]) + + def test_boolean_pattern_instance_requires_a_surviving_member(self) -> None: + cdsl = self._base_block() + cdsl["features"][0]["params"]["result_mode"] = "new_body" + cdsl["features"].extend([ + { + "id": "mirror_plane", "atomic_id": "reference_plane", "depends_on": [], "params": { + "plane": {"origin_mm": [0, 0, 0], "x_dir": [0, 1, 0], "normal": [1, 0, 0]}, + }, + }, + { + "id": "mirror", "atomic_id": "pattern_mirror", "depends_on": ["base_add", "mirror_plane"], "params": { + "source_feature_ids": ["base_add"], "mirror_plane": { + "kind": "plane", "owner_feature_id": "mirror_plane", "stable_id": "mirror-plane", + "source": "runtime_snapshot", "confidence": 1, + }, + }, "selectors": [{ + "kind": "plane", "owner_feature_id": "mirror_plane", "stable_id": "mirror-plane", + "source": "runtime_snapshot", "confidence": 1, + }], + }, + { + "id": "join", "atomic_id": "boolean_bodies", "depends_on": ["mirror"], "params": { + "operation": "union", "target_feature_ids": ["base_add"], + "tool_pattern_instance_refs": [{ + "pattern_feature_id": "mirror", "source_feature_id": "base_add", "instance_index": 2, + }], "keep_tools": False, + }, + }, + ]) + for feature in cdsl["features"]: + feature["execution_status"] = "supported" + + from cdsl_engine.semantic_validation import validate_semantic_cdsl + with self.assertRaisesRegex(ValueError, "not a surviving copy"): + validate_semantic_cdsl(cdsl) + + analysis = CapabilityAnalyzer( + atomic_ids={"extrude_add_blind", "reference_plane", "pattern_mirror", "boolean_bodies"}, + profile_types=SHAPE_GENERATORS, + ).analyze(cdsl) + blocker = next(item for item in analysis.feature_results[-1].blockers if item.code == "pattern_instance_unavailable") + self.assertEqual(blocker.detail, { + "pattern_feature_id": "mirror", "source_feature_id": "base_add", "instance_index": 2, + }) + + def test_mirror_pattern_instance_reference_rejects_any_instance_but_one(self) -> None: + from cdsl_engine.semantic_validation import validate_semantic_cdsl + + cdsl = self._base_block() + cdsl["features"][0]["params"]["result_mode"] = "new_body" + cdsl["features"].extend([ + { + "id": "mirror_plane", "atomic_id": "reference_plane", "depends_on": [], "params": { + "plane": {"origin_mm": [0, 0, 0], "x_dir": [0, 1, 0], "normal": [1, 0, 0]}, + }, + }, + { + "id": "mirror", "atomic_id": "pattern_mirror", "depends_on": ["base_add", "mirror_plane"], "params": { + "source_feature_ids": ["base_add"], "mirror_plane": { + "kind": "plane", "owner_feature_id": "mirror_plane", "stable_id": "mirror-plane", + "source": "runtime_snapshot", "confidence": 1, + }, + }, "selectors": [{ + "kind": "plane", "owner_feature_id": "mirror_plane", "stable_id": "mirror-plane", + "source": "runtime_snapshot", "confidence": 1, + }], + }, + { + "id": "move", "atomic_id": "transform_bodies", "depends_on": ["mirror"], "params": { + "pattern_instance_refs": [{"pattern_feature_id": "mirror", "source_feature_id": "base_add", "instance_index": 2}], + "transform": {"type": "translation", "translation_mm": [1, 0, 0]}, "make_copy": False, + }, + }, + ]) + for feature in cdsl["features"]: + feature["execution_status"] = "supported" + with self.assertRaisesRegex(ValueError, "not a surviving copy"): + validate_semantic_cdsl(cdsl) + + def test_mirror_pattern_instance_reference_requires_a_new_body_source(self) -> None: + from cdsl_engine.semantic_validation import validate_semantic_cdsl + + cdsl = self._base_block() + cdsl["features"].extend([ + { + "id": "mirror_plane", "atomic_id": "reference_plane", "depends_on": [], "params": { + "plane": {"origin_mm": [0, 0, 0], "x_dir": [0, 1, 0], "normal": [1, 0, 0]}, + }, + }, + { + "id": "mirror", "atomic_id": "pattern_mirror", "depends_on": ["base_add", "mirror_plane"], "params": { + "source_feature_ids": ["base_add"], "mirror_plane": { + "kind": "plane", "owner_feature_id": "mirror_plane", "stable_id": "mirror-plane", + "source": "runtime_snapshot", "confidence": 1, + }, + }, "selectors": [{ + "kind": "plane", "owner_feature_id": "mirror_plane", "stable_id": "mirror-plane", + "source": "runtime_snapshot", "confidence": 1, + }], + }, + { + "id": "move", "atomic_id": "transform_bodies", "depends_on": ["mirror"], "params": { + "pattern_instance_refs": [{"pattern_feature_id": "mirror", "source_feature_id": "base_add", "instance_index": 1}], + "transform": {"type": "translation", "translation_mm": [1, 0, 0]}, "make_copy": False, + }, + }, + ]) + for feature in cdsl["features"]: + feature["execution_status"] = "supported" + with self.assertRaisesRegex(ValueError, "requires a preceding new_body mirror source"): + validate_semantic_cdsl(cdsl) + + def test_pattern_instance_reference_rejects_an_excluded_copy(self) -> None: + cdsl = self._base_block() + cdsl["features"][0]["params"]["result_mode"] = "new_body" + cdsl["features"].extend([ + { + "id": "pattern", "atomic_id": "pattern_circular", "depends_on": ["base_add"], + "params": { + "source_feature_ids": ["base_add"], "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}, + "pattern_count": 3, "sweep_angle_deg": 360, "excluded_instance_indices": [1], + }, "execution_status": "supported", + }, + { + "id": "move", "atomic_id": "transform_bodies", "depends_on": ["pattern"], + "params": { + "pattern_instance_refs": [{"pattern_feature_id": "pattern", "source_feature_id": "base_add", "instance_index": 1}], + "transform": {"type": "translation", "translation_mm": [1, 0, 0]}, "make_copy": False, + }, "execution_status": "supported", + }, + ]) + cdsl["features"][0]["execution_status"] = "supported" + from cdsl_engine.semantic_validation import validate_semantic_cdsl + + with self.assertRaisesRegex(ValueError, "not a surviving copy"): + validate_semantic_cdsl(cdsl) + def test_mirror_pattern_can_union_the_active_body(self) -> None: from cdsl_engine.runtime import rebuild_cdsl @@ -881,9 +2172,136 @@ class EngineRuntimeFoundationTests(unittest.TestCase): }], } with tempfile.TemporaryDirectory() as directory: - result = rebuild_cdsl(cdsl, Path(directory) / "sweep.step") + root = Path(directory) + result = rebuild_cdsl(cdsl, root / "sweep.step") + shelled = deepcopy(cdsl) + shelled["features"].append({ + "id": "shell", "atomic_id": "shell", "depends_on": ["sweep"], + "params": {"thickness_mm": 0.25, "inward": True}, + "selectors": [{ + "kind": "face", "owner_feature_id": "sweep", "output_role": "sweep.end", + "source": "runtime_snapshot", "confidence": 1, + }], + }) + downstream = rebuild_cdsl(shelled, root / "sweep-shell.step") self.assertEqual(result["solid_count"], 1) self.assertAlmostEqual(result["volume_mm3"], 80 * math.pi, delta=1e-4) + delta = next(item for item in result["topology_deltas"] if item["operation"] == "sweep") + self.assertEqual( + {item.get("output_role") for item in delta["relations"]}, + {"sweep.start", "sweep.end"}, + ) + self.assertTrue(all( + item["event"] == "generated" and item["status"] == "recorded_without_owner_transfer" + and item["result_record_ids"] + for item in delta["relations"] + )) + self.assertTrue(any( + item["feature_id"] == "sweep" and item.get("output_roles") == ["sweep.end"] + for item in result["topology_records"] + )) + self.assertEqual(downstream["runtime_diagnostics"], []) + self.assertTrue(any( + item["feature_id"] == "shell" and item["status"] == "resolved" + and item["selected"].get("output_roles") == ["sweep.end"] + for item in downstream["selector_resolution"] + )) + + def test_sweep_history_falls_back_for_hollow_profiles(self) -> None: + from build123d import Edge, Face, Plane, Vector, Wire + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + adapter = Build123dGeometryAdapter() + outer = Face(Wire.make_circle(4, Plane.XZ)) + inner = Face(Wire.make_circle(2, Plane.XZ)) + profile = adapter.face_with_holes(outer, [inner]) + result, delta = adapter.sweep_with_topology_delta( + profile, Edge.make_line(Vector(0, 0, 0), Vector(0, 10, 0)), + ) + + self.assertIsNone(delta) + self.assertAlmostEqual(float(result.volume), 120 * math.pi, places=5) + + def test_sweep_history_matches_native_builder_for_a_curved_path(self) -> None: + from build123d import Edge, Face, Plane, Vector, Wire + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + adapter = Build123dGeometryAdapter() + profile = Face(Wire.make_circle(2, Plane.XZ)) + path = Edge.make_spline([ + Vector(0, 0, 0), Vector(0, 10, 0), Vector(5, 20, 0), + ], scale=False) + direct, delta = adapter.sweep_with_topology_delta(profile, path) + native = adapter._sweep_without_topology_delta(profile, path) + + self.assertIsNotNone(delta) + self.assertTrue(direct.is_valid) + self.assertAlmostEqual(float(direct.volume), float(native.volume), places=5) + self.assertEqual(len(direct.faces()), len(native.faces())) + self.assertEqual( + [relation.output_role for relation in delta.relations], + ["sweep.start", "sweep.end"], + ) + + def test_initial_loft_captures_builder_proven_cap_evidence(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "loft-history", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [ + {"id": "lower", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 2}}, + { + "id": "upper", + "workplane": {"origin_mm": [0, 0, 10], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "circle", "radius_mm": 4}, + }, + { + "id": "extension", + "workplane": {"origin_mm": [0, 0, 20], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "circle", "radius_mm": 3}, + }, + ]}, + "features": [{ + "id": "loft", "atomic_id": "loft_add", "depends_on": [], + "params": {"profile_sketch_ids": ["lower", "upper"]}, + }], + } + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + result = rebuild_cdsl(cdsl, root / "loft.step") + extended = deepcopy(cdsl) + extended["features"].append({ + "id": "loft_extension", "atomic_id": "loft_add_with_cap_face", "depends_on": ["loft"], + "params": {"profile_sketch_ids": ["extension"]}, + "selectors": [{ + "kind": "face", "owner_feature_id": "loft", "output_role": "loft.end", + "source": "runtime_snapshot", "confidence": 1, + }], + }) + downstream = rebuild_cdsl(extended, root / "loft-extension.step") + + self.assertAlmostEqual(result["volume_mm3"], 280 * math.pi / 3, places=5) + delta = next(item for item in result["topology_deltas"] if item["operation"] == "loft") + self.assertEqual( + {item.get("output_role") for item in delta["relations"]}, + {"loft.start", "loft.end"}, + ) + self.assertTrue(all( + item["event"] == "generated" and item["status"] == "recorded_without_owner_transfer" + and item["result_record_ids"] + for item in delta["relations"] + )) + self.assertTrue(any( + item["feature_id"] == "loft" and item.get("output_roles") == ["loft.end"] + for item in result["topology_records"] + )) + self.assertEqual(downstream["runtime_diagnostics"], []) + self.assertTrue(any( + item["feature_id"] == "loft_extension" and item["status"] == "resolved" + and item["selected"].get("output_roles") == ["loft.end"] + for item in downstream["selector_resolution"] + )) def test_circular_pattern_rotates_a_sweep_profile_and_path(self) -> None: from cdsl_engine.runtime import rebuild_cdsl @@ -939,6 +2357,77 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(result["solid_count"], 1) self.assertAlmostEqual(result["volume_mm3"], 424.0, places=5) + def test_shell_removes_a_selected_cap_and_offsets_outward(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": False}, + "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-outward.step") + + self.assertEqual(result["solid_count"], 1) + # A shell is hollow on either side of the source skin. This confirms + # the exterior offset reached a distinct OCC result rather than + # silently using the inward default (424 mm^3 for this fixture). + self.assertGreater(result["volume_mm3"], 424.0) + + def test_shell_explicit_target_requires_the_live_face_member(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, "target_feature_id": "base_add"}, + "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) + + invalid = deepcopy(shelled) + invalid["features"][-1]["params"]["target_feature_id"] = "missing_body" + analysis = CapabilityAnalyzer( + atomic_ids={"extrude_add_blind", "shell"}, profile_types=SHAPE_GENERATORS, + ).analyze(invalid) + self.assertIn( + "shell_target_body_unavailable", + [blocker.code for blocker in analysis.feature_results[-1].blockers], + ) + def test_fillet_and_hole_wizard_use_resolved_face_edge_selectors(self) -> None: from cdsl_engine.runtime import rebuild_cdsl diff --git a/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md b/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md new file mode 100644 index 00000000..2a63c5a8 --- /dev/null +++ b/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md @@ -0,0 +1,514 @@ +# CADFS 全量能力闭环目标 + +## 目标 + +将 CADFS FeatureScript 的完整建模历史通用地转换为 CDSL,再由 CDSL engine +重建 STEP。目标不是让少数代表模型通过,也不是为某个样本拟合最终 STEP;目标是 +补齐当前 CADFS 全量语料实际出现的所有建模语义,使每个可比较模型都能以完整 +feature history 生成工程相似的 STEP。 + +工程验收使用 `comparison.json.rp.passed == true`:双向 surface max/p99 与 bbox +不超过 `0.02 mm`、体积和面积相对误差不超过 `0.005`、实体数相等。`strict` 的 +`0.01 mm` / `1e-5` 阈值保留为高精度诊断,不能因严格失败而隐藏已工程相似的模型, +也不能因生成 STEP 就把几何错误的模型标为成功。 + +本文件的范围是 +`output-history/20260907-235209` 的 9,347 条历史快照。后续重新扫描全量数据时, +必须以新快照的真实样本数和能力矩阵更新本文件及本地能力台账。 + +## 不可妥协的实现原则 + +这是绘图引擎和通用 converter,不是样本修复脚本。每项能力必须由 FeatureScript +语义、CDSL contract 和内核拓扑共同定义,并对同类输入普遍成立。 + +- 禁止以 `sample_id`、文件路径、特定尺寸、特定坐标、特定 feature ID 或 gold STEP + 测量值驱动行为。不得为让单条样本通过而植入分支、偏移量、默认 profile 或 selector。 +- 不得读取原 STEP 来补齐 FeatureScript 未提供的参数、反推 CDSL 尺寸或决定建模策略。 + 原 STEP 只用于最终比较、诊断和已验证的 source exception 证据。 +- 不得把未知语义降级成任意盲拉伸、默认 union、任意当前 body、任意相近 face/edge + 或静默跳过。无法唯一表达或绑定时必须保留可执行前缀、输出明确诊断,并记录能力 + 缺口。 +- 优先扩展显式 CDSL schema、typed runtime state、body graph 和 kernel-level topology + delta;不得以 parsing/lowering 层的临时重写替代应由 runtime/adapter 承担的几何语义。 +- 允许针对几何类别采用受限算法,但适用前提必须由通用的、可验证的几何条件表达, + 并有正向、反向和边界测试。条件不满足时必须拒绝并诊断,不得猜测。 +- 若已有实现反复依赖样本化补丁、不能表达已出现的通用语义或受内核 API 结构性限制, + 必须评估替代方案,不得沿错误方向继续累积补丁。替换需要可复现根因、成熟实现或最小 + 原型的对照、contract/迁移影响评估和回归计划;单个样本或偶发内核失败不足以触发重写。 +- 每项能力必须同时具备:FeatureScript lowering、CDSL schema/semantic validation、 + runtime/adapter 实现、selector/body 语义、单元测试、多个语料回归和比较工件;缺少 + 任一层只能标记为“部分完成”。 + +## 全量基线 + +历史快照的主要操作和当前能力缺口如下。数量是出现次数或受该缺口阻塞的模型数, +只用于排序,不代表一个操作只有一种语义。 + +| FeatureScript 操作 | 历史出现次数 | 当前主要缺口数 | +| --- | ---: | ---: | +| `newSketch` | 20,857 | 草图/工作平面诊断 7,043 | +| `extrude` | 18,168 | 3,600 | +| `fillet` | 5,210 | 1,904 | +| `revolve` | 2,087 | 662 | +| `chamfer` | 1,818 | 588 | +| `cPlane` | 1,620 | 168 | +| `hole` | 1,203 | 582 | +| `shell` | 729 | 696 | +| `mirror` | 483 | 241 | +| `transform` | 468 | 252 | +| `sweep` | 378 | 326 | +| `loft` | 377 | 107 | +| `circularPattern` | 252 | 139 | +| `booleanBodies` | 228 | 187 | + +已发现的派生 profile / topology 语义必须单独覆盖,不能合并为笼统的 `extrude` +或 `loft` 支持: + +| 语义 | 历史缺口数 | +| --- | ---: | +| `extrude_profile_topology:intersect` | 383 | +| `extrude_profile_topology:cap_face` | 201 | +| `extrude_profile_topology:cap_edge` | 134 | +| `extrude_profile_topology:swept_face` | 99 | +| `extrude_extent:up_to_surface` | 100 | +| `loft_profile_topology:cap_face` | 30 | +| `extrude_profile_topology:offset_face` | 18 | +| `extrude_profile_topology:swept_edge` | 16 | +| `loft_profile_topology:swept_face` | 9 | +| `extrude_extent:up_to_vertex` | 7 | +| `extrude_surface_or_mixed` | 4 | +| `extrude_extent:up_to_body` | 2 | +| `extrude_profile_topology:mid_cap_edge` | 2 | +| `loft_profile_topology:cap_edge` / `cap_vertex` | 1 / 1 | + +历史报告还记录了 1,335 个未支持操作诊断。当前已知的 P2 操作包括 `draft`、 +`thicken`、`split`、`moveFace`、`deleteFace`、`replaceFace`、完整 `transform`、 +`derive`、`import` 和 `bend_add`。重新扫描时必须从诊断原文生成完整操作清单; +未列出的新操作不能被静默归入已有能力。 + +## 覆盖模型 + +每一项能力的回归选择必须覆盖下面五个维度,而不是只覆盖操作名称: + +1. 操作和参数:operation mode、实体/曲面模式、extent、方向、offset、draft、 + 角度、数量、终止条件和 result mode。 +2. 几何输入:直线、圆弧、圆、椭圆、B-spline、开口/闭合 wire、多 region、孔洞、 + 退化和自交拒绝路径。 +3. 拓扑来源:原草图、`CAP_FACE`、`CAP_EDGE`、`SWEPT_FACE`、`SWEPT_EDGE`、 + `OFFSET_FACE`、`INTERSECT`、`MID_CAP_EDGE`、`COPY` 和 boolean 后继。 +4. body 生命周期:`NEW`、`ADD`、`REMOVE`、`INTERSECT`、keep tools、copy、 + transform、delete、mirror、linear/circular/nested pattern、多实体输出。 +5. 比较状态:strict、RP、实际几何拒绝、conversion defer、runtime ineligible、 + rebuild failure、comparison timeout 与 source exception。 + +维护三个固定层级的回归集: + +| 层级 | 用途 | 通过条件 | +| --- | --- | --- | +| 原子语义矩阵 | 每个 operation/参数/拓扑/body tuple 的最小合成模型 | CDSL contract、可执行性和精确几何断言。 | +| 核心 17 | 跨能力的稳定烟雾回归 | 完整 history、工程相似或有归因明确的 source/comparison 证据。 | +| 扩展 100+ | 每个已出现 tuple 至少多个真实语料样本 | RP 工程相似率、失败诊断和能力覆盖均可追溯。 | +| 全量 9,347 | 完整发布前的能力验证 | 每条都有完整分类;支持的完整 history 均工程相似,剩余项仅能是有证据的 source exception 或未实现 capability。 | + +扩展集必须从能力矩阵自动选择并保留历史失败样本。修复能力后应扩大相应 tuple 的 +样本数;不得因当前失败而从 manifest 中移除样本,也不得以新的贪心选择替换已有 +反例。 + +## 实施路线 + +### 开源实现策略 + +每个新的内核级能力在自研前,先检查官方 OCCT/OCP API、项目现有依赖和成熟开源实现。 +本地首选参考是 `/Users/lk/Downloads/SimpleCADAPI-master 4`:其 +`topology/tracking.py` 保留 OCC builder 的 `Modified`、`Generated`、`IsDeleted` 和 +section-edge 历史,`ocp_booleans.py` 展示了保留 history 的 boolean 与 same-domain +cleanup,其他 kernel/operators/tests 覆盖 transform、shell、sweep、loft、fillet 和 +chamfer。这些实现对 P0 topology delta、body provenance 和 P2 feature adapter 具有 +直接参考价值。 + +采用外部方案时必须确认许可证、OCP/OCCT 版本和异常语义,并将算法适配到本项目的 +CDSL contract、body graph 和 selector resolver。SimpleCADAPI 强制单一 `Solid` 的 +boolean helper 不能直接使用,因为 CADFS 需要独立 body、copy、keep-tools 和 pattern +instance 生命周期。每项采用或拒绝外部方案的决定、理由和回归证据都记录在本地能力 +台账;不得不经评估地复制代码,或为复用外部 API 破坏现有 contract。 + +### P0:通用几何状态和拓扑基础 + +1. 建立显式 body graph:feature 输出 body ID、result mode、copy、transform、delete、 + keep tools、pattern instance 和多 body 聚合语义。当前可用的受限增量是以 source + feature body 为输入的 rigid translation/rotation 与受限 non-rigid uniform-scale + `transform_bodies` / `delete_bodies` contract;它不能 + 代替 fused body 或 pattern instance 的完整 provenance,后两者在唯一性未证明时 + 必须诊断而不能退化为 current body。capability preflight 已镜像 runtime 的显式 + body-member 生命周期:fused/dress-up 输出、boolean 消耗、delete 和 pattern replay + 会撤销旧 member 的可选资格;只有 `new_body`、`keep_tools`、明确 transform 输出,或 + 经 sole-body lineage 已证明的 current successor,才能被后续 body query 选中。受限的 + `pattern_circular` 现在可对所有 source 都是明确可选 member 的 `NEW` additive body,或 + 一个 direct `SWEPT_BODY` 经该 sole-body successor 保留的 current member,以 + `pattern_feature_id` / `source_feature_id` / `instance_index` 记录 direct COPY + member;后续 `transform_bodies` 可精确引用该 COPY 而无需写入 runtime ID。CADFS 的 + direct `SWEPT_BODY` transform 现可将 `TRANSLATION_3D`、rotation,以及单一线性 + sketch/`CAP_EDGE` 或有 physical cap frame 的 direct `CAP_FACE` direction 的非负 + `TRANSLATION_DISTANCE` lower 为同一刚体 contract。系统 datum plane、已显式 lower 的 + reference plane,以及能由 direct planar source 唯一构成 frame 的 `SWEPT_FACE` 也可提供该 + direction;圆柱/曲面 `SWEPT_FACE`、`SWEPT_EDGE`、OFFSET/COPY face 和没有可证明 frame 的 + plane query 继续诊断而不猜测。`SCALE_UNIFORMLY` 在 factor 有限且为 + 正数、scale point 是 Origin、direct sketch vertex(含 circle center)或 direct + `CAP_VERTEX` 时 lower 为显式 `uniform_scale` body transform,不烘焙回草图。`TRANSLATION_ENTITY` 在 direct source + 仍是可选独立 body 时接受一个线性 + sketch/`CAP_EDGE` 的端点差或两个 sketch/`CAP_VERTEX` 的有序点差;move 的 bake path + 另要求 source 是紧邻的 `NEW` body。对于 exact single-source `makeCopy` transform, + 后续 `COPY(instanceName=1)` body/line/vertex 可递归验证其 `derivedFrom` 链并引用已生成 + body member;对于前序 direct multi-source `makeCopy`,CDSL 以 + `transform_copy_refs(transform_feature_id, source_feature_id)` 表示被查询的唯一直接 source, + runtime 分别物化各 source 的 transformed member,aggregate 只用于 STEP 导出且不伪造 + topology owner/delta。schema、semantic validation、capability preflight 和 runtime 都要求 + owner 是 preceding multi-source `make_copy:true` transform,且 source 是该 transform 的 + 直接选择项;single-source、source 外、fused、pattern 和无源限定的 aggregate reference + 一律拒绝。无位移的 direct `TransformType.COPY` 以显式 zero translation 与 + `make_copy:true` 产生一个独立 identity body member,绝不将其写成 non-copy successor + alias。CAP edge/vertex 的 source-local in-plane coordinates 保持 profile frame, + 仅映射到 selected physical cap origin。face、 + generic face、swept/offset、pattern 或未证明 COPY/curve direction、多个 direction、已吸收 body 与其它非刚体 + transform 仍必须明确诊断。对于已证明只有一个 CADFS body 的受限 lineage,direct + `SWEPT_BODY` 可从初始 `new_body` 经 ordinary fusing add、单 body fillet/chamfer/shell + 与 exact non-copy transform 续接到当前 CDSL member;cut 保留所选 member,因而不虚构 + 一个 cut successor。直接 circular ADD 且 source 唯一等于该 current member 时保留这份 + 证明,并以该成员的实际 B-rep 物化每个 COPY instance;第二个 `new_body`、`make_copy` + transform、boolean、其它 pattern、delete 或任何未列出的 body atomic 都清空它。它绝不以 + aggregate/current body 兜底, + 也不为 linear/nested pattern、被排除 instance 或无明确 source member 建立 ownership。 + 唯一新增的 + mirror 例外是:直接 `pattern_mirror` 的每个 source 都仍为可选的 + `result_mode: new_body` member 时,runtime 以实际 B-rep mirror 物化其唯一的 + `instance_index: 1` COPY member;CDSL semantic validation、capability preflight 与 + runtime 都要求这一完整条件。普通 additive/cut/dress-up 后继、已吸收 source、linear + 或 nested mirror 仍走 feature replay,不能借该例外暴露 aggregate member。 + 本轮 327 条 transform shard 中 `00120430`、`00496729`、`00749755` 因该 provenance + rule 完整 lower;只有 source 在 mirror 前未被后续 mutation 吸收的 `00749755` 可执行到 + RP `rebuilt_approximate`,另两条保留 `runtime_ineligible` 和已有前缀/诊断,绝不以 + converter 完整状态冒充 runtime 或相似通过。新增的 multi-source COPY path 令 `00699847` + 的 F3 两源 copy 和 F4 的 source-qualified F3/F1、F3/F2 queries 完整 lower,强制 + pipeline 为 `rebuilt_strict`;`00950564` 证明 F1 的 direct `SWEPT_BODY` 在 F3/F5/F7/F9 + ordinary add 和 F10/F11 chamfer 后精确 lower 为 F12 的 `source_feature_ids:["f_F11"]`, + 不引用 current aggregate。`output/single-body-successor-20260909` 的完整 history 为 + `converted_complete`、runtime eligible、8/8 feature executed、`rebuilt_approximate`; + RP 通过(bbox delta `0`,体积/面积相对误差 `0.00011913` / `0.00016132`,实体数 `1`)。 + strict 仍因 max/p99 surface `0.01 mm` 与体积/面积阈值失败而保留为诊断。单元测试还覆盖 + copy、boolean、pattern 与 delete 均清空 lineage;没有能唯一表示的 nested source-member + chain 仍属未完成 contract,继续保留明确诊断。 +2. 在 adapter 层记录每次建模操作的 `preserved`、`modified`、`generated`、`deleted` + topology delta 及 CAP/SWEPT/boolean 等输出角色;runtime 以该记录完成后继 selector + 绑定,不以“当前形状中最相近元素”猜测。当前增量已覆盖 OCC 刚体 + `transform_bodies`(含 OCP `gp_Trsf.SetScale` 的 uniform scale)、单实体 + `boolean_bodies` 的 union/subtract/intersect 以及 direct + single-body fillet/chamfer,以及单一 selected solid 的 `shell`:adapter 返回 opaque + kernel relation,registry 只在 source/result snapshot 的 exact relation 唯一时传递 + owner 和 successor,并在 rebuild 输出 `topology_deltas`。shell 沿既有 + `BRepOffsetAPI_MakeThickSolidByJoin` builder 的 `Modified`、`Generated`、`IsDeleted` + 历史记录 delta;未返回 `IsDeleted` 的内核输入不会被虚构为 deleted。该受限 shell path + 还记录可由 source subshape 和 kernel event 证明的 `shell.body_face`、 + `shell.offset_face`、`shell.closing_descendant` 和 `shell.wall` evidence role。当前 + `shell.offset_face` 只有一个受限 lowering consumer:紧邻 shell 后续的 shell face query, + 且 FeatureScript 以 `TDD`/`trueDependencyDisambiguation` 明确指向前序 direct `new_body` + blind-prism 的 `extrude.start` 或 `extrude.end`。CDSL 以 + `output_role_source(owner_feature_id, output_role)` 保留此关系;registry 只接受 exact + kernel-history relation 的唯一 active snapshot,不能以相同 geometry、bare offset role 或 + 任意嵌套 CAP query 替代。它仍不是可 lower 的通用 CAP/SWEPT/OFFSET query。多个 Compound 成员时只有被选中实体的同一 + builder 可以产生 delta,其他未改成员继续依赖 body graph 的精确等价。多实体 boolean、 + 非 direct-builder dress-up fallback、复杂 sweep/loft history 及 CAP/SWEPT/section + query roles 仍属未完成范围。受限的单闭合无内环 profile、无 guide/transition 的 direct + solid sweep 在 initial 或 `new_body` final member 上,会记录 + `BRepOffsetAPI_MakePipeShell.FirstShape/LastShape` 的 `sweep.start`/`sweep.end` + evidence;受限的 initial closed-wire solid loft 会记录 + `BRepOffsetAPI_ThruSections.FirstShape/LastShape` 的 `loft.start`/`loft.end` evidence。 + 后续 fuse 会重建 topology,因此不复用已经失效的 sweep/loft builder history。当前 + CDSL `selectorRef.output_role` 现公开 direct-prism `extrude.start`/`extrude.end` 与四个 + sweep/loft direct-builder cap role;registry 只会在 + relation 的唯一 result snapshot 上写入 `TopologyRecord.output_roles`,resolver 要求 + owner、active body snapshot 与唯一 role 命中,并拒绝 stable ID、geometry、snapshot ID + 混用。CADFS lowering 现对独立 `new_body` blind prism 的 CAP_FACE,以及满足全部条件的 + `LocOpe_DPrism` drafted extrusion 生成该 contract:单一闭合外环、无内环、单向 blind + extent,且拉伸方向与实际端盖法向同向。`extrude_from_face` 直接拉伸该 B-rep 面,不能把 + CAP_FACE 还原为原始草图。registry 只会让唯一 OCC `modified`/`preserved` relation 跨 + boolean 删除成员后的 Compound member-index 改变继续传递 owner/output role;几何回退仍按 + member index 隔离,绝不以相似性跨成员继承。该路径不覆盖 drafted 内环、fused/multi-region/ + multi-extent/loft/sweep/pattern 或一般 CAP_FACE,故 `CAP_FACE`/`SWEPT_FACE` lowering 仍为 + 部分完成。`00016195` 的 F1 -> fillet -> F3 和 `00835610` 的 drafted F3 -> F4 subtract -> + F5 都是正例;后者为 `converted_complete` / `rebuilt_approximate`,9 个 feature、3 个实体, + RP 通过(bbox delta `0.0008 mm`、体积相对误差 `0.00020848`、面积相对误差 + `0.00021742`)。`00268467` 的 drafted 内环则明确保留 capability diagnostic 和可执行 + 前缀,不伪造可绑定 selector。 +3. 建立统一 selector resolver:owner body、source feature、output role、几何签名、 + snapshot 和唯一性证明。找不到或多解必须稳定诊断。 +4. 完成草图 region/wire 模型:多轮廓、内环、开口 reference geometry、B-spline/ellipse、 + profile query、显式 construction 和退化检测。 + 非周期 `skFitSpline` 的两点受限变体现已贯通:仅当端点不同、同时给出两端导数,且 + CDSL 显式携带严格递增的参数域时,lowering 才以真实的 two-point B-spline 输出;它 + 绝不退化为直线。schema、semantic validation、sketch solver 与 adapter 共同拒绝缺失 + 导数/参数、周期两点和重合端点。solver 以参数域上的 cubic Hermite 采样检查闭环面积, + adapter 仍把端点切线与参数交给 OCP 插值,而非近似多段直线。历史快照中 453 个此类 + entity 都带两端导数;当前 10 条历史反例矩阵的 9 个可生成候选保留了 18 条该曲线。 + 该增量只完成此一 profile contract,不覆盖由 `INTERSECT`、`CAP/SWEPT` 等拓扑查询 + 派生的 profile,也不把后续 dress-up 或内核失败误记为 spline 成功。 + +### P1:派生 profile 与拉伸终止 + +1. 用 P0 topology registry 通用重放 `CAP_FACE`、`CAP_EDGE`、`SWEPT_FACE`、 + `SWEPT_EDGE`、`OFFSET_FACE`、`INTERSECT`、`MID_CAP_EDGE`,将其物化为可追溯的 + profile/wire,而不是复用原草图或写入样本特例。现有 direct-prism,以及单闭合无内环、 + 法向同向的 `LocOpe_DPrism` draft CAP_FACE,是受限例外:它们以 builder-proven output + role 直接消费物理 B-rep 面,未试图物化或重建 profile。 + `OFFSET_FACE` 现有一个独立的受限 profile 物化路径:只有 inward shell 直接消费一个 + `new_body` blind additive prism,shell 恰移除该 prism 的一个 CAP,且 OFFSET query 恰指向 + 其未修改、单闭合凸线性 profile 中的一条非 construction line,lowering 才根据 source + profile 的相邻边 offset 交点、shell thickness、实际 start/end cap frame 构造四边内壁。 + 它不复用整张 source sketch,也不从 STEP 反推 trim;draft、cut/fused/multi-body source、 + 多 removal cap、曲线/凹/多 contour profile、多个 source edge 和非正交 span 都保留 + `extrude_profile_topology:offset_face` 诊断。`00789939` 的 F15 是严格通过的完整 history + 正例;`00588094` 的非直接 provenance 则保持 partial conversion 和五 feature executable + prefix,后续 F15 的 output-role binding failure 仍单独报告。`output/offset-face-linear-wall-20260909-r3` + 记录前者 strict/RP 通过;当前 26 条含 `OFFSET_FACE` history 的受控 shard + `output/offset-face-profile-shard-20260909-r1` 为 1 strict、17 rejected、7 rebuild failure、 + 1 deferred-no-executable-feature,仍有 17 条明确的 `extrude_profile_topology:offset_face` + gap。`output/core-17-offset-face-linear-wall-20260909-r2` 为 4 strict、3 RP、9 rejected、1 + comparison timeout,故这仍是派生 profile 的部分完成项而非 general OFFSET replay。 + 对 CAP_EDGE,现有的受限 outer-profile 规则还会递归展开仅由 `qUnion` 构成的关联嵌套, + 再要求恰有一个原始闭合 IMPRINT 外 region 和一个唯一 CAP_EDGE-derived IMPRINT region, + 并由两个明确 face-side 判断完整外 region 或外环加孔。该展开只消除 FeatureScript 局部 + alias 引入的集合包装,不改变 query provenance,也不放宽 owner、曲线或 side 的唯一性。 + `00950564` 的 F3/F5 是这一语义的真实证据:两个 CAP_EDGE profile 都完整 lower,F1--F12 + history 也因上述单 body successor rule 完整执行并 RP 通过。它不覆盖一般 CAP_EDGE、CAP + edge 物理重放、COPY/boolean/pattern 后继或未证明的 region 选择。双向 blind extrusion + 同样沿用单向 CAP 顺序:`isStart:true` 是主方向的反侧端盖,`isStart:false` 是主方向端盖; + 因而 second depth 趋近于零时保持同一 contract,不按 primary/reverse 字段名称猜测。 + `00247322` 的 F4 以 `z=40.3 mm` 的反侧 cap 附着 F6,F5 后该 F4 owner 仍有唯一 + continuation,F7 的 CAP_EDGE hole profile 可执行。后续 F9 选择已被 F5 消耗的 F4 member, + 故保持 `body_source_unavailable`,不烘焙 transform 回 F4 草图或替换为 aggregate body。 + 刷新的 `output/cap-edge-two-sided-frame-matrix-20260909` 三样本矩阵为 1 个 + `rebuilt_approximate`(`00950564`)、1 个 `rebuilt_rejected`(`00803284`)和 1 个 + `runtime_ineligible`(`00247322`,但 F1--F7 prefix 可执行)。共享 lowering 的固定核心 + 17 工件 `output/core-17-two-sided-cap-frame-final-20260909` 分类为 3 strict、3 RP、9 rejected、 + 1 rebuild failure、1 comparison timeout,与此前共享基线一致。受控全量 24 条 shard + `output/full-shard-two-sided-cap-frame-20260909` 记录 1 strict、2 RP、10 rejected、6 rebuild + failure、3 runtime-ineligible、1 timeout 和 1 无可执行 feature;其 conversion diagnostics + 仍归因为已有的 `cPlane`、CAP_EDGE、fillet/chamfer、hole、revolve 与 shell selector 缺口, + 没有将它们重分类为双向 CAP frame 成功或失败。 + `INTERSECT` 的第一条通用受限路径现以 CDSL `planar_imprint` 保存同一草图的原始 + analytic entities、IMPRINT 面侧、可选 INTERSECT vertex order 与 fragment side;solver + 仅转换这些精确曲线,adapter 用 OCP `BOPAlgo_Splitter` 切分有界 support face 后选取实际 + B-rep region。没有唯一 fragment、选中无界 support-boundary region、construction/source + alias、split failure 或不完整 query 都稳定拒绝,不把 B-spline/arc 采样成多边形,也不复用 + 整张草图。该路径覆盖 bounded line/arc/circle/ellipse/B-spline arrangement 的 shared + contract,但尚未覆盖不同 sketch、surface/topology producer、trim/copy/pattern 后继或完整 + FeatureScript IMPRINT query grammar,因此仍是 P1 部分完成项。工件 + `output/planar-imprint-intersect-20260909-runtime` 是 CAP_FACE 扩展前的历史工件: + `00082324` 与当时的 `00835610` 都保留可执行 STEP 但 `rp.passed == false`,`00071859` 和 + `00093912` 分别保留既有 `TopoDS::Solid` 和 revolution-segmentation runtime failure。 + 当前 `output/drafted-cap-face-20260909` 已证明 `00835610` 在 drafted CAP_FACE continuation + 与该 IMPRINT 后续均执行时 RP 通过;这不改变 `planar_imprint` 的其它失败分类,也不构成 + 该能力的完整覆盖。共享核心 17 回归 + `output/core-17-planar-imprint-20260909` 分类保持 3 strict、2 RP、10 rejected、1 + rebuild failure、1 comparison timeout。 + 对同一逻辑 circle source,solver 现在保留一条带 workplane frame 的 OCC circle edge,而不将 + 它预拆为四条 contour arc;fragment 的 directed successor/predecessor 由 splitter image 在 + 精确交点处的端点和原 curve 同向切向解析,避免周期参数接缝的数值排序歧义。无 fragment + 的 source 代表全体 splitter descendants:只有其给定 face side 的每一个 B-rep face 都有界时, + adapter 才保留完整 face 集;任一 support-boundary face、非唯一 side 或 fragment 都拒绝, + 不按 image 顺序/长度选择或静默省略。`output/intersect-circle-imprint-matrix-20260909-r2` + 记录 `00001313` F1/F3 完整执行但 RP 拒绝(bbox delta `0.023571 mm`)、`00004246` + 的 F1/F3 executable prefix 和三个仍有归因的失败。`00040869` 的 line/ellipse + `OD(0)` pairs 以 OCP exact distance 均为 `0.004240908 mm`,没有 splitter image 或 + section edge,故 `output/intersect-circle-imprint-source-gap-20260909-r1` 保留 F1 prefix + 并报告 source/anchor 无实际交点,绝不以容差补点;核心 17 + `output/core-17-intersect-circle-imprint-20260909-r2` 为 4 strict、3 RP、9 rejected、1 + comparison timeout。故这仍只是同草图有界 exact-curve arrangement 的 P1 扩展,不能宣称 + general INTERSECT replay 已完成。 +2. `UP_TO_NEXT` 已 lower 为 CDSL `through_next`,并有一个受限的 current-body + contract:所有 profile sample ray 都在当前 body 命中时,保留 profile 到首个正向 + 命中面之前的外部材料;只有部分 ray 命中时,以实际命中 face 做有限体层裁剪;没有 + 命中时明确拒绝,不退化为盲拉伸。`00192744` 的 F5/F10/F14 已由该 contract 完整 + 执行,但其独立 body 与 source STEP 的融合差异、后续 multi-body transform 和 source + history/STEP 不一致仍单独分类。这是部分完成,不代表复杂 profile、多 body target、 + source body lifecycle 或第二方向终止已支持。 +3. 在上述受限路径之外,完成 `up_to_surface`、`up_to_next`、`up_to_body`、 + `up_to_vertex`、two-sided、through-all、surface/mixed 的有限交集语义和多 body + target 选择。 +4. 仅在所有 profile rays、target body 和 offset 均能由通用几何验证时执行;否则保留 + 前缀并说明哪一个终止条件无法证明。 + +### P2:实体特征的完整语义 + +1. `extrude`、`revolve`、`loft`、`sweep`:补齐实体/曲面模式、复杂 profile、导轨、 + section correspondence、twist/transition、path/axis selector 和结果 body。现有 direct + initial/new-body simple sweep 与 initial simple loft 现可将 builder-proven cap evidence + 作为受限 CDSL output-role selector 供 downstream feature 消费;同一 contract 还支持 + independent blind prism CAP_FACE 的 `extrude_from_face`。这些路径仍不能代替完整的 + derived-topology selector 语义,CADFS lowering 也尚未以此表示一般 CAP/SWEPT query。 +2. `shell`:单一 selected solid 的 direct-builder topology delta 与有限 output-role + evidence 已覆盖。CADFS lowering 现额外接受一个受限的 removal selector:直接 blind 或 + two-sided linear extrusion 的立即后继 shell,可从同一未修改 source profile 中唯一的、非 + construction `line` 原始实体生成平面 `SWEPT_FACE` placeholder,再由 prefix snapshot + 唯一绑定。该规则要求 owner、profile、start/end frame、blind extent 和单 body + lifecycle 都可证明;revolve/sweep/loft、圆或其它非线性 source、changed profile、copy/ + pattern/boolean/dress-up continuation 和多 target body 均继续诊断,不能回退到当前 body + 或相近面。CADFS `oppositeDirection` 已显式 lower 为 `inward:false`,由既有 OCC + `MakeThickSolidByJoin` 的有符号 offset 执行;这只复用已验证的 removal selector 和单一 + selected solid contract,不会将 OCC feasibility failure 伪装成 inward shell 成功。仍需任意 + 稳定 removal face、多 body target、其它 join/offset 参数语义和更广的 kernel failure + 诊断回归。作为进一步的窄例外,紧邻 shell 的 `OFFSET_FACE` removal query 可在其 + `disambiguationData` 中只有一个 direct `TDD` CAP source,且 source 是前序 direct + `new_body` blind-prism cap 时 lower 为 `shell.offset_face` 加 source-qualified output role。 + 没有 `TDD`、多个 source、draft/fused/COPY/pattern/boolean source、非邻接 owner 或无 + active unique snapshot 都稳定诊断。`shell.parts` 现有一个独立且同样受限的 body-source + lifecycle contract:只有 parts query 可唯一解析为仍存活的 direct `SWEPT_BODY` independent + member 时,lowering 才写入 CDSL `target_feature_id`;capability preflight 和 runtime 都要求 + 该 ID 的 exact B-rep solid 仍在 active member graph 中,不能以 `session.body`、current + aggregate 或几何近似替代。`00107631` F3 中 F1 可证明地继续为 `f_F2`,因而是正例; + `00542223` F7 在 fuse/pattern 后不再有独立 member;runtime 仅在 pattern 前后都是一个 + 可执行实体且 source 唯一时保留 direct source 的 sole-body alias,因此 parts-scoped shell + 可以验证为当前实体,而不把 aggregate 伪装成一个独立 member。此路径尚未覆盖 multi-part / + multi-body parts query、COPY、boolean 或 pattern instance 的通用 successor;这些情形必须 + 保留可执行前缀或既有运行路径,不能宣称 explicit target-body semantics。实现参考 + SimpleCADAPI(Apache-2.0)`IsSame` member identity 语义,未复用代码;其单 Solid boolean + 假设未被采用,因为它与 CADFS 的独立 body 生命周期不兼容。当前验证为:受影响 + lowering/integration/selector/runtime suite `218 passed`(3 条既有 workplane warning); + `output/core-17-shell-parts-20260909` 为 3 strict、3 RP、9 rejected、1 rebuild failure、1 + comparison timeout。`output/shell-parts-matrix-20260909` 的 21 实样本中,10 个 shell 写入 + explicit `target_feature_id`,结果为 13 rebuilt-rejected、7 rebuild-failed、1 runtime-ineligible。 + `00107631` 的 F3 在这项 target contract 下通过 converter/preflight。其普通、非 instance-locked + `CAP_FACE` selector 只有在 active owner candidates 均不满足完整 geometry signature 时,才可在 + 当前 active member 中选择唯一的 threshold-qualified face;这不能替代 `output_role` relation, + 也不适用于 `owner_match_required` COPY provenance。因而 F3 的两张 removal faces 分别严格绑定 + 为 `f_F2:face:1` 和由 `(f_F1, extrude.start)` 证明的 `f_F2:face:5`。该 feature 随后由 OCC + 报告 invalid thick-solid,而不是 selector-not-found;系统保留 F2 executable STEP prefix,不会 + 修改厚度、重选面或伪造 shell 成功。`00542223` 的完整 history 现可 rebuild,但仍因几何比较 + rejected;这不是 RP 通过声明。`output/core-17-fused-sole-body-pattern-provenance-20260909-r2` + 的新代码工件为 3 strict、3 RP、10 rejected、1 comparison timeout;五样本 + `output/fused-sole-body-pattern-provenance-20260909-r2` 中 `00542223` 与 `00423838` 均为 + rebuilt/rejected,`00293508` 保持 RP,`00253824` 仍在无关 `planar_imprint` 路径失败并保留 + F3 prefix。这些结果不能作为 complete shell 或 RP 相似通过的证据。 + 无 geometry 的普通 context selector 同时保留既有的唯一 active same-kind record 回退;它只在 + source owner 没有 active record、context 内候选唯一时成立。`owner_match_required` 的 + COPY/instance selector 没有此路径,零 owner candidates 仍是 `selector_not_found`。该边界的 + 原子正反测试与完整 CADFS 测试均通过(11 个 selector test、118 个 CADFS test)。刷新后的 + `output/core-17-selector-context-full-20260909` 仍为 3 strict、3 RP、9 rejected、1 rebuild + failure、1 comparison timeout;`output/shell-parts-selector-context-fallback-matrix-20260909` 的 + 21 条分类仍为 13 rebuilt-rejected、7 rebuild-failed、1 runtime-ineligible。这是 selector + 绑定范围修正,不增加 shell 的已完成语义,也不改变 OCC invalid-shape 的保留前缀诊断。 +3. `fillet`、`chamfer`、`hole`:完整 selector、tangent propagation、参数变体、 + feasibility 检查和 dress-up 后继。当前 fillet/chamfer lowering 对 direct blind/ + mid-plane extrusion 的唯一源端点,以及 direct two-section loft 的每 section 一对唯一 + 源端点,可生成可绑定的 endpoint-bbox `SWEPT_EDGE` selector;前者额外证明为直线, + 后者不声称曲线类型,因为内核可将其物化为 B-spline。独立 `NEW` 的 full solid + revolve 也可在 profile 与 axis 都是同一原始草图的直接来源、original-set 唯一指向一 + 个非轴端点时,按 axis projection 生成 `circle_center_mm` / `radius_mm` 圆边签名; + adapter 只从实际 OCC circle edge 记录该签名。一个 `IMPRINT` profile 仅在 materialized + workplane 和完整 profile 与原草图完全相等时可使用同一来源证明;任何实际 region 选择 + 都不继承该 contract。该受限路径不覆盖 sweep、surface/partial/fused revolve、 + multi-section loft、generated/trimmed/transformed profile 或 boolean/pattern 后继;任一 + 端点、axis 或 owner 不唯一时必须保留前缀并诊断。内核不能完成时不能伪造较小半径或不同 + 孔型。 +4. `booleanBodies`、`mirror`、`circularPattern`:显式 source/target bodies、实例输出、 + nested pattern、remove/intersect/keep-tools 和 transform 后继。当前 direct circular + `NEW` COPY -> rigid transform,以及 direct mirror 的 `NEW` source -> `instance 1` + COPY -> transform 是受限增量;不代表 linear/nested pattern、镜像 aggregate source 或 + fused instance 已有完整 output body lifecycle。mirror 的 `mirrorPlane` 另有一个受限 + `SWEPT_FACE` contract:唯一 direct full solid `revolve_add` 的原始 source line 与 revolve + axis 可由既有 `_query_plane` 证明为同一物理平面时,lowering 物化显式 + `reference_plane` 后交给现有 `pattern_mirror` 执行。它不从 STEP 或 current body 推断 + plane;partial revolve、非 revolve、curve/derived face 或任一未解析 frame 都保留原有 + mirror-plane diagnostic。`00000385` 的 F5 是正例(XY plane,F5 executed); + `00023074` 保留拒绝边界。五样本 + `output/mirror-revolve-plane-matrix-20260909` 为 2 RP、3 rejected,且只移除了 + `00000385` F5 的 plane defer,F2 chamfer 与 F7 fillet 仍如实诊断。`booleanBodies` + 现在也有一个独立、受限的 COPY body contract:direct `SWEPT_BODY` 和已存活的 + mirror/circular `COPY(SWEPT_BODY)` 分别 lower 为 feature ID 和 + `{pattern_feature_id, source_feature_id, instance_index}`,runtime/body graph 仅从该 + tuple 解析对应独立 body member,绝不以 producer aggregate 或 current body 代替。 + direct `SWEPT_BODY` 若唯一解析到上述 fused sole-body successor,也会以 successor 而非 + creator 作为 circular source;`00253824` 的 F1 -> F3 cut -> F4 add -> F5 circular 的 + source 因而是 `f_F4`,其 F6 targetless UNION 保留 `f_F4` 与 F5/F4/instance-1,2 的 + 明确 ownership。该完整样本目前在无关的 F4 `planar_imprint` runtime selection 失败时 + 保留 F3 executable STEP,不能作为 pattern/boolean RP 通过证据。合成 fused-body + circular COPY -> boolean runtime 回归完整执行。多 body、boolean 后继、nested/linear + pattern、被排除 instance 和 CAP/face topology ownership 仍未覆盖。 + targetless FeatureScript `UNION` 只在 `keepTools:false` 且至少有两个 explicit members + 时按 query 顺序确定一个 target;`00293508` 的 direct F1/F3 UNION 以 F1 target、F3 tool + 完整执行并 RP 通过。targetless subtract/intersect、keep-tools、无效或已经吸收的 instance、 + 以及 multi-source transform COPY 仍保持拒绝。`00000385` 的 F6 因而以 `f_F1` 为 target、 + F5/F1/instance-1 为 tool 完整 lower 并执行,F2/F7 保持其原有 diagnostics。四样本 + `output/boolean-pattern-copy-provenance-matrix-20260909` 为 3 rejected、 + 1 runtime-ineligible;其中其它 sweep、keep-tools/subtract、shell/hole 和 selector 缺口均 + 未被重分类。新的 `output/core-17-boolean-pattern-copy-provenance-20260909` 仍为 3 strict、 + 3 RP、9 rejected、1 rebuild failure、1 comparison timeout。direct circular ADD 现将每个 + transformed COPY 的面、边、顶点沿 exact OCC transform/fuse history 传入 final active snapshot; + 只有一个 source 和一个 final result 的 relation 才传递 `f_pattern.cN.f_source` owner, + split/merge/no-history 仍不声明 owner。`00542223` 的 F7 removal faces 和 `00423838` 的 + `COPY(CAP_FACE)` 因而都在 active snapshot 中绑定为真实 instance owner;后二者的最终 STEP + 仍分别 rejected,不能将 selector 可执行性描述为几何相似通过。direct-new wedge 的原子回归同时 + 验证三个 copy owner 都出现在最终 fused body。nested/linear pattern、boolean 后继、被排除 + instance 和没有 exact history 的 CAP/face ownership 仍未覆盖。 + CADFS 的静态数值表达式还允许无歧义的 `round(N)`,但仅当 `N` 已是有限整数,因而该 + 表达式可被证明为恒等;`00003011` 的 `round(8)` pattern 由此完整 lower。非整数 + `round`、变量和其它函数不会在 converter 中猜测 FeatureScript 的数值语义。这只是 + lowering 前置条件,不能作为 COPY owner、pattern instance lifecycle 或 RP 相似通过的 + 证据。 +5. `cPlane`:所有历史出现的 plane constructor、曲线/曲面 attachment、方向手性和 + 退化输入。`CPlaneType.OFFSET` 必须将 CADFS `oppositeDirection` 编译为 source plane + normal 上的负 signed offset,而不是忽略该 flag 或翻转 plane frame;后者会镜像同一草图的 + local coordinates。该 shared lowering contract 已覆盖 272 条实际带该 flag 的 history。 + `00777619` 将 Right plane 的 `76.2 mm` offset 修正为 `x=-76.2 mm`;因其 profile 只有部分 + 射线到达 selected `SWEPT_BODY`,`up_to_body` 仍以非均匀 target 诊断保留 F2 prefix,不能 + 被这一 plane 修复伪装成成功。Right/Front/Top 的三个真实 matrix 例与 24 条分层 shard + `output/cplane-offset-opposite-shard-20260909` 分别验证 frame 和独立分类:3 strict、1 RP、 + 13 rejected、7 rebuild failure。共享 core-17 + `output/core-17-cplane-offset-opposite-20260909` 分类保持 3 strict、3 RP、9 rejected、1 + rebuild failure、1 comparison timeout。它仍只是 cPlane 的一项通用 frame contract,不覆盖 + 曲面/曲线 attachment、退化输入或完整 derived-topology plane semantics。 + +### P3:尚未支持的 FeatureScript 操作 + +按全量诊断计数建立 operation registry,逐项为 `draft`、`thicken`、`split`、 +`moveFace`、`deleteFace`、`replaceFace`、完整 `transform`、`derive`、`import`、 +`bend_add` 建立 schema、lowering、runtime、adapter 和回归。外部资产或生成器缺失时, +必须将 capability 标为不可执行并提供部署诊断,不能在 import 时影响无关模型。 + +### P4:比较、来源和性能 + +1. 保留 strict 与 RP 两层几何比较,并为被精确量快速拒绝的边缘模型提供可控的 + 离线 surface 诊断预算;comparison timeout 是基础设施问题,不能混入几何失败。 +2. 输出 source/rebuild 多视图、局部差异、body 体积/面积和最近 feature 的关联证据。 + 截图只用于人工复核,不能替代多视角 B-rep 比较。 +3. 识别 source STEP 非闭合、FeatureScript literal 与 export 精度不一致、缺失资产等 + source exception。它们必须保留原始导入/检查证据,不能修饰 CDSL 以伪造通过。 +4. 使用 capability shard、缓存中间工件和可恢复 worker 隔离完成全量回归;任何 failure + 都必须保留最后一个重新绑定 selector 并以 strict 模式实际执行成功的前缀 STEP、GLB + 和诊断。不得把未绑定候选或当前 body fallback 伪装成可执行工件。 + +## 每项能力的完成定义 + +一项能力仅在满足以下条件时可标记完成: + +1. 有版本化的 CDSL 表达,且 schema/semantic validation 拒绝不完整或歧义输入。 +2. lowering 仅根据 FeatureScript source 产生该表达,并保留 source feature、selector + 和 body provenance。仅有 runtime CDSL contract 而未由 lowering 产生的能力必须标为 + 部分完成。 +3. runtime/adapter 按通用算法执行,记录结果 body 和 topology delta,不依赖样本信息。 +4. 原子语义矩阵包含正向、边界和拒绝测试;至少多个真实语料样本覆盖不同几何和 + 生命周期组合。 +5. 受影响核心集、扩展集和全量 shard 有可复现结果,工程相似通过率、失败数和剩余 + exception 均更新到本地台账。 +6. 代码审查确认没有 sample-specific 分支、gold STEP 参数回填、隐式默认尺寸或为 + 通过比较而降低 RP 阈值的行为。 + +## 执行节奏 + +每次改动依次执行:受影响原子测试 -> 受影响真实样本 -> 核心 17 -> 扩展能力矩阵 -> +对应全量 shard。完成一个 capability family 后,更新 +`ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`,记录新增的通用 contract、剩余边界、 +样本列表、比较证据和下一优先级;该本地台账不提交 Git。 + +全量目标完成时,报告必须按“工程相似”“严格一致”“source exception”“未实现能力” +和“基础设施失败”分别给出分母、样本 ID、工件和原因。任何仍可执行的模型都必须 +保留输出,不能因未满足最终几何目标而丢弃。 diff --git a/cadfs_to_cdsl/CADFS_RECONSTRUCTION_TARGET.md b/cadfs_to_cdsl/CADFS_RECONSTRUCTION_TARGET.md index 73375943..d16a8b3f 100644 --- a/cadfs_to_cdsl/CADFS_RECONSTRUCTION_TARGET.md +++ b/cadfs_to_cdsl/CADFS_RECONSTRUCTION_TARGET.md @@ -3,11 +3,12 @@ ## 目标 将 CADFS FeatureScript 历史稳定转换为 CDSL,由 engine 重建 STEP,并与 -原始 STEP 做严格几何比较。近期交付门槛是 17 个代表性样本全部严格通过; +原始 STEP 做工程几何比较。近期交付门槛是 17 个代表性样本全部达到工程相似; 最终交付门槛是当前 CADFS 全量语料中的每个可比较模型都完成同一条链路。 -这里的“重建成功”仅指 `comparison.json` 的 `strict.passed == true`,不是 -仅生成 `rebuild.step`,也不是只通过 RP 宽松阈值。 +这里的“重建成功”指 `comparison.json` 的 `rp.passed == true`,不是仅生成 +`rebuild.step`。`strict.passed` 保留为高精度诊断指标:它用于发现源历史精度、 +内核拓扑和局部几何差异,不再作为工程可用模型的唯一验收门槛。 本文件记录目标、阶段门槛和验收口径。实现进度、能力边界和每次代码变更 后的证据记录在本地台账 @@ -22,33 +23,34 @@ FeatureScript 操作、草图实体、已 lower 的 engine atomic 和未支持能力变体。 - 本地能力台账记录该池已有 `17/17 rebuilt` 的可执行 STEP 证据;当前 checked-in manifest 保留的是历史选择时的 `3` 个 engine baseline / `17` 个 conversion - 样本分类。因此,开始严格回归前必须重新生成并核对 manifest,不能将旧分类 + 样本分类。因此,开始工程回归前必须重新生成并核对 manifest,不能将旧分类 当作当前通过结果。 - 当前全量报告中仍有 `rebuild_failed`、`rebuild_timeout`、`comparison_timeout` 和 - `rebuilt_rejected`。这些都是待消除的问题,不可因保留了可执行 STEP 而视为完成。 + `comparison.json.decision = rejected`。这些都是待消除的问题;`approximate_pass` + 是已重建且工程相似的模型,仍应保留 strict 差异证据供后续能力完善。 ## 验收口径 -一个样本必须同时具备以下工件和结果,才计入严格重建成功: +一个样本必须同时具备以下工件和结果,才计入工程重建成功: 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`,以及实体数相等。 +4. `comparison.json` 的 `decision` 为 `strict_pass` 或 `approximate_pass`,并满足 + RP 工程阈值:`surface_max <= 0.02 mm`、`surface_p99 <= 0.02 mm`、 + `bbox_max_delta <= 0.02 mm`、体积和表面积相对误差均不超过 `0.005`,以及实体数相等。 5. `status.json`、转换诊断、重建报告和比较报告保存于该样本目录;可执行但不相似 的模型必须保留,而不能在失败时删除。 -`approximate_pass` 仅用于定位接近结果,不能通过本计划的 17 样本或全量目标。 -缺失原始 STEP、源数据损坏或外部资产不可取得时,必须单独列为 source exception, -附原始证据;不得伪装成 engine 或 converter 已完成。 +严格比较失败不自动表示 engine 或 converter 失败。缺失原始 STEP、源数据损坏或 +外部资产不可取得时,必须单独列为 source exception,附原始证据;不得伪装成 +engine 或 converter 已完成。 ## 第一阶段:17 个代表样本 -阶段完成条件:下面 17 个样本全部以 `--compare-mode strict` 重新执行并严格通过, +阶段完成条件:下面 17 个样本全部以 `--compare-mode rp` 重新执行并达到工程相似, 同时 manifest 的覆盖标签与当前输出一致。每次修复只针对实际失败根因扩展能力; 一个样本可能由多个能力共同阻塞。 @@ -60,7 +62,7 @@ | `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 一致性证据。 | +| `00192744` | circularPattern、extrude、transform | 已执行 `curve_point` 附着 frame、`up_to_next`、pattern copy deletion,以及 direct circular `COPY(BODY)` 的 multi-body transform;仍缺完整 pattern/body lifecycle 与 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 的非盲终止。 | @@ -76,7 +78,7 @@ ```bash PYTHONPATH=backend:. python -m cadfs_to_cdsl regression \ - --tier all --stage pipeline --compare-mode strict --timeout-seconds 60 + --tier all --stage pipeline --compare-mode rp --timeout-seconds 60 ``` 命令结束后应检查 17 个 `comparison.json`,而不是只检查 CLI 的进程退出码。 @@ -84,7 +86,7 @@ PYTHONPATH=backend:. python -m cadfs_to_cdsl regression \ ## 能力完成目标 能力的完成定义为同一能力的 CADFS lowering、CDSL contract、runtime/adapter、 -selector 或 body 语义(需要时)、严格比较回归均具备。仅实现其中一层时只能记为 +selector 或 body 语义(需要时)、工程比较回归均具备。仅实现其中一层时只能记为 “部分完成”。优先级依据全量影响数和 17 样本依赖关系确定。 ### P0:使特征历史成为完整实体 @@ -139,7 +141,7 @@ SimpleCADAPI 强制将 boolean 结果收敛为单个 `Solid`,不满足 CADFS ## Source Exception Evidence -严格重建失败必须先按下表归类。只有“已验证 source exception”能够从全量 +工程重建失败必须先按下表归类。只有“已验证 source exception”能够从全量 成功分母移除;候选项仍是未完成样本,必须保留 source、CDSL、STEP 和比较工件。 | 分类 | 样本 | 当前证据 | 处理要求 | @@ -148,13 +150,15 @@ SimpleCADAPI 强制将 boolean 结果收敛为单个 `Solid`,不满足 CADFS | 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 差异候选 | `00192744` | F3/F5 frame、`UP_TO_NEXT`、F7/F12 `COPY(BODY)` delete 及 F15 的 direct circular COPY multi-body transform 均已完整执行,零 runtime diagnostic。F15 将 F1/F5/F6 COPY 移动约 `-10000 mm` 后,未被选择的 source `NEW` members 留在原处;当前 rebuild 有 22 个实体、gold STEP 只有 1 个,面积误差约 `3.3%`,RP 失败。 | 保留 executable STEP 和 comparison;继续完成 fused/add、nested、linear/mirror COPY 与 boolean/dress-up 后继的完整 body lifecycle。不得从 gold STEP 回推 union、transform 参数或把 source `NEW` 改为 `ADD`。 | | source / STEP 差异候选 | `00212904` | 原 STEP 出现 source FeatureScript history 未表达的正交 cut strip。 | 保留对比工件;除非 source 能提供缺失 feature,不得在 converter 伪造几何。 | +| source / STEP 精度候选 | `00287955` | 完整 history 已执行并达到 RP。source STEP 的 F1 顶面为 `z=8.128 mm`,FeatureScript 只有 `8.13 mm`;F3、F5/F6 和 F14 的后续面偏差累计到 `0.002-0.008 mm`,最终体积相对误差为 `1.793e-4`。 | 保留 CDSL 与比较工件;不得根据 source STEP 回填未在 FeatureScript 中出现的精确尺寸。 | +| source / STEP 拓扑候选 | `00542223` | 开放 B-spline F2 已作为 reference 草图完整 lower,完整 history 无诊断并重建为 `1` 个实体、`49775.5119 mm3`。source STEP 虽声明 `MANIFOLD_SOLID_BREP` / `CLOSED_SHELL`,OCC 却导入为 `TopAbs_SHELL`、`0 solids`,并报告 `BRepCheck_NotClosed`。 | 保留严格失败;需要可信 source STEP 或中间 B-rep 后,才能验证完整 shell/mixed-body 语义。 | | source / STEP 精度候选 | `00835610` | 完整 history 可执行且 RP 通过,严格最大表面差约 `0.014 mm`;FeatureScript 值与 STEP 存在约 `0.014 mm` 的 literal 差异。 | 不能从 gold STEP 回填尺寸;继续标为严格失败,等待 source 版本证据。 | ## 全量完成门槛 -在 17 样本严格通过后,按 capability family 对全量语料滚动运行,并按失败原因选择 +在 17 样本工程通过后,按 capability family 对全量语料滚动运行,并按失败原因选择 新的最小代表样本。最终报告必须以扫描快照中的全部样本为分母,同时满足: 1. 每个有效源样本生成语义有效、可执行的 CDSL;`parse_failed`、 @@ -162,8 +166,8 @@ SimpleCADAPI 强制将 boolean 结果收敛为单个 `Solid`,不满足 CADFS feature 而导致的 `converted_partial` 均为零。 2. 每个拥有原始 STEP 的样本生成完整 `rebuild.step`;`rebuild_failed` 和 `rebuild_timeout` 均为零。 -3. 每个可比较样本严格通过比较;`comparison_timeout`、`rebuilt_rejected` 和仅 - `rebuilt_approximate` 均不计入完成。 +3. 每个可比较样本以 RP 工程阈值通过比较;`comparison_timeout`、 + `comparison.json.decision = rejected` 和仅有可执行 STEP 的样本均不计入完成。 4. 任何合法 source exception 都有单独清单、原始证据和可复现原因;它不进入成功 分母,也不得吞并为“未分类失败”。 5. 生成全量 capability-gap、比较汇总和逐样本证据,确保任一退化都可定位到 @@ -181,15 +185,16 @@ SimpleCADAPI 强制将 boolean 结果收敛为单个 `Solid`,不满足 CADFS 问题。证据不足时不得猜测分类。 3. 只在责任层修复:转换问题修改 CADFS lowering;执行问题扩展 engine;两者都涉及 时分开提交证据和测试。保留最新可执行 STEP/GLB 和失败诊断。 -4. 每项代码改动至少重跑受影响样本、17 样本严格回归和相应单元测试;能力状态与 +4. 每项代码改动至少重跑受影响样本、17 样本工程回归和相应单元测试;能力状态与 证据同步到本地能力台账。全量统计变化后重新生成 `regression/manifest.json`。 -5. 不以关闭诊断、跳过 feature、降低 strict 阈值或用未说明的默认尺寸换取通过。 +5. 不以关闭诊断、跳过 feature、用未说明的默认尺寸或放宽 RP 阈值换取通过;严格 + 指标的变化必须作为诊断证据单独说明。 ## 阶段交付物 | 阶段 | 必须交付 | 通过条件 | | --- | --- | --- | | R0:证据稳定 | 刷新的 regression manifest、17 份完整工件 | 每个样本可重复运行并可定位当前失败。 | -| R1:代表样本 | 17 个严格比较报告、能力台账更新 | `17/17 strict_pass`。 | +| R1:代表样本 | 17 个工程比较报告、能力台账更新 | `17/17 rp.passed`。 | | R2:能力扩展 | 按能力 family 的 converter/engine/selector 实现及回归 | 对应缺口不再造成该 family 的失败。 | | R3:全量验证 | 全量 CDSL、STEP、比较结果和汇总报告 | 满足“全量完成门槛”的五项条件。 | diff --git a/cadfs_to_cdsl/compare.py b/cadfs_to_cdsl/compare.py index 27a536cd..4629a9ee 100644 --- a/cadfs_to_cdsl/compare.py +++ b/cadfs_to_cdsl/compare.py @@ -24,7 +24,15 @@ 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.compare import strict_compare + # ``onshape_to_cdsl`` is a sibling src-layout package. CADFS commands + # intentionally require only ``PYTHONPATH=backend:.`` so they must work + # from a source checkout as well as an installed package. + try: + from onshape_to_cdsl.compare import strict_compare + except ModuleNotFoundError as error: + if error.name != "onshape_to_cdsl.compare": + raise + from onshape_to_cdsl.src.onshape_to_cdsl.compare import strict_compare raw = strict_compare( gold_step, rebuilt_step, diff --git a/cadfs_to_cdsl/lowering.py b/cadfs_to_cdsl/lowering.py index d579b0f1..45ba9b16 100644 --- a/cadfs_to_cdsl/lowering.py +++ b/cadfs_to_cdsl/lowering.py @@ -38,6 +38,10 @@ class UnsupportedCapability(ValueError): super().__init__(message); self.capability = capability +class OpenSketchProfileError(ValueError): + pass + + def plain(value: Any) -> Any: if isinstance(value, Call): return {"call": value.name, "args": [plain(arg) for arg in value.args], "line": value.line} if isinstance(value, list): return [plain(item) for item in value] @@ -58,6 +62,14 @@ def _number(value: Any, units: bool = False) -> float: if isinstance(value, Call) and value.name == "__binary__": left, op, right = value.args; a, b = _number(left, units), _number(right, units) return {"+": a + b, "-": a - b, "*": a * b, "/": a / b}[str(op)] + if isinstance(value, Call) and value.name == "round" and len(value.args) == 1: + # CADFS sometimes serializes a known pattern count as ``round(8)``. + # An already integral constant is provably unchanged, so it needs no + # FeatureScript rounding-mode assumption. Non-integral calls remain + # unsupported until that language-level semantic is represented. + rounded_input = _number(value.args[0], units) + if math.isfinite(rounded_input) and rounded_input.is_integer(): + return rounded_input raise ValueError(f"not a constant number: {plain(value)!r}") @@ -369,6 +381,20 @@ def _face_reference( 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)} + # 同一 feature 的多个圆形端盖可以共面。仅用平面方程会把它们误判为 + # 同一个 selector;保留由 source circle 给出的物理圆心和最小面积, + # 使 pattern COPY(CAP_FACE) 在 prefix B-rep 中仍可唯一绑定。 + for sketch_id, entity_id in _source_refs(value): + entity = (entity_by_sketch.get(sketch_id) or {}).get(entity_id) + sketch = sketch_by_source.get(sketch_id) + if entity is None or sketch is None or entity.get("type") != "circle": + continue + center = _global(cap, entity["center"]) + if rotation is not None: + axis, angle = rotation; center = _rotate_point(center, axis, angle) + geometry["center_mm"] = center + geometry["minimum_area_mm2"] = math.pi * float(entity["radius_mm"]) ** 2 * 0.5 + break 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 "") @@ -405,6 +431,151 @@ def _face_reference( return reference +def _direct_linear_extrude_swept_face_shell_reference( + value: Any, + feature_frames: dict[str, dict[str, Any]], + sketch_by_source: dict[str, dict[str, Any]], + sketches_by_id: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + feature_by_id: dict[str, dict[str, Any]], + previous: list[str], +) -> dict[str, Any]: + """Lower one source-proven linear-extrude side wall for a shell removal. + + This is deliberately narrower than generic ``SWEPT_FACE`` replay. The + original sketch line defines an exact planar side wall only while its + direct blind/two-sided extrusion is the immediately preceding producer; + boolean, dress-up, copy, and transformed continuations have different + ownership and are left to future topology-history contracts. + """ + _call, owner, topology, kind, _definition = _direct_make_query(value) + producer_id = f"f_{owner}" + producer = feature_by_id.get(producer_id) or {} + frame = feature_frames.get(owner) or {} + params = producer.get("params") or {} + atomic_id = str(producer.get("atomic_id") or "") + supported_atomics = { + "extrude_add_blind", "extrude_add_two_sided", + "extrude_cut_blind", "extrude_cut_two_sided", + } + profile_source = frame.get("profile_source") + source_sketch = sketch_by_source.get(profile_source) if isinstance(profile_source, str) else None + profile_sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) + if ( + topology != "SWEPT_FACE" + or kind not in {"face", "entitytype.face"} + or atomic_id not in supported_atomics + or previous[-1:] != [producer_id] + or not isinstance(frame.get("profile"), dict) + or not isinstance(frame.get("start"), dict) + or not isinstance(frame.get("end"), dict) + or source_sketch is None + or profile_sketch is None + or not _profile_matches_direct_source(profile_sketch, source_sketch) + or (params.get("end_condition") or {}).get("type") != "blind" + or ( + atomic_id.endswith("two_sided") + and (params.get("reverse_end_condition") or {}).get("type") != "blind" + ) + ): + raise UnsupportedCapability( + "shell_face_selector", + "current CDSL shell SWEPT_FACE requires the immediately preceding direct blind/two-sided linear extrusion", + ) + + query = parse_query(value) + refs = _source_refs(value) + if ( + len(refs) != 1 + or refs[0][0] != profile_source + or query.source_sketch != profile_source + or query.source_entity != refs[0][1] + ): + raise UnsupportedCapability( + "shell_face_selector", + "current CDSL shell SWEPT_FACE requires one direct source-profile line", + ) + entity = (entity_by_sketch.get(profile_source) or {}).get(refs[0][1]) + if not entity or entity.get("type") != "line" or entity.get("construction"): + raise UnsupportedCapability( + "shell_face_selector", + "current CDSL shell SWEPT_FACE requires one original non-construction source line", + ) + return _face_reference(value, feature_frames, sketch_by_source, entity_by_sketch) + + +def _shell_offset_face_output_role_selector( + value: Any, + feature_by_id: dict[str, dict[str, Any]], + sketches_by_id: dict[str, dict[str, Any]], + previous: list[str], +) -> dict[str, Any]: + """Lower a shell OFFSET_FACE only through its explicit CAP true dependency. + + An OFFSET_FACE is a generated B-rep face, so a bare geometric signature is + not enough: a shell may generate several offset faces. CADFS can retain + the exact semantic source as a nested true-dependency CAP query. Preserve + that relation for the runtime topology registry instead of choosing a + nearby shell face. + """ + _call, owner, topology, kind, _definition = _direct_make_query(value) + owner_feature_id = f"f_{owner}" + producer = feature_by_id.get(owner_feature_id) + if ( + topology != "OFFSET_FACE" + or kind not in {"face", "entitytype.face"} + or producer is None + or producer.get("atomic_id") != "shell" + or previous[-1:] != [owner_feature_id] + ): + raise UnsupportedCapability( + "shell_offset_face_selector", + "current CDSL shell OFFSET_FACE requires the immediately preceding direct shell owner", + ) + # Only a true-dependency disambiguation may establish a source relation. + # ``walk_calls`` is deliberately not used here: arbitrary nested CAP_FACE + # queries can describe ordering or source-profile evidence, but do not + # prove which shell offset face they produced. + source_roles = [] + disambiguation = _definition.get("disambiguationData") + for item in disambiguation if isinstance(disambiguation, list) else (): + if ( + not isinstance(item, Call) + or item.name not in {"TDD", "trueDependencyDisambiguation"} + or len(item.args) != 1 + or not isinstance(item.args[0], list) + ): + continue + for candidate in item.args[0]: + try: + cap = _cap_face_output_role_selector(candidate, feature_by_id, sketches_by_id) + except ValueError: + continue + if cap is not None: + source_roles.append(cap) + unique_roles = { + (item["owner_feature_id"], item["output_role"]): item + for item in source_roles + } + if len(unique_roles) != 1: + raise UnsupportedCapability( + "shell_offset_face_selector", + "current CDSL shell OFFSET_FACE requires one direct builder CAP_FACE true dependency", + ) + source = next(iter(unique_roles.values())) + return { + "kind": "face", + "owner_feature_id": owner_feature_id, + "output_role": "shell.offset_face", + "output_role_source": { + "owner_feature_id": source["owner_feature_id"], + "output_role": source["output_role"], + }, + "source": "runtime_snapshot", + "confidence": 1.0, + } + + def _pattern_copy_face_reference( value: Any, feature_frames: dict[str, dict[str, Any]], @@ -431,12 +602,14 @@ def _pattern_copy_face_reference( 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( + reference = _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), ) + reference["owner_match_required"] = True + return reference def _intersection_vertex_reference( @@ -688,7 +861,14 @@ def _lower_sketch(sketch: SketchIR, plane: dict[str, Any], allow_open: bool = Fa elif entity.operation == "skArc": item = _arc(_point(p["start"]), _point(p["mid"]), _point(p["end"])) elif entity.operation == "skFitSpline": 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") + if len(spline_points) < 2: raise ValueError("fit spline needs at least 2 points") + start_derivative = p.get("startDerivative") + end_derivative = p.get("endDerivative") + if len(spline_points) == 2: + if _same_point(spline_points[0], spline_points[1]): + raise ValueError("two-point fit spline endpoints must be distinct") + if start_derivative is None or end_derivative is None: + raise ValueError("two-point fit spline requires both endpoint derivatives") # FeatureScript 的 skFitSpline 以根号弦长参数化。参数域既决定 # 插值曲线,也决定端点导数的长度语义;半边遍历反转轮廓时会将 # 它按反向参数域同步变换,不能交给 OCC 默认重新计算。 @@ -703,10 +883,19 @@ def _lower_sketch(sketch: SketchIR, plane: dict[str, Any], allow_open: bool = Fa # 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"]) + if start_derivative is not None: item["start_tangent"] = _point(start_derivative) + if end_derivative is not None: item["end_tangent"] = _point(end_derivative) else: unsupported.append(entity.operation); continue - (explicit_construction if _bool(p.get("construction")) else segments).append(item); entities[entity.feature_id] = item + if _bool(p.get("construction")): + # Keep construction provenance available to topology-query lowering, + # but never let it participate in a planar IMPRINT arrangement. + # ``construction`` is runtime-only mapping metadata and must not + # leak into the public analytic-segment schema. + entities[entity.feature_id] = {**item, "construction": True} + explicit_construction.append(item) + else: + entities[entity.feature_id] = item + segments.append(item) if unsupported: raise ValueError("unsupported sketch entities: " + ",".join(sorted(set(unsupported)))) _recover_imperial_grid(segments + explicit_construction, points) if not segments: @@ -722,7 +911,7 @@ def _lower_sketch(sketch: SketchIR, plane: dict[str, Any], allow_open: bool = Fa # reference geometry,不能因为它们存在就丢弃同一草图中的合法 # 区域;若草图没有任何闭合区域,则仍按原有规则拒绝实体 profile。 if not contours: - if not allow_open: raise ValueError(f"sketch has {len(open_segments)} open non-construction segment(s)") + if not allow_open: raise OpenSketchProfileError(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] @@ -748,6 +937,231 @@ def _source_refs(value: Any) -> list[tuple[str, str]]: return refs +def _source_ref_entity( + sketch_id: str, + token: str, + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> tuple[str, dict[str, Any]] | None: + """Resolve an original sketch entity without accepting derived-name aliases.""" + entities = entity_by_sketch.get(sketch_id) or {} + if token in entities: + return token, entities[token] + entity_id = max((key for key in entities if token.startswith(key + ".")), key=len, default="") + if not entity_id: + return None + suffix = token[len(entity_id) + 1:] + # A named source endpoint is still direct provenance. Other suffixes + # (trim offspring, mirrored construction, generated fillet arcs, ...) do + # not identify one source-local endpoint and must not be guessed. + if suffix not in {"start", "end"}: + return None + return entity_id, entities[entity_id] + + +def _source_ref_endpoint_points(entity_id: str, token: str, entity: dict[str, Any]) -> list[list[float]]: + """Return only endpoint coordinates that FeatureScript explicitly exposes.""" + if entity.get("type") == "point" and token == entity_id: + point = entity.get("point") + return [point] if isinstance(point, list) and len(point) == 2 else [] + start, end = entity.get("start"), entity.get("end") + if not all(isinstance(point, list) and len(point) == 2 for point in (start, end)): + return [] + if token == entity_id: + return [start, end] + suffix = token[len(entity_id) + 1:] + return [start] if suffix == "start" else [end] if suffix == "end" else [] + + +def _shared_source_endpoint( + refs: list[tuple[str, str]], + sketch_id: str, + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> list[float] | None: + """Find the one explicit endpoint shared by a set of source curves. + + ``SWEPT_EDGE`` provenance from CADFS uses an original-set disambiguation + containing the source curves incident at a profile vertex. A single curve + has two possible ends, so it cannot identify a swept edge by itself. This + routine deliberately accepts only one shared endpoint from distinct direct + source entities, with the direct ``skPoint`` case retained for point-based + provenance. + """ + selected: dict[str, tuple[str, dict[str, Any]]] = {} + for source, token in refs: + if source != sketch_id: + continue + resolved = _source_ref_entity(source, token, entity_by_sketch) + if resolved is None: + return None + entity_id, entity = resolved + selected.setdefault(entity_id, (token, entity)) + if not selected: + return None + if len(selected) == 1: + entity_id, (token, entity) = next(iter(selected.items())) + if entity.get("type") == "point": + points = _source_ref_endpoint_points(entity_id, token, entity) + return points[0] if len(points) == 1 else None + return None + + endpoints: list[tuple[str, list[float]]] = [] + for entity_id, (token, entity) in selected.items(): + points = _source_ref_endpoint_points(entity_id, token, entity) + if not points: + return None + endpoints.extend((entity_id, point) for point in points) + candidates: list[list[float]] = [] + for _entity_id, point in endpoints: + incident = { + candidate_id + for candidate_id, candidate_point in endpoints + if math.dist(point, candidate_point) <= 1e-5 + } + if len(incident) < 2 or any(math.dist(point, candidate) <= 1e-5 for candidate in candidates): + continue + candidates.append(point) + return candidates[0] if len(candidates) == 1 else None + + +def _endpoint_bbox(start: list[float], end: list[float], *, known_line: bool) -> dict[str, Any] | None: + if math.dist(start, end) <= 1e-8: + return None + geometry = {"bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]} + if known_line: + geometry["curve_type"] = "line" + return geometry + + +def _swept_edge_line_selector_geometry( + owner: str, + refs: list[tuple[str, str]], + frame: dict[str, Any], + feature_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]]], +) -> dict[str, Any] | None: + """Lower a directly proven ``SWEPT_EDGE`` into an endpoint-bbox selector. + + This is intentionally not a general topology replay. It covers an edge + created by a translational extrude from one profile vertex, or by a direct + two-section loft whose source query identifies one profile vertex on each + section. Both constructions provide exact source-local endpoints. A + sweep, revolve, multi-section loft, generated/trimmed source, or any + ambiguous original-set remains unsupported rather than selecting a nearby + B-rep edge. + """ + producer = feature_by_id.get(f"f_{owner}") or {} + atomic_id = str(producer.get("atomic_id") or "") + source_ids = {source for source, _token in refs} + if atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided"}: + profile_source = frame.get("profile_source") + profile = frame.get("profile") + if ( + not isinstance(profile_source, str) + or source_ids != {profile_source} + or not isinstance(profile, dict) + or not isinstance(frame.get("start"), dict) + or not isinstance(frame.get("end"), dict) + ): + return None + local = _shared_source_endpoint(refs, profile_source, entity_by_sketch) + if local is None: + return None + base = _global(profile, local) + start = [base[index] + frame["start"]["origin_mm"][index] - profile["origin_mm"][index] for index in range(3)] + end = [base[index] + frame["end"]["origin_mm"][index] - profile["origin_mm"][index] for index in range(3)] + return _endpoint_bbox(start, end, known_line=True) + if atomic_id != "loft_add": + return None + profile_sources = frame.get("loft_profile_sources") + if not isinstance(profile_sources, list) or len(profile_sources) != 2 or len(set(profile_sources)) != 2: + return None + if source_ids != set(profile_sources): + return None + points = [ + _shared_source_endpoint(refs, source, entity_by_sketch) + for source in profile_sources + ] + if any(point is None or source not in sketch_by_source for point, source in zip(points, profile_sources)): + return None + start = _global(sketch_by_source[profile_sources[0]]["workplane"], points[0]) + end = _global(sketch_by_source[profile_sources[1]]["workplane"], points[1]) + # A ThruSections loft may represent this side edge as a B-spline even + # though its endpoint correspondence is exact. Do not claim a line type. + return _endpoint_bbox(start, end, known_line=False) + + +def _swept_edge_revolve_circle_selector_geometry( + owner: str, + refs: list[tuple[str, str]], + frame: dict[str, Any], + feature_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]]], +) -> dict[str, Any] | None: + """Lower one direct full-revolve profile vertex to a circular edge signature. + + This deliberately accepts only an independent full solid revolve from its + original sketch and original in-sketch axis. Its source query must prove + exactly one profile endpoint, and that endpoint must have positive radial + distance to the explicit axis. Partial/surface/additive revolutions and + generated or transformed profile provenance have different topology and + remain unsupported instead of being geometrically guessed. + """ + producer = feature_by_id.get(f"f_{owner}") or {} + if ( + producer.get("atomic_id") != "revolve_add" + or (producer.get("params") or {}).get("result_mode") != "new_body" + or not frame.get("revolve_full") + ): + return None + profile_source = frame.get("profile_source") + axis = frame.get("revolve_axis") + if ( + not isinstance(profile_source, str) + or {source for source, _token in refs} != {profile_source} + or profile_source not in sketch_by_source + or not isinstance(axis, dict) + ): + return None + local = _shared_source_endpoint(refs, profile_source, entity_by_sketch) + if local is None: + return None + try: + origin = [float(value) for value in axis["origin_mm"]] + direction = [float(value) for value in axis["direction"]] + except (KeyError, TypeError, ValueError): + return None + if len(origin) != 3 or len(direction) != 3 or not all(math.isfinite(value) for value in origin + direction): + return None + direction_length = math.sqrt(sum(value * value for value in direction)) + if direction_length <= 1e-9: + return None + direction = [value / direction_length for value in direction] + point = _global(sketch_by_source[profile_source]["workplane"], local) + projection = sum((point[index] - origin[index]) * direction[index] for index in range(3)) + center = [origin[index] + projection * direction[index] for index in range(3)] + radius = math.dist(point, center) + if not math.isfinite(radius) or radius <= 1e-8: + return None + return {"curve_type": "circle", "circle_center_mm": center, "radius_mm": radius} + + +def _profile_matches_direct_source(selected: dict[str, Any], source: dict[str, Any]) -> bool: + """Prove that a materialized profile selection changed no source geometry. + + ``IMPRINT`` is sometimes only the FeatureScript representation of selecting + the one existing sketch region. It may also select a proper subset. The + latter must not inherit a source-profile SWEPT_EDGE contract, so accept the + former only when its physical workplane and complete profile are exactly + the original lowered mappings. + """ + return ( + selected.get("workplane") == source.get("workplane") + and selected.get("profile") == source.get("profile") + ) + + def _source_sketch(params: dict[str, Any]) -> str | None: for key in ("entities", "sheetProfilesArray", "surfaceEntities"): if key in params: @@ -928,6 +1342,163 @@ def _intersect_partition_profile_sketch( return output +def _definition_topology_side(definition: dict[str, Any]) -> float | None: + """Read the directly attached ``TD`` sign from one query definition.""" + 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(definition.get("disambiguationData")): + if call.name in {"TD", "topologyDisambiguation"}: + side = next(numbers(call.args), None) + if side in {-1.0, 1.0}: + return side + return None + + +def _definition_order(definition: dict[str, Any]) -> int | None: + """Read a finite non-negative ``OD`` index without guessing an intersection.""" + 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(definition.get("disambiguationData")): + if call.name in {"OD", "orderDisambiguation"}: + value = next(numbers(call.args), None) + if value is None or not math.isfinite(value) or value < 0 or value != round(value): + return None + return int(value) + return None + + +def _planar_imprint_selection(value: Any) -> tuple[str, dict[str, Any]] | None: + """Lower one IMPRINT face query to source-edge and side evidence. + + The result deliberately retains the nested edge-fragment proof instead of + flattening it to an arbitrary original profile. A fragment-side sign is + only meaningful together with an exact ``INTERSECT`` vertex and optional + FeatureScript order disambiguation; any other nested form stays outside + this contract. + """ + try: + _root, owner, topology, kind, definition = _direct_make_query(value) + except ValueError: + return None + face_side = _definition_topology_side(definition) + if topology != "IMPRINT" or kind != "face" or face_side is None: + return None + + edge_queries: list[tuple[Call, dict[str, Any]]] = [] + for call in walk_calls(definition.get("disambiguationData")): + if call.name != "makeQuery": + continue + try: + _edge, edge_owner, edge_topology, edge_kind, edge_definition = _direct_make_query(call) + except ValueError: + continue + if edge_owner == owner and edge_topology == "IMPRINT" and edge_kind == "edge": + edge_queries.append((call, edge_definition)) + if len(edge_queries) != 1: + return None + _edge, edge_definition = edge_queries[0] + sources = list(dict.fromkeys(_source_refs(edge_definition.get("derivedFrom")))) + if len(sources) != 1 or sources[0][0] != owner: + return None + source_entity = sources[0][1] + selection: dict[str, Any] = {"source_entity_id": source_entity, "face_side": face_side} + + intersections: list[dict[str, Any]] = [] + for call in walk_calls(edge_definition.get("disambiguationData")): + if call.name != "makeQuery": + continue + try: + _intersection, intersection_owner, intersection_topology, intersection_kind, intersection_definition = _direct_make_query(call) + except ValueError: + continue + if intersection_owner == owner and intersection_topology == "INTERSECT" and intersection_kind == "vertex": + intersections.append(intersection_definition) + if not intersections: + return owner, selection + if len(intersections) != 1: + return None + fragment_side = _definition_topology_side(edge_definition) + intersection_sources = list(dict.fromkeys(_source_refs(intersections[0].get("derivedFrom")))) + if fragment_side is None or len(intersection_sources) != 2 or any(sketch != owner for sketch, _entity in intersection_sources): + return None + anchor = [entity for _sketch, entity in intersection_sources if entity != source_entity] + if len(anchor) != 1: + return None + fragment: dict[str, Any] = {"anchor_entity_id": anchor[0], "side": fragment_side} + order = _definition_order(intersections[0]) + if order is not None: + fragment["intersection_index"] = order + selection["fragment"] = fragment + return owner, selection + + +def _planar_imprint_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: + """Create an exact planar-arrangement profile from IMPRINT face queries. + + The adapter later splits a bounded support face with these original + analytic curves and consumes only the selected B-rep regions. This is a + typed derived-profile contract, not a polygonization or a reconstruction + of a potentially unrelated source contour. + """ + selections: list[dict[str, Any]] = [] + source_sketch: str | None = None + for root in _queries(value): + parsed = _planar_imprint_selection(root) + if parsed is None: + return None + owner, selection = parsed + if source_sketch is None: + source_sketch = owner + elif source_sketch != owner: + return None + selections.append(selection) + if source_sketch is None or not selections or source_sketch not in sketch_by_source: + return None + entities = entity_by_sketch.get(source_sketch) or {} + source_entities: list[dict[str, Any]] = [] + for entity_id, curve in entities.items(): + if curve.get("construction") or curve.get("type") not in {"line", "arc", "circle", "ellipse", "bspline"}: + continue + source_entities.append({"id": entity_id, "curve": {key: deepcopy(value) for key, value in curve.items() if key != "construction"}}) + known = {entry["id"] for entry in source_entities} + for selection in selections: + fragment = selection.get("fragment") or {} + if selection["source_entity_id"] not in known or (fragment and fragment.get("anchor_entity_id") not in known): + return None + if len(source_entities) < 2: + return None + output = deepcopy(sketch_by_source[source_sketch]) + output["id"] = f"{output['id']}__{feature_id}" + output["name"] = f"{output['name']}__{feature_id}" + output["profile"] = { + "type": "planar_imprint", + "source_entities": source_entities, + "selections": selections, + } + return output + + def _same_point(left: list[float], right: list[float]) -> bool: return math.dist(left, right) <= 1e-5 @@ -1135,81 +1706,6 @@ def _open_imprint_profile_sketch( } -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) @@ -1233,6 +1729,86 @@ def _cap_face_selector(value: Any, feature_frames: dict[str, dict[str, Any]], fe } +def _cap_face_output_role_selector( + value: Any, + feature_by_id: dict[str, dict[str, Any]], + sketches_by_id: dict[str, dict[str, Any]], +) -> dict[str, Any] | None: + """Reference one direct-builder cap face without reconstructing its sketch. + + A CAP_FACE is a B-rep output, not an alias for the profile that originally + produced it. The selector is therefore legal only when its producer has a + runtime builder role capable of proving the exact active face. The runtime + rejects it if a later mutation makes that role non-unique or unavailable. + """ + try: + _call, owner, topology, kind, _definition = _direct_make_query(value) + except ValueError: + return None + if topology != "CAP_FACE" or kind != "face": + return None + query = parse_query(value) + if query.is_start is None: + return None + owner_feature_id = f"f_{owner}" + producer = feature_by_id.get(owner_feature_id) + params = (producer or {}).get("params") or {} + producer_sketch = sketches_by_id.get(str((producer or {}).get("sketch_id") or "")) + + def has_one_closed_outer_region(sketch: dict[str, Any] | None) -> bool: + """Prove the narrowed draft builder can receive exactly one face.""" + profile = (sketch or {}).get("profile") or {} + if profile.get("type") == "circle": + return True + contours = profile.get("contours") + return ( + profile.get("type") == "analytic_contours" + and isinstance(contours, list) + and len(contours) == 1 + and bool((contours[0] or {}).get("closed")) + ) + # This derived-profile contract exposes only caps that a direct, one-sided, + # independently retained builder result can prove. The adapter supports + # both a regular prism and the restricted one-face LocOpe drafted-prism + # builder path. Fused/multi-extent/profile results still have no + # unambiguous builder output in the active snapshot. + if ( + producer is None + or producer.get("atomic_id") != "extrude_add_blind" + or params.get("result_mode") != "new_body" + or (params.get("end_condition") or {}).get("type") != "blind" + # LocOpe_DPrism exposes one builder cap only for the one-face path. + # A profile with holes is valid geometry but follows the fallback + # tapered-extrude path, which intentionally carries no cap role. + or params.get("draft") is not None and not has_one_closed_outer_region(producer_sketch) + ): + return None + role_prefix = { + "extrude_add_blind": "extrude", + }[str(producer["atomic_id"])] + return { + "kind": "face", + "owner_feature_id": owner_feature_id, + "output_role": f"{role_prefix}.{'start' if query.is_start else 'end'}", + "source": "runtime_snapshot", + "confidence": 1.0, + } + + +def _profile_query_union_leaves(value: Any) -> list[Any]: + """Flatten only associative query unions used to select one profile. + + FeatureScript histories can wrap a qUnion in a second qUnion when a local + alias is later assigned to ``entities``. The wrapper changes neither the + selected topology nor the source provenance. Keeping this normalization + local to CAP_EDGE profile recognition avoids changing generic selector + parsing, where query grouping may still be diagnostically meaningful. + """ + if isinstance(value, Call) and value.name == "qUnion" and value.args and isinstance(value.args[0], list): + return [leaf for item in value.args[0] for leaf in _profile_query_union_leaves(item)] + return [value] + + def _cap_edge_hole_profile_sketch( value: Any, sketch_by_source: dict[str, dict[str, Any]], @@ -1247,7 +1823,7 @@ def _cap_edge_hole_profile_sketch( from the original sketch entity. This intentionally accepts one outer closed region and one uniquely framed cap edge only. """ - roots = _queries(value) + roots = _profile_query_union_leaves(value) if len(roots) != 2: return None outer_value = next((item for item in roots if parse_query(item).topology_type == "IMPRINT"), None) @@ -1287,7 +1863,7 @@ def _cap_edge_union_profile_sketch( feature_id: str, ) -> dict[str, Any] | None: """Materialize the complete outer region selected around one CAP_EDGE.""" - roots = _queries(value) + roots = _profile_query_union_leaves(value) if len(roots) != 2: return None outer_value = next((item for item in roots if parse_query(item).topology_type == "IMPRINT"), None) @@ -1496,23 +2072,44 @@ def _pattern_body_history_sources( sources: list[str], previous: list[str], feature_by_id: dict[str, dict[str, Any]], + body_aliases: dict[str, str] | None = None, ) -> list[str]: - """Extend one selected SWEPT_BODY with its fused additive history. + """Resolve a selected SWEPT_BODY to its exact current member when known. 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. + `circularPattern` copies it. When the restricted one-body successor state + proves that current member, use it directly: the runtime can transform its + actual B-rep and retain a concrete COPY body member. Replaying a creator + plus its additive history would make each replay fragment look like a + separate source, which loses the instance ownership needed by a later + COPY(BODY) boolean/transform. + + Without that one-to-one proof, retain the older bounded replay expansion. + It can preserve geometry for patterns with fused history, but deliberately + does not claim individual COPY body ownership. """ - has_swept_body = any( - parse_query(item).topology_type == "SWEPT_BODY" - and parse_query(item).kind in {"body", "entitytype.body"} + body_aliases = body_aliases or {} + swept_body_sources = { + f"f_{query.owner_feature}" for item in _queries(value) - ) + for query in [parse_query(item)] + if query.topology_type == "SWEPT_BODY" + and query.kind in {"body", "entitytype.body"} + and query.owner_feature + } + has_swept_body = bool(swept_body_sources) if not has_swept_body: return sources + resolved_sources = { + source: _resolved_body_alias(source, body_aliases) + for source in swept_body_sources + } + if any(resolved != source for source, resolved in resolved_sources.items()): + return list(dict.fromkeys( + resolved_sources.get(source, source) + for source in sources + )) replayable_adds = { "extrude_add_blind", "extrude_add_two_sided", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", @@ -1643,6 +2240,69 @@ def _boolean_body_sources(value: Any) -> list[str]: return sources +def _boolean_body_references( + value: Any, + previous: list[str], + feature_by_id: dict[str, dict[str, Any]], + body_aliases: dict[str, str] | None = None, +) -> tuple[list[str], list[dict[str, Any]]]: + """Keep direct pattern COPY bodies explicit for boolean selection. + + A body selected from a mirror or circular pattern is not its pattern's + aggregate result. Reuse the established transform-query provenance parser, + but limit this first boolean contract to direct feature bodies and pattern + instances. Multi-source transform COPY output has distinct lifecycle + semantics and remains a separate future contract. + """ + sources, instance_refs, transform_copy_refs = _transform_body_references( + value, previous, feature_by_id, body_aliases, + ) + if transform_copy_refs: + raise UnsupportedCapability( + "boolean_pattern_copy", + "current CDSL booleanBodies does not yet consume multi-source transform COPY bodies", + ) + return sources, instance_refs + + +def _shell_target_body_source( + value: Any, + previous: list[str], + body_aliases: dict[str, str], + body_members: set[str], +) -> str: + """Lower one CADFS shell ``parts`` query to its live body member. + + ``parts`` is not a hint to shell whichever aggregate currently contains + the selected faces. It names the CADFS body that owns the shell operation. + A direct SWEPT_BODY can follow only a lowering-time successor that already + proves a one-to-one active member; patterns and fused aggregates deliberately + never enter that alias map. + """ + queries = _queries(value) + if len(queries) != 1: + raise UnsupportedCapability( + "shell_parts_body_source", + "current CDSL shell.parts requires exactly one direct SWEPT_BODY", + ) + _call, owner, topology, kind, _definition = _direct_make_query(queries[0]) + if topology != "SWEPT_BODY" or kind not in {"body", "entitytype.body"}: + raise UnsupportedCapability( + "shell_parts_body_source", + "current CDSL shell.parts requires one direct SWEPT_BODY", + ) + source = f"f_{owner}" + if source not in previous: + raise ValueError("shell parts body source is unresolved") + source = _resolved_body_alias(source, body_aliases) + if source not in body_members: + raise UnsupportedCapability( + "shell_parts_body_source", + "shell parts body no longer has one independently selectable member", + ) + return source + + def _pattern_remove_source(feature: dict[str, Any]) -> None: # circularPattern 的 REMOVE 会把源实体及其实例作为切削工具。仅直接的 # 加料拉伸/回转可无歧义改写为同一 profile 的切除;其他 source 需要 @@ -1661,8 +2321,239 @@ def _pattern_remove_source(feature: dict[str, Any]) -> None: feature["params"].pop("result_mode", None) -def _transform_source_features(value: Any, previous: list[str]) -> list[str]: +def _resolved_body_alias(source: str, aliases: dict[str, str]) -> str: + """Follow one proven direct body's current successor without guessing. + + CADFS ``SWEPT_BODY`` queries keep the original operation owner after a + non-copy transform or a sole-body mutation. The source still names the + same physical body, whose executable CDSL member is now the successor. + This only follows lowering-time transitions that preserve a one-to-one + body member. Boolean, multi-body, delete and replayed pattern output + never enter the alias map. + """ + resolved = source + visited = {source} + while resolved in aliases: + successor = aliases[resolved] + if successor in visited: + raise ValueError("body transform successor aliases contain a cycle") + visited.add(successor) + resolved = successor + return resolved + + +def _direct_transform_copy_member( + value: Any, + previous: list[str], + feature_by_id: dict[str, dict[str, Any]], + body_aliases: dict[str, str], + visited: set[str] | None = None, +) -> tuple[str, dict[str, str] | None]: + """Resolve an exact ``COPY`` chain emitted by explicit transform copies. + + FeatureScript represents a ``makeCopy`` transform result as + ``owner.opPattern/COPY`` even when the owner is a plain transform rather + than a CADFS pattern feature. The runtime already gives that transform a + distinct body member under its feature ID. A multi-source transform COPY + exposes one member for each selected source, so this resolver returns a + structured reference for that case and validates its complete derived-from + chain. It intentionally does not infer ownership for generic pattern, + fused, or dress-up output. + """ + _call, owner, topology, kind, definition = _direct_make_query(value) + if kind not in {"body", "entitytype.body"}: + raise UnsupportedCapability("transform_pattern_copy", "CADFS transform COPY source is not a body") + if topology == "SWEPT_BODY": + source = f"f_{owner}" + if source not in previous: + raise ValueError("transform COPY source body is unresolved") + return _resolved_body_alias(source, body_aliases), None + if topology != "COPY": + raise UnsupportedCapability( + "transform_pattern_copy", + "CADFS transform COPY source must descend from a direct swept body", + ) + try: + instance = int(str(definition.get("instanceName"))) + except (TypeError, ValueError) as error: + raise ValueError("transform COPY instance is unresolved") from error + if instance != 1: + raise UnsupportedCapability( + "transform_pattern_copy", + "direct transform COPY provenance only has generated instance 1", + ) + member_id = f"f_{owner}" + if member_id in (visited or set()): + raise ValueError("transform COPY provenance contains a cycle") + transform = feature_by_id.get(member_id) + params = (transform or {}).get("params") or {} + source_ids = params.get("source_feature_ids") or [] + if ( + member_id not in previous + or transform is None + or transform.get("atomic_id") != "transform_bodies" + or not bool(params.get("make_copy")) + or params.get("pattern_instance_refs") + or params.get("transform_copy_refs") + or not isinstance(source_ids, list) + or not source_ids + ): + raise UnsupportedCapability( + "transform_pattern_copy", + "CADFS transform COPY must name an exact preceding explicit transform copy", + ) + derived = definition.get("derivedFrom") + if derived is None: + raise ValueError("transform COPY has no derived body") + upstream, _upstream_copy_ref = _direct_transform_copy_member( + derived, previous, feature_by_id, body_aliases, (visited or set()) | {member_id}, + ) + if len(source_ids) == 1 and source_ids == [upstream]: + return member_id, None + if len(source_ids) > 1 and upstream in source_ids: + return member_id, { + "transform_feature_id": member_id, + "source_feature_id": upstream, + } + raise UnsupportedCapability( + "transform_pattern_copy", + "CADFS transform COPY derived body does not match its CDSL transform source", + ) + + +def _transform_copy_query_provenance( + value: Any, + previous: list[str], + feature_by_id: dict[str, dict[str, Any]], + visited: set[str] | None = None, +) -> tuple[Any, list[dict[str, Any]], str]: + """Return a direct source query and exact transforms for a COPY descendant. + + The copied edge/vertex must be produced by the same restricted transform + copy chain as its selected body. Applying the recorded CDSL transforms to + the direct source reference preserves the physical location without a + topology-nearest fallback. This is deliberately separate from runtime + selector binding: the values are only used to lower a FeatureScript + translation vector whose source points are explicit and unique. + """ + _call, owner, topology, _kind, definition = _direct_make_query(value) + member_id = f"f_{owner}" + if topology != "COPY": + if member_id not in previous: + raise ValueError("transform COPY reference owner is unresolved") + return value, [], member_id + try: + instance = int(str(definition.get("instanceName"))) + except (TypeError, ValueError) as error: + raise ValueError("transform COPY reference instance is unresolved") from error + if instance != 1: + raise UnsupportedCapability( + "transform_translation_entity", + "COPY reference only has exact transform provenance for instance 1", + ) + if member_id in (visited or set()): + raise ValueError("transform COPY reference provenance contains a cycle") + transform = feature_by_id.get(member_id) + params = (transform or {}).get("params") or {} + source_ids = params.get("source_feature_ids") or [] + transform_spec = params.get("transform") + if ( + member_id not in previous + or transform is None + or transform.get("atomic_id") != "transform_bodies" + or not bool(params.get("make_copy")) + or params.get("pattern_instance_refs") + or not isinstance(source_ids, list) + or len(source_ids) != 1 + or not isinstance(transform_spec, dict) + ): + raise UnsupportedCapability( + "transform_translation_entity", + "COPY reference must name an exact preceding single-source transform copy", + ) + derived = definition.get("derivedFrom") + if derived is None: + raise ValueError("transform COPY reference has no derived geometry") + source_query, transforms, upstream_member = _transform_copy_query_provenance( + derived, previous, feature_by_id, (visited or set()) | {member_id}, + ) + if source_ids != [upstream_member]: + raise UnsupportedCapability( + "transform_translation_entity", + "COPY reference derived geometry does not match its transform source", + ) + return source_query, [*transforms, transform_spec], member_id + + +def _apply_body_transform_to_point(point: list[float], transform: dict[str, Any]) -> list[float]: + """Apply one validated CDSL body transform to an explicit point.""" + kind = str(transform.get("type") or "") + if kind == "translation": + offset = transform.get("translation_mm") + if not isinstance(offset, list) or len(offset) != 3: + raise ValueError("transform COPY translation is incomplete") + return [point[index] + float(offset[index]) for index in range(3)] + if kind == "rotation": + axis = transform.get("axis") + angle = transform.get("angle_deg") + if not isinstance(axis, dict) or not isinstance(angle, (int, float)): + raise ValueError("transform COPY rotation is incomplete") + return _rotate_point(point, axis, math.radians(float(angle))) + if kind == "uniform_scale": + center = transform.get("center_mm") + factor = transform.get("scale_factor") + if not isinstance(center, list) or len(center) != 3 or not isinstance(factor, (int, float)): + raise ValueError("transform COPY uniform scale is incomplete") + return [float(center[index]) + (point[index] - float(center[index])) * float(factor) for index in range(3)] + raise ValueError(f"transform COPY has unsupported transform type {kind!r}") + + +def _transform_copy_point( + query: 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]], + previous: list[str], +) -> list[float]: + try: + _call, _owner, topology, _kind, _definition = _direct_make_query(query) + except ValueError: + return _query_point(query, feature_frames, sketch_by_source, entity_by_sketch) + if topology != "COPY": + return _query_point(query, feature_frames, sketch_by_source, entity_by_sketch) + source, transforms, _member = _transform_copy_query_provenance(query, previous, feature_by_id) + point = _query_point(source, feature_frames, sketch_by_source, entity_by_sketch) + for transform in transforms: + point = _apply_body_transform_to_point(point, transform) + return point + + +def _transform_copy_line( + query: 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]], + previous: list[str], +) -> tuple[list[float], list[float]]: + try: + _call, _owner, topology, _kind, _definition = _direct_make_query(query) + except ValueError: + return _query_line(query, feature_frames, sketch_by_source, entity_by_sketch) + if topology != "COPY": + return _query_line(query, feature_frames, sketch_by_source, entity_by_sketch) + source, transforms, _member = _transform_copy_query_provenance(query, previous, feature_by_id) + start, end = _query_line(source, feature_frames, sketch_by_source, entity_by_sketch) + for transform in transforms: + start = _apply_body_transform_to_point(start, transform) + end = _apply_body_transform_to_point(end, transform) + return start, end + + +def _transform_source_features(value: Any, previous: list[str], body_aliases: dict[str, str] | None = None) -> list[str]: sources = [] + body_aliases = body_aliases or {} for call in walk_calls(value): if call.name not in {"makeQuery", "qCreatedBy"} or not call.args: continue @@ -1670,13 +2561,195 @@ def _transform_source_features(value: Any, previous: list[str]) -> list[str]: if "F" not in owner: continue source = "f_" + owner[owner.find("F"):].split(".", 1)[0] - if source in previous and source not in sources: - sources.append(source) + resolved = _resolved_body_alias(source, body_aliases) + if source in previous and resolved not in sources: + sources.append(resolved) if not sources: raise ValueError("transform source features are unresolved") return sources +def _transform_body_references( + value: Any, + previous: list[str], + feature_by_id: dict[str, dict[str, Any]], + body_aliases: dict[str, str] | None = None, +) -> tuple[list[str], list[dict[str, Any]], list[dict[str, str]]]: + """Lower direct CADFS body queries without flattening COPY provenance. + + A pattern COPY is not its pattern's aggregate result. Keep the producer, + source body and instance index as a structured CDSL reference so runtime + can select a proven body member without receiving an internal state ID. + """ + sources: list[str] = [] + instance_refs: list[dict[str, Any]] = [] + transform_copy_refs: list[dict[str, str]] = [] + body_aliases = body_aliases or {} + query_values = _queries(value) + # Older CADFS exports represent a directly created body as + # qCreatedBy(id + "F1", BODY), without a makeQuery topology wrapper. + # It has no COPY provenance, so the established source-feature contract + # remains exact and does not need a body-member instance reference. + if query_values and all(isinstance(item, Call) and item.name == "qCreatedBy" for item in query_values): + return _transform_source_features(value, previous, body_aliases), instance_refs, transform_copy_refs + for query_value in query_values: + _call, owner, topology, kind, definition = _direct_make_query(query_value) + if kind not in {"body", "entitytype.body"}: + raise UnsupportedCapability("transform_body_query", "CADFS transform requires direct body queries") + if topology == "SWEPT_BODY": + source = f"f_{owner}" + if source not in previous: + raise ValueError("transform source body is unresolved") + source = _resolved_body_alias(source, body_aliases) + if source not in sources: + sources.append(source) + continue + if topology != "COPY": + raise UnsupportedCapability( + "transform_body_query", + "CADFS transform requires SWEPT_BODY or circular-pattern COPY body queries", + ) + derived = definition.get("derivedFrom") + if derived is None: + raise ValueError("pattern copy transform source is unresolved") + pattern_id = f"f_{owner}" + owner_feature = feature_by_id.get(pattern_id) + if owner_feature is not None and owner_feature.get("atomic_id") == "transform_bodies": + source, transform_copy_ref = _direct_transform_copy_member( + query_value, previous, feature_by_id, body_aliases, + ) + if transform_copy_ref is not None: + if transform_copy_ref not in transform_copy_refs: + transform_copy_refs.append(transform_copy_ref) + elif source not in sources: + sources.append(source) + continue + _source_call, source_owner, source_topology, source_kind, _source_definition = _direct_make_query(derived) + source_id = _resolved_body_alias(f"f_{source_owner}", body_aliases) + pattern = feature_by_id.get(pattern_id) + if ( + source_topology == "SWEPT_BODY" + and source_kind in {"body", "entitytype.body"} + and pattern is not None + and pattern.get("atomic_id") == "pattern_mirror" + and pattern_id in previous + and source_id in (pattern.get("params") or {}).get("source_feature_ids", []) + and (feature_by_id.get(source_id) or {}).get("params", {}).get("result_mode") == "new_body" + ): + try: + instance = int(str(definition.get("instanceName"))) + except (TypeError, ValueError) as error: + raise ValueError("mirror copy transform instance is unresolved") from error + if instance != 1: + raise UnsupportedCapability( + "transform_pattern_copy", + "direct mirror COPY provenance only has generated instance 1", + ) + reference = { + "pattern_feature_id": pattern_id, + "source_feature_id": source_id, + "instance_index": instance, + } + if reference not in instance_refs: + instance_refs.append(reference) + continue + if ( + source_topology != "SWEPT_BODY" + or source_kind not in {"body", "entitytype.body"} + or pattern is None + or pattern.get("atomic_id") != "pattern_circular" + or pattern_id not in previous + or source_id not in (pattern.get("params") or {}).get("source_feature_ids", []) + ): + raise UnsupportedCapability( + "transform_pattern_copy", + "CADFS transform COPY body must name a direct source of a preceding circular pattern", + ) + try: + instance = int(str(definition.get("instanceName"))) + except (TypeError, ValueError) as error: + raise ValueError("pattern copy transform instance is unresolved") from error + count = int((pattern.get("params") or {}).get("pattern_count") or 0) + excluded = {int(value) for value in (pattern.get("params") or {}).get("excluded_instance_indices") or []} + if instance < 1 or instance >= count or instance in excluded: + raise ValueError("pattern copy transform instance is outside the generated range") + reference = { + "pattern_feature_id": pattern_id, + "source_feature_id": source_id, + "instance_index": instance, + } + if reference not in instance_refs: + instance_refs.append(reference) + if not sources and not instance_refs and not transform_copy_refs: + raise ValueError("transform source bodies are unresolved") + return sources, instance_refs, transform_copy_refs + + +def _body_transform( + params: 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]]], + feature_by_id: dict[str, dict[str, Any]] | None = None, + previous: list[str] | None = None, +) -> dict[str, Any]: + """Lower one CADFS body transform without changing the selected body.""" + transform_type = str(params.get("transformType") or "").split(".")[-1].upper() + # FeatureScript COPY has no geometric displacement, but it does create a + # separate body member. Represent its identity geometry explicitly and let + # the enclosing transform_bodies operation retain the source via + # ``make_copy``. This stays on the OCC transform/history path rather than + # aliasing the source's runtime body. + if transform_type == "COPY": + return {"type": "translation", "translation_mm": [0.0, 0.0, 0.0]} + if transform_type == "TRANSLATION_3D": + return { + "type": "translation", + "translation_mm": [_number(params.get(key, 0.0), True) for key in ("dx", "dy", "dz")], + } + if transform_type == "TRANSLATION_DISTANCE": + return { + "type": "translation", + "translation_mm": _translation_distance_vector( + params, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous, + ), + } + if transform_type == "TRANSLATION_ENTITY": + return { + "type": "translation", + "translation_mm": _translation_entity_vector( + params, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous, + ), + } + if transform_type == "ROTATION": + axis = _transform_axis(params.get("transformAxis"), feature_frames, sketch_by_source, entity_by_sketch) + return {"type": "rotation", "axis": axis, "angle_deg": _number(params.get("angle"), True)} + if transform_type == "SCALE_UNIFORMLY": + scale_factor = _number(params.get("scale")) + if not math.isfinite(scale_factor) or scale_factor <= 0: + raise UnsupportedCapability( + "transform_uniform_scale", + "SCALE_UNIFORMLY requires a finite positive scale factor", + ) + return { + "type": "uniform_scale", + "center_mm": _scale_center(params.get("scalePoint"), feature_frames, sketch_by_source, entity_by_sketch), + "scale_factor": scale_factor, + } + raise UnsupportedCapability("transform", f"current CDSL engine cannot exactly execute {transform_type or 'unknown'} transform") + + +def _delete_body_source(value: Any) -> str: + """Resolve a directly owned body output without broad query expansion.""" + _call, owner, topology, kind, _definition = _direct_make_query(value) + if kind not in {"body", "entitytype.body"} or topology not in {"SWEPT_BODY", "COPY"}: + raise UnsupportedCapability( + "delete_bodies", + "current CDSL deleteBodies requires a direct SWEPT_BODY or COPY body query", + ) + return f"f_{owner}" + + def _circular_pattern_axis( value: Any, feature_frames: dict[str, dict[str, Any]], @@ -1739,6 +2812,8 @@ def _profile_executable(sketch: dict[str, Any]) -> bool: profile = sketch.get("profile") or {} if profile.get("type") == "circle": return True if profile.get("type") == "polygon": return len(profile.get("vertices") or []) >= 3 + if profile.get("type") == "planar_imprint": + return bool(profile.get("source_entities") and profile.get("selections")) return bool(profile.get("contours")) @@ -1769,6 +2844,8 @@ def _entity_from_query( def _entity_point(entity: dict[str, Any], plane: dict[str, Any], token: str) -> list[float]: if entity["type"] == "point": return _global(plane, entity["point"]) + if entity["type"] == "circle" and ".center" in token: + return _global(plane, entity["center"]) if entity["type"] == "line": local = entity["end"] if ".end" in token else entity["start"] return _global(plane, local) @@ -1924,26 +3001,220 @@ def _offset_face_plane( return _attachment_plane(_frame(offset, x_dir, normal)) +def _convex_linear_offset_wall_endpoints( + profile: dict[str, Any], + entity: dict[str, Any], + source_plane: dict[str, Any], + offset_plane: dict[str, Any], + thickness: float, +) -> tuple[list[float], list[float]] | None: + """Offset one convex, closed, linear profile edge with its true neighbors. + + A shell's offset wall ends at the intersections with the offset adjacent + walls. This is not equivalent to translating the selected outer edge: a + rectangular wall, for example, shortens at both corners. Restrict this + construction to one ordered convex line loop so every miter and its + interior side are uniquely defined. + """ + contours = (profile.get("profile") or {}).get("contours") or [] + if len(contours) != 1: + return None + contour = contours[0] + segments = contour.get("segments") or [] + if ( + not contour.get("closed") + or len(segments) < 3 + or any(segment.get("type") != "line" for segment in segments) + or any(not _same_point(segment["end"], segments[(index + 1) % len(segments)]["start"]) for index, segment in enumerate(segments)) + ): + return None + matches = [index for index, segment in enumerate(segments) if _matching_profile_segment(segment, entity)] + if len(matches) != 1: + return None + area_twice = sum( + segment["start"][0] * segment["end"][1] - segment["end"][0] * segment["start"][1] + for segment in segments + ) + if abs(area_twice) <= 1e-9: + return None + orientation = 1.0 if area_twice > 0 else -1.0 + + def interior_normal(segment: dict[str, Any]) -> list[float] | None: + dx = segment["end"][0] - segment["start"][0] + dy = segment["end"][1] - segment["start"][1] + length = math.hypot(dx, dy) + if length <= 1e-9: + return None + return [-orientation * dy / length, orientation * dx / length] + + normals = [interior_normal(segment) for segment in segments] + if any(normal is None for normal in normals): + return None + # A convex loop has one consistent signed turn direction. Concave offset + # boundaries can self-intersect and require the shell kernel's exact trim + # history, so they deliberately remain deferred. + turn_signs = [] + for index, segment in enumerate(segments): + next_segment = segments[(index + 1) % len(segments)] + dx = segment["end"][0] - segment["start"][0] + dy = segment["end"][1] - segment["start"][1] + next_dx = next_segment["end"][0] - next_segment["start"][0] + next_dy = next_segment["end"][1] - next_segment["start"][1] + turn = dx * next_dy - dy * next_dx + if abs(turn) <= 1e-9: + return None + turn_signs.append(1.0 if turn > 0 else -1.0) + if any(sign != turn_signs[0] for sign in turn_signs): + return None + + selected_index = matches[0] + selected_normal = normals[selected_index] + global_normal = [ + source_plane["x_dir"][index] * selected_normal[0] + _y_dir(source_plane)[index] * selected_normal[1] + for index in range(3) + ] + if abs(_dot(_unit(global_normal, "offset wall normal is degenerate"), offset_plane["normal"]) - 1.0) > 1e-6: + return None + + def shifted_line(index: int) -> tuple[list[float], list[float]]: + segment = segments[index] + normal = normals[index] + return ( + [segment["start"][axis] + thickness * normal[axis] for axis in range(2)], + [segment["end"][axis] + thickness * normal[axis] for axis in range(2)], + ) + + def intersection( + first_start: list[float], first_end: list[float], second_start: list[float], second_end: list[float], + ) -> list[float] | None: + first_direction = [first_end[0] - first_start[0], first_end[1] - first_start[1]] + second_direction = [second_end[0] - second_start[0], second_end[1] - second_start[1]] + denominator = first_direction[0] * second_direction[1] - first_direction[1] * second_direction[0] + if abs(denominator) <= 1e-9: + return None + difference = [second_start[0] - first_start[0], second_start[1] - first_start[1]] + scale = (difference[0] * second_direction[1] - difference[1] * second_direction[0]) / denominator + return [first_start[axis] + scale * first_direction[axis] for axis in range(2)] + + previous = (selected_index - 1) % len(segments) + following = (selected_index + 1) % len(segments) + selected_start, selected_end = shifted_line(selected_index) + previous_start, previous_end = shifted_line(previous) + following_start, following_end = shifted_line(following) + first = intersection(previous_start, previous_end, selected_start, selected_end) + second = intersection(selected_start, selected_end, following_start, following_end) + if first is None or second is None or _same_point(first, second): + return None + return _global(source_plane, first), _global(source_plane, second) + + 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_by_id: dict[str, dict[str, Any]], feature_id: str, ) -> dict[str, Any] | None: - """Materialize a planar OFFSET_FACE as the shell's shifted profile region.""" + """Materialize one planar, linear-extrude OFFSET_FACE wall when proven. + + An offset face is a generated side wall, not the source extrusion's cap + region. Reusing the full source profile therefore changes both the face + extent and its topology. The wall is reconstructible only when direct + source provenance proves one non-construction edge of a convex linear + profile, one finite direct extrusion span, and one removed extrusion cap. + All other OFFSET_FACE queries remain an explicit deferred capability + rather than receiving a guessed profile. + """ 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: + shell_source = frame.get("shell_source") + direct_frame = feature_frames.get(str(shell_source)) if shell_source else None + direct_feature = feature_by_id.get(f"f_{shell_source}") if shell_source else None + profile_source = (direct_frame or {}).get("profile_source") + source_sketch = sketch_by_source.get(str(profile_source)) if profile_source else None + refs = _source_refs(value) + if ( + query.topology_type != "OFFSET_FACE" + or profile is None + or direct_frame is None + or direct_feature is None + or direct_feature.get("atomic_id") != "extrude_add_blind" + or (direct_feature.get("params") or {}).get("result_mode") != "new_body" + or (direct_feature.get("params") or {}).get("draft") is not None + or frame.get("shell_inward") is not True + or frame.get("shell_removed_cap") not in {"start", "end"} + or not isinstance(profile_source, str) + or source_sketch is None + or not _profile_matches_direct_source(profile, source_sketch) + or len(refs) != 1 + or refs[0][0] != profile_source + or query.source_sketch != profile_source + or query.source_entity != refs[0][1] + ): return None + + entity = (entity_by_sketch.get(profile_source) or {}).get(refs[0][1]) + if entity is None or entity.get("type") != "line" or entity.get("construction"): + return None + profile_plane = direct_frame.get("profile") + start_plane = direct_frame.get("start") + end_plane = direct_frame.get("end") + if not all(isinstance(plane, dict) for plane in (profile_plane, start_plane, end_plane)): + return None + + try: + source_start, source_end = _convex_linear_offset_wall_endpoints( + profile, entity, source_sketch["workplane"], + _offset_face_plane(value, feature_frames, sketch_by_source, entity_by_sketch), + float(frame["shell_thickness_mm"]), + ) or (None, None) + if source_start is None or source_end is None: + return None + line_direction = _unit(_sub(source_end, source_start), "offset face source line is degenerate") + span = _sub(end_plane["origin_mm"], start_plane["origin_mm"]) + span_direction = _unit(span, "offset face extrusion span is degenerate") + profile_normal = _unit(profile_plane["normal"], "offset face extrusion profile normal is degenerate") + if ( + abs(_dot(line_direction, span_direction)) > 1e-6 + or abs(abs(_dot(span_direction, profile_normal)) - 1.0) > 1e-6 + ): + return None + cap_shift = _sub(start_plane["origin_mm"], profile_plane["origin_mm"]) + if not all(math.isfinite(component) for point in (source_start, source_end, span, cap_shift) for component in point): + return None + offset_plane = _offset_face_plane(value, feature_frames, sketch_by_source, entity_by_sketch) + except (KeyError, TypeError, ValueError): + return None + + cap_shrink = [float(frame["shell_thickness_mm"]) * component for component in span_direction] + start_shift = list(cap_shift) + end_shift = [cap_shift[index] + span[index] for index in range(3)] + if frame["shell_removed_cap"] == "end": + start_shift = [start_shift[index] + cap_shrink[index] for index in range(3)] + else: + end_shift = [end_shift[index] - cap_shrink[index] for index in range(3)] + corners = [ + [source_start[index] + start_shift[index] for index in range(3)], + [source_end[index] + start_shift[index] for index in range(3)], + [source_end[index] + end_shift[index] for index in range(3)], + [source_start[index] + end_shift[index] for index in range(3)], + ] + local_corners = [_local(offset_plane, corner) for corner in corners] + if not all(math.isfinite(component) for point in local_corners for component in point): + return None + segments = [ + {"type": "line", "start": local_corners[index], "end": local_corners[(index + 1) % len(local_corners)]} + for index in range(len(local_corners)) + ] 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"]), + "workplane": offset_plane, + "profile": {"type": "analytic_contours", "contours": [{"role": "unknown", "closed": True, "segments": segments}]}, } @@ -1956,11 +3227,17 @@ def _query_line( info = parse_query(query) entity, plane, _ = _entity_from_query(query, sketch_by_source, entity_by_sketch) 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 + frame_data = feature_frames[info.owner_feature] + frame = frame_data["start" if info.is_start else "end"] + # CAP outer normals may flip the x direction to preserve the later + # sketch attachment handedness. A source sketch edge, however, keeps + # its original physical in-plane coordinates at either cap. Preserve + # that profile frame and move only its origin to the selected cap. + profile = frame_data.get("profile") + plane = {**profile, "origin_mm": list(frame["origin_mm"])} if isinstance(profile, dict) else frame if entity["type"] == "circle": center = _global(plane, entity["center"]) - return center, [center[index] + plane["normal"][index] for index in range(3)] + return center, [center[index] + frame["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") @@ -1980,6 +3257,222 @@ def _transform_axis( return {"origin_mm": start, "direction": _unit(_sub(end, start), "transform axis is degenerate")} +def _scale_center( + 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]]], +) -> list[float]: + """Resolve the explicit center of a CADFS uniform scale. + + Origin point is a system datum with a known coordinate. Other centers must + name exactly one direct sketch or CAP_VERTEX source whose physical point is + available in the lowering state. COPY/SWEPT/OFFSET vertices need runtime + topology provenance, so treating their nearest visible point as the scale + center would change the operation's semantics. + """ + centers = _queries(value) + if len(centers) != 1: + raise UnsupportedCapability( + "transform_uniform_scale_center", + "SCALE_UNIFORMLY requires exactly one explicit scale point", + ) + center = centers[0] + for call in walk_calls(center): + if call.name == "qCreatedBy" and call.args and "Origin.pointOp" in symbolic_string(call.args[0]): + return [0.0, 0.0, 0.0] + info = parse_query(center) + if info.kind not in {"vertex", "entitytype.vertex"} or info.topology_type not in {None, "CAP_VERTEX"}: + raise UnsupportedCapability( + "transform_uniform_scale_center", + "SCALE_UNIFORMLY scale point must be Origin point or a direct sketch/CAP_VERTEX", + ) + try: + return _query_point(center, feature_frames, sketch_by_source, entity_by_sketch) + except ValueError as error: + raise UnsupportedCapability( + "transform_uniform_scale_center", + "SCALE_UNIFORMLY scale point must resolve to one physical vertex", + ) from error + + +def _translation_distance_vector( + params: 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]]], + feature_by_id: dict[str, dict[str, Any]] | None = None, + previous: list[str] | None = None, +) -> list[float]: + """Resolve the restricted CADFS TRANSLATION_DISTANCE direction contract. + + An edge direction is only unambiguous when it comes from one source + sketch line or its CAP_EDGE descendant. A direct CAP_FACE is also exact + when its producer has recorded a physical start/end frame: its selected + cap normal is the FeatureScript direction. A generic face normal, swept + edge, offset edge, and curve tangent each need different source semantics, + so they remain explicit capability gaps instead of borrowing a nearby + direction from the active body. + """ + directions = _queries(params.get("transformDirection")) + if len(directions) != 1: + raise UnsupportedCapability( + "transform_translation_direction", + "TRANSLATION_DISTANCE requires exactly one direct linear sketch or CAP_EDGE direction", + ) + direction_query = directions[0] + info = parse_query(direction_query) + # System datum planes and previously lowered reference planes have an + # explicit physical normal. ``_query_plane`` accepts only those two + # qCreatedBy forms here, so this never treats an arbitrary produced face + # as a translation direction. + if "qCreatedBy" in info.calls and info.topology_type is None: + try: + direction = _unit( + list(_query_plane(direction_query, feature_frames, sketch_by_source, entity_by_sketch)["normal"]), + "transform reference-plane normal is degenerate", + ) + except ValueError as error: + raise UnsupportedCapability( + "transform_translation_direction", + "TRANSLATION_DISTANCE reference-plane direction must have an explicit plane frame", + ) from error + elif info.kind in {"face", "entitytype.face"} and info.topology_type == "SWEPT_FACE": + try: + direction = _unit( + list(_query_plane(direction_query, feature_frames, sketch_by_source, entity_by_sketch)["normal"]), + "transform swept-face normal is degenerate", + ) + except (UnsupportedCapability, ValueError) as error: + raise UnsupportedCapability( + "transform_translation_direction", + "TRANSLATION_DISTANCE SWEPT_FACE direction requires one direct planar source face", + ) from error + elif info.kind in {"face", "entitytype.face"} and info.topology_type == "CAP_FACE" and info.is_start is not None: + frame = feature_frames.get(info.owner_feature or "") or {} + cap = frame.get("start" if info.is_start else "end") + try: + direction = _unit(list((cap or {}).get("normal") or []), "transform cap-face normal is degenerate") + except ValueError as error: + raise UnsupportedCapability( + "transform_translation_direction", + "TRANSLATION_DISTANCE CAP_FACE direction requires a producer with a physical cap frame", + ) from error + else: + if info.kind not in {"edge", "entitytype.edge"} or info.topology_type not in {None, "CAP_EDGE"}: + raise UnsupportedCapability( + "transform_translation_direction", + "TRANSLATION_DISTANCE only supports a linear sketch/CAP_EDGE, exact transform copy, explicit reference plane, direct planar SWEPT_FACE, or framed CAP_FACE direction", + ) + try: + entity, _plane, _token = _entity_from_query(direction_query, sketch_by_source, entity_by_sketch) + if entity.get("type") != "line": + raise ValueError("transform direction source is not linear") + if feature_by_id is not None and previous is not None: + start, end = _transform_copy_line( + direction_query, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous, + ) + else: + start, end = _query_line(direction_query, feature_frames, sketch_by_source, entity_by_sketch) + direction = _unit(_sub(end, start), "transform translation direction is degenerate") + except ValueError as error: + raise UnsupportedCapability( + "transform_translation_direction", + "TRANSLATION_DISTANCE direction must resolve to a non-degenerate line", + ) from error + distance = _number(params.get("distance"), True) + if distance < 0: + raise UnsupportedCapability( + "transform_translation_distance", + "TRANSLATION_DISTANCE requires a non-negative distance", + ) + if _bool(params.get("oppositeDirection")): + distance = -distance + return [component * distance for component in direction] + + +def _translation_entity_vector( + params: 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]]], + feature_by_id: dict[str, dict[str, Any]] | None = None, + previous: list[str] | None = None, +) -> list[float]: + """Resolve a direct CADFS TRANSLATION_ENTITY vector without topology guesses. + + FeatureScript accepts either a line entity, whose endpoint delta is the + translation vector, or two vertices interpreted in selection order. This + restricted lowering accepts raw sketch/CAP descendants and exact copies + made by explicit single-source transforms. Other COPY/SWEPT/OFFSET + geometry requires a kernel-proven successor relation. + """ + entities = _queries(params.get("transformLine")) + if len(entities) == 1: + line = entities[0] + info = parse_query(line) + if info.kind not in {"edge", "entitytype.edge"} or info.topology_type not in {None, "CAP_EDGE"}: + raise UnsupportedCapability( + "transform_translation_entity", + "TRANSLATION_ENTITY requires a linear sketch/CAP_EDGE or its exact transform copy", + ) + try: + entity, _plane, _token = _entity_from_query(line, sketch_by_source, entity_by_sketch) + if entity.get("type") != "line": + raise ValueError("transform line source is not linear") + if feature_by_id is not None and previous is not None: + start, end = _transform_copy_line( + line, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous, + ) + else: + start, end = _query_line(line, feature_frames, sketch_by_source, entity_by_sketch) + except ValueError as error: + raise UnsupportedCapability( + "transform_translation_entity", + "TRANSLATION_ENTITY line must resolve to a non-degenerate line", + ) from error + elif len(entities) == 2: + first, second = entities + first_info, second_info = parse_query(first), parse_query(second) + if ( + first_info.kind not in {"vertex", "entitytype.vertex"} + or second_info.kind not in {"vertex", "entitytype.vertex"} + or first_info.topology_type not in {None, "CAP_VERTEX"} + or second_info.topology_type not in {None, "CAP_VERTEX"} + ): + raise UnsupportedCapability( + "transform_translation_entity", + "TRANSLATION_ENTITY requires exactly two sketch/CAP_VERTEX points or their exact transform copies", + ) + try: + if feature_by_id is not None and previous is not None: + start = _transform_copy_point( + first, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous, + ) + end = _transform_copy_point( + second, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous, + ) + else: + start = _query_point(first, feature_frames, sketch_by_source, entity_by_sketch) + end = _query_point(second, feature_frames, sketch_by_source, entity_by_sketch) + except ValueError as error: + raise UnsupportedCapability( + "transform_translation_entity", + "TRANSLATION_ENTITY vertices must resolve to unique points", + ) from error + else: + raise UnsupportedCapability( + "transform_translation_entity", + "TRANSLATION_ENTITY requires one direct line or exactly two direct vertices", + ) + vector = _sub(end, start) + if math.sqrt(sum(component * component for component in vector)) <= 1e-9: + raise UnsupportedCapability("transform_translation_entity", "TRANSLATION_ENTITY vector is degenerate") + if _bool(params.get("oppositeDirectionEntity")): + vector = [-component for component in vector] + return vector + + def _bake_transform( params: dict[str, Any], previous: list[str], @@ -2007,6 +3500,21 @@ def _bake_transform( offset = [_number(params.get(key, 0.0), True) for key in ("dx", "dy", "dz")] transform_frame = lambda frame: _translate_frame(frame, offset) transform_axis = lambda axis: {**axis, "origin_mm": [axis["origin_mm"][index] + offset[index] for index in range(3)]} + elif transform_type == "TRANSLATION_DISTANCE": + offset = _translation_distance_vector(params, feature_frames, sketch_by_source, entity_by_sketch) + transform_frame = lambda frame: _translate_frame(frame, offset) + transform_axis = lambda axis: {**axis, "origin_mm": [axis["origin_mm"][index] + offset[index] for index in range(3)]} + elif transform_type == "TRANSLATION_ENTITY": + # Baking is only semantics-preserving while the selected NEW result + # has not been absorbed or changed by another body-mutating feature. + if source.get("params", {}).get("result_mode") != "new_body" or previous[-1:] != [source_id]: + raise UnsupportedCapability( + "transform_body_lifecycle", + "TRANSLATION_ENTITY bake requires an immediately preceding independent NEW body", + ) + offset = _translation_entity_vector(params, feature_frames, sketch_by_source, entity_by_sketch) + transform_frame = lambda frame: _translate_frame(frame, offset) + transform_axis = lambda axis: {**axis, "origin_mm": [axis["origin_mm"][index] + offset[index] for index in range(3)]} elif transform_type == "ROTATION": axis = _transform_axis(params.get("transformAxis"), feature_frames, sketch_by_source, entity_by_sketch) angle_rad = math.radians(_number(params.get("angle"), True)) @@ -2033,6 +3541,206 @@ def _bake_transform( source["params"]["axis"] = transform_axis(source_axis) +def _record_non_copy_body_successors( + aliases: dict[str, str], + source_ids: list[str], + successor_id: str, +) -> None: + """Bind direct transform sources to their latest physical body member. + + A non-copy transform replaces exactly the selected independent members in + the runtime. Preserve that one-to-one lifecycle fact for later CADFS + queries that retain the original producer ID. This deliberately has no + fallback for fused/dress-up/pattern members because those are not entered + into ``aliases`` by lowering. + """ + for source in source_ids: + for owner, current in list(aliases.items()): + if current == source: + aliases[owner] = successor_id + aliases[source] = successor_id + + +_SINGLE_BODY_FUSING_ATOMICS = frozenset({ + "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", + "extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", +}) +_SINGLE_BODY_DRESSUP_ATOMICS = frozenset({"fillet", "chamfer", "shell"}) +_SINGLE_BODY_CUT_ATOMICS = frozenset({ + "extrude_cut_blind", "extrude_cut_two_sided", "revolve_cut", + "hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard", "thread_cut", +}) +_SINGLE_BODY_NON_MUTATING_ATOMICS = frozenset({"reference_plane", "reference_axis", "extrude_surface", "revolve_surface"}) +_LOWERING_BODY_MUTATING_ATOMICS = frozenset({ + "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", + "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_from_face", + "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", + "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", + "hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard", "fillet", "chamfer", + "shell", "boolean_bodies", +}) +_LOWERING_CUT_ATOMICS = frozenset({ + "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "revolve_cut", + "thread_cut", "hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard", +}) +_LOWERING_PRIMARY_ATOMICS = frozenset({ + "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_from_face", + "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "sphere_add", "box_add", + "cylinder_add", "thread_add", "bend_add", +}) + + +def _record_lowered_body_members(members: set[str], feature: dict[str, Any]) -> None: + """Mirror the runtime's independently selectable body-member contract. + + This projection is intentionally narrower than geometric body ownership. + It only decides whether a later direct CADFS SWEPT_BODY can be emitted as a + CDSL shell target. No aggregate/current-body fallback is permitted here. + """ + feature_id = str(feature["id"]) + atomic_id = str(feature.get("atomic_id") or "") + params = feature.get("params") or {} + if atomic_id == "boolean_bodies": + targets = {str(value) for value in params.get("target_feature_ids") or ()} + tools = {str(value) for value in params.get("tool_feature_ids") or ()} + members.difference_update(targets | tools) + members.add(feature_id) + if bool(params.get("keep_tools")): + members.update(tools) + return + if atomic_id == "transform_bodies": + sources = {str(value) for value in params.get("source_feature_ids") or ()} + if bool(params.get("make_copy")): + if len(sources) == 1: + members.add(feature_id) + return + members.difference_update(sources) + members.add(feature_id) + return + if atomic_id == "delete_bodies": + members.difference_update(str(value) for value in params.get("target_feature_ids") or ()) + return + if atomic_id in {"pattern_linear", "pattern_mirror", "pattern_circular"}: + sources = {str(value) for value in params.get("source_feature_ids") or ()} + if not ( + atomic_id == "pattern_circular" + and str(params.get("operation_mode") or "add") == "add" + and sources + and sources <= members + ): + members.clear() + return + if atomic_id not in _LOWERING_BODY_MUTATING_ATOMICS: + return + if atomic_id in _LOWERING_CUT_ATOMICS or ( + atomic_id == "extrude_from_face" and params.get("operation") == "cut" + ): + return + if atomic_id in _LOWERING_PRIMARY_ATOMICS and params.get("result_mode") == "new_body": + members.add(feature_id) + return + members.clear() + members.add(feature_id) + + +def _clear_single_body_successor_state( + aliases: dict[str, str], + state: dict[str, Any], +) -> None: + """Discard only aliases derived from the restricted aggregate lineage.""" + for source in state["sources"]: + aliases.pop(source, None) + state["owner"] = None + state["sources"] = set() + + +def _record_single_body_successor( + aliases: dict[str, str], + state: dict[str, Any], + feature: dict[str, Any], +) -> None: + """Track one CADFS body through exact single-aggregate successors. + + A ``SWEPT_BODY`` query names a CADFS body object, not a frozen feature + result. When an ordinary additive feature or dress-up mutates the only + active body, the original body query still denotes that same physical + body. Runtime collapses those operations to one explicit body member, so + lowering may follow the successor only while this state machine mirrors + that one-member lifecycle exactly. Multi-body, boolean, pattern, delete, + and other body-changing paths intentionally clear the proof rather than + substituting ``session.body``. + """ + feature_id = str(feature["id"]) + atomic_id = str(feature.get("atomic_id") or "") + params = feature.get("params") or {} + owner = state["owner"] + sources: set[str] = state["sources"] + + def advance() -> None: + for source in sources: + if source != feature_id: + aliases[source] = feature_id + aliases.pop(feature_id, None) + sources.add(feature_id) + state["owner"] = feature_id + + if atomic_id in _SINGLE_BODY_NON_MUTATING_ATOMICS: + return + if atomic_id in _SINGLE_BODY_CUT_ATOMICS: + # Runtime preserves the selected member keys for a cut. The cut + # feature itself is not a new independently selectable body member. + return + if atomic_id in _SINGLE_BODY_FUSING_ATOMICS: + if atomic_id == "extrude_from_face" and params.get("operation") == "cut": + return + if params.get("result_mode") == "new_body": + if owner is None: + state["owner"] = feature_id + sources.add(feature_id) + else: + _clear_single_body_successor_state(aliases, state) + return + if owner is None: + state["owner"] = feature_id + sources.add(feature_id) + else: + advance() + return + if atomic_id in _SINGLE_BODY_DRESSUP_ATOMICS: + if owner is not None: + advance() + return + if atomic_id == "transform_bodies": + source_ids = [str(value) for value in params.get("source_feature_ids") or ()] + if bool(params.get("make_copy")): + # The original member remains addressable, but the aggregate is no + # longer a one-body lifecycle. Do not let a later ordinary ADD or + # dress-up advance an alias across that unproven split. + _clear_single_body_successor_state(aliases, state) + return + if owner is not None and source_ids == [owner] and not params.get("pattern_instance_refs"): + advance() + return + _clear_single_body_successor_state(aliases, state) + return + if atomic_id == "pattern_circular": + # A direct circular ADD over the sole current member takes the runtime + # body-member path: it preserves that member and exposes each rotated + # copy under an exact instance key. Keep the already-proven aliases so + # a following CADFS COPY(SWEPT_BODY) resolves to the same member. + source_ids = [str(value) for value in params.get("source_feature_ids") or ()] + if ( + owner is not None + and str(params.get("operation_mode") or "add") == "add" + and source_ids == [owner] + ): + return + # The remaining body atomics either split ownership, select explicit + # members, or replay feature geometry. Their CADFS body continuation is + # not represented by this restricted one-member contract. + _clear_single_body_successor_state(aliases, state) + + def _query_point( query: Any, feature_frames: dict[str, dict[str, Any]], @@ -2041,9 +3749,12 @@ def _query_point( ) -> list[float]: info = parse_query(query) if info.topology_type == "CAP_VERTEX" and info.owner_feature in feature_frames: + frame_data = feature_frames[info.owner_feature] + cap = frame_data["start" if info.is_start else "end"] + profile = frame_data.get("profile") + plane = {**profile, "origin_mm": list(cap["origin_mm"])} if isinstance(profile, dict) else cap references = _source_refs(query) if len(references) >= 2: - plane = feature_frames[info.owner_feature]["start" if info.is_start else "end"] lines = [] for source, token in references: available = entity_by_sketch.get(source) or {} @@ -2058,7 +3769,10 @@ def _query_point( if distance <= 1e-5: return point entity, plane, token = _entity_from_query(query, sketch_by_source, entity_by_sketch) if info.topology_type == "CAP_VERTEX" and info.owner_feature in feature_frames: - plane = feature_frames[info.owner_feature]["start" if info.is_start else "end"] + frame_data = feature_frames[info.owner_feature] + cap = frame_data["start" if info.is_start else "end"] + profile = frame_data.get("profile") + plane = {**profile, "origin_mm": list(cap["origin_mm"])} if isinstance(profile, dict) else cap return _entity_point(entity, plane, token) @@ -2071,7 +3785,12 @@ def _cplane( plane_type = str(params.get("cplaneType") or "OFFSET").split(".")[-1].upper() entities = _queries(params.get("entities")) if plane_type == "OFFSET": - return _shift_plane(_query_plane(entities[0], feature_frames, sketch_by_source, entity_by_sketch), _number(params.get("offset", 0), True)) + offset = _number(params.get("offset", 0), True) + # CPlane OFFSET preserves the source plane's local frame. CADFS uses + # oppositeDirection only to select the other signed offset side. + if _bool(params.get("oppositeDirection")): + offset = -offset + return _shift_plane(_query_plane(entities[0], feature_frames, sketch_by_source, entity_by_sketch), offset) if plane_type == "LINE_ANGLE": line_query = next((item for item in entities if parse_query(item).source_entity), None) if line_query is None: raise ValueError("line-angle reference line is unresolved") @@ -2160,12 +3879,62 @@ def _cplane( raise UnsupportedCapability(f"reference_plane:{plane_type.lower()}", f"current converter has no exact {plane_type} reference plane") +def _mirror_plane_from_query( + 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] | None: + """Materialize the bounded planar mirror-face source into a CDSL frame. + + A full solid revolve turns a source line perpendicular to its axis into a + planar annular face. That face has a source-defined physical plane even + though it is neither a datum plane nor a separately lowered cPlane. The + generic ``_query_plane`` path already proves that frame from the direct + line and revolve axis; use it here only after checking the exact producer + contract. Curved, partial, derived, or non-revolve swept faces remain + unresolved instead of becoming a guessed mirror plane. + """ + plane = _default_plane(value) + if plane is not None: + return plane + info = parse_query(value) + if info.topology_type != "SWEPT_FACE" or not info.owner_feature: + return None + producer = feature_by_id.get(f"f_{info.owner_feature}") or {} + frame = feature_frames.get(info.owner_feature) or {} + if ( + producer.get("atomic_id") != "revolve_add" + or not frame.get("revolve_full") + or not isinstance(frame.get("revolve_axis"), dict) + ): + return None + try: + return _query_plane(value, feature_frames, sketch_by_source, entity_by_sketch) + except (UnsupportedCapability, ValueError): + return None + + 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]] = {}; cap_profiles: dict[str, dict[tuple[str, str], dict[str, Any]]] = {}; surface_profiles: list[dict[str, Any]] = []; swept_face_sketches: set[str] = set() + feature_frames: dict[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] = {} + # Original CADFS operation owners can remain the query anchor after an + # explicit non-copy body transform. Map only those proven transform + # successors; all other body lifecycle transitions remain unaliased. + body_transform_aliases: dict[str, str] = {} + # This is the lowering-side mirror of runtime ``body_members``. It makes + # a source-qualified shell.parts target possible only while that source is + # still one explicit selectable body member. + lowered_body_members: set[str] = set() + # A direct SWEPT_BODY query can continue to name the sole CADFS body after + # ordinary additive and dress-up successors. This state is deliberately + # cleared before any aggregate, multi-member, or otherwise ambiguous body + # transition can make that continuation non-unique. + single_body_successor_state: dict[str, Any] = {"owner": None, "sources": set()} 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]}) @@ -2186,6 +3955,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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 + if isinstance(exc, OpenSketchProfileError): continue except Exception: pass diagnostics.append({"code": "sketch_deferred", "feature_id": step.feature_id, "message": str(exc)}); complete = False @@ -2197,18 +3967,92 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: try: fid = f"f_{item.feature_id}"; depends = list(previous[-1:]); p = item.params; feature: dict[str, Any] if item.operation == "transform": - # 仅将单一、直接的原始实体变换烘焙回其输入几何。不能移动当前 - # 聚合主体: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 + # 单一直接 source 可以烘焙回原始几何。多 body 与 COPY instance + # 必须保留为显式 body graph transform,不能移动聚合主体。 + sources, pattern_instance_refs, transform_copy_refs = _transform_body_references( + p.get("entities"), previous, feature_by_id, body_transform_aliases, + ) + transform_type = str(p.get("transformType") or "").split(".")[-1].upper() + identity_copy = transform_type == "COPY" + # Uniform scaling changes the B-rep's dimensions. It cannot be + # baked into a source sketch without also transforming every + # dependent parameter and topology frame, so retain it as an + # explicit body-graph operation even for one direct source. + source_for_bake = sources[0] if len(sources) == 1 else None + source_feature_for_bake = feature_by_id.get(source_for_bake or "") + if ( + transform_type not in {"SCALE_UNIFORMLY", "COPY"} + and not _bool(p.get("makeCopy")) + and len(sources) == 1 + and not pattern_instance_refs + # Baking mutates an already emitted source sketch and its + # lowering-only frames. It is therefore equivalent to a + # transform only for the immediately preceding, + # independent NEW member. A boolean, dress-up, later add, + # or even a reference feature can retain geometry from the + # pre-transform source; changing that source retroactively + # would invert CADFS history order. + and ( + # _bake_transform has a stronger lifecycle diagnostic + # for TRANSLATION_ENTITY. Let it run even when the + # source was absorbed so that this unrepresentable + # body move is rejected rather than silently lowered + # as an explicit transform of a successor member. + transform_type == "TRANSLATION_ENTITY" + or ( + # A later transform may retain the original CADFS + # owner while the physical member is an earlier + # transform's successor. Baking it into the + # original sketch would discard the first move. + sources == _transform_source_features(p.get("entities"), previous) + and + previous[-1:] == sources + and (source_feature_for_bake or {}).get("params", {}).get("result_mode") == "new_body" + ) + ) + ): + _bake_transform(p, previous, feature_by_id, feature_source_by_id, sketches_by_id, feature_frames, sketch_by_source, entity_by_sketch) + continue + missing = [source for source in sources if source not in feature_by_id] + if missing: + raise ValueError("transform source bodies are unresolved: " + ", ".join(missing)) + pattern_dependencies = [reference["pattern_feature_id"] for reference in pattern_instance_refs] + transform_copy_dependencies = [reference["transform_feature_id"] for reference in transform_copy_refs] + transform_params: dict[str, Any] = { + "transform": _body_transform( + p, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous, + ), + "make_copy": identity_copy or _bool(p.get("makeCopy")), + } + if sources: + transform_params["source_feature_ids"] = sources + if pattern_instance_refs: + transform_params["pattern_instance_refs"] = pattern_instance_refs + if transform_copy_refs: + transform_params["transform_copy_refs"] = transform_copy_refs + feature = { + "id": fid, + "name": item.feature_id, + "atomic_id": "transform_bodies", + "depends_on": list(dict.fromkeys(sources + pattern_dependencies + transform_copy_dependencies + depends)), + "params": transform_params, + "execution_status": "supported", + } elif item.operation == "deleteBodies": - copies = [_pattern_copy_body(query) for query in _queries(p.get("entities"))] - if not copies: + queries = _queries(p.get("entities")) + if not queries: raise ValueError("deleteBodies selection is empty") - for pattern_id, source_id, instance in copies: + targets = [] + for query in queries: + _call, owner, topology, kind, _definition = _direct_make_query(query) + pattern_id = f"f_{owner}" 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") + if topology != "COPY" or kind not in {"body", "entitytype.body"} or pattern is None or pattern.get("atomic_id") != "pattern_circular": + target = _delete_body_source(query) + if target not in targets: + targets.append(target) + continue + pattern_id, source_id, instance = _pattern_copy_body(query) 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") @@ -2218,7 +4062,19 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: excluded = params.setdefault("excluded_instance_indices", []) if instance not in excluded: excluded.append(instance) - continue + if not targets: + continue + missing = [target for target in targets if target not in feature_by_id] + if missing: + raise ValueError("deleteBodies source bodies are unresolved: " + ", ".join(missing)) + feature = { + "id": fid, + "name": item.feature_id, + "atomic_id": "delete_bodies", + "depends_on": list(dict.fromkeys(targets + depends)), + "params": {"target_feature_ids": targets}, + "execution_status": "supported", + } 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"} @@ -2246,20 +4102,30 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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_face_output_selector = _cap_face_output_role_selector( + profile_value, feature_by_id, sketches_by_id, + ) 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( + planar_imprint_profile = _planar_imprint_profile_sketch( profile_value, sketch_by_source, entity_by_sketch, fid, ) if profile_kind == "INTERSECT" else None + intersect_profile_sketch = _intersect_partition_profile_sketch( + profile_value, sketch_by_source, entity_by_sketch, fid, + ) if profile_kind == "INTERSECT" and planar_imprint_profile is None else None offset_face_profile = _offset_face_profile_sketch( - profile_value, feature_frames, sketches_by_id, sketch_by_source, entity_by_sketch, fid, + profile_value, feature_frames, sketches_by_id, sketch_by_source, entity_by_sketch, feature_by_id, fid, ) if profile_kind == "OFFSET_FACE" else None - if cap_edge_hole is not None: + profile_sketch: dict[str, Any] | None = None + if cap_face_output_selector is not None: + # Keep the B-rep face as a runtime-derived profile. It + # must not be reconstructed from the original sketch. + pass + elif 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 @@ -2268,8 +4134,8 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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 + elif planar_imprint_profile is not None: + profile_sketch = planar_imprint_profile sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch elif intersect_profile_sketch is not None: profile_sketch = intersect_profile_sketch @@ -2283,14 +4149,14 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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 cap_face_output_selector is None and cap_edge_hole is None and cap_edge_union_profile is None and planar_imprint_profile 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": + if cutting and profile_kind == "IMPRINT" and profile_sketch is not None: 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): @@ -2298,7 +4164,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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") + if profile_sketch is not None and 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) @@ -2357,8 +4223,24 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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 cap_face_output_selector is not None: + params["operation"] = "cut" if cutting else "add" + if second or end["type"] == "mid_plane": + params["two_sided"] = True + feature = { + "id": fid, + "name": item.feature_id, + "atomic_id": "extrude_from_face", + "depends_on": depends, + "params": params, + "selectors": [cap_face_output_selector], + "execution_status": "supported", + } + else: + if profile_sketch is None: + raise ValueError("extrude profile is unresolved") + 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") @@ -2374,7 +4256,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: "params": surface_params, "execution_status": "supported", } - plane = profile_sketch["workplane"] + plane = _query_plane(profile_value, feature_frames, sketch_by_source, entity_by_sketch) if cap_face_output_selector is not None else profile_sketch["workplane"] if end["type"] == "blind" and not second: direction = -1 if reverse else 1 # FeatureScript 的 CAP_FACE 是实体端盖,而不是原草图平面。 @@ -2384,17 +4266,34 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: "start": _oriented_plane(plane, -direction), "end": _oriented_plane(plane, direction, direction * depth), "profile": dict(plane), + "profile_source": source, + } + elif ( + second + and end["type"] == "blind" + and (params.get("reverse_end_condition") or {}).get("type") == "blind" + ): + # A two-sided blind extrusion has no cap at the source + # plane. Keep the one-sided CAP convention: ``isStart`` + # is the cap on the side opposite the primary extent and + # ``isStart:false`` is the primary-extent cap. This makes + # the zero-second-distance limit agree with the + # one-sided frame above, regardless of oppositeDirection. + direction = -1 if reverse else 1 + reverse_depth = float(params["reverse_distance_mm"]) + feature_frames[item.feature_id] = { + "start": _oriented_plane(plane, -direction, -direction * reverse_depth), + "end": _oriented_plane(plane, direction, direction * depth), + "profile": dict(plane), + "profile_source": source, } - 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": _oriented_plane(plane, -direction, -direction * depth / 2), "end": _oriented_plane(plane, direction, direction * depth / 2), "profile": dict(plane), + "profile_source": source, } elif item.operation == "loft": cap_face_loft = _loft_cap_face_profile(p, sketch_by_source, entity_by_sketch, feature_frames, fid) @@ -2432,7 +4331,13 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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 + if frames is not None: + # This lowering-only provenance permits an exact source + # endpoint pair for a two-section direct loft. It is not + # a replacement for runtime topology history. + if cap_face_loft is None: + frames["loft_profile_sources"] = sources + 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: @@ -2481,26 +4386,79 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: } 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): + # An omitted FeatureScript ``targets`` field is the exact + # targetless-UNION form. ``_queries(None)`` intentionally + # returns one placeholder for callers that need a diagnostic, + # so it cannot be used to decide whether this field exists. + has_targets = p.get("targets") is not None + targets, target_instance_refs = ( + _boolean_body_references( + p.get("targets"), previous, feature_by_id, body_transform_aliases, + ) + if has_targets else ([], []) + ) + tools, tool_instance_refs = _boolean_body_references( + p.get("tools"), previous, feature_by_id, body_transform_aliases, + ) + if not has_targets: + # FeatureScript UNION permits one tools set without a + # separate target. A union is commutative, so selecting + # one exact member as the CDSL target preserves geometry + # only when tools are not retained. keepTools would need + # an explicit all-tools retention contract. + if operation != "UNION" or _bool(p.get("keepTools")): + raise UnsupportedCapability( + "boolean_bodies_targets", + "targetless booleanBodies is currently supported only for UNION with keepTools:false", + ) + selections = [("feature", value) for value in tools] + [("pattern", value) for value in tool_instance_refs] + if len(selections) < 2: + raise ValueError("targetless booleanBodies union requires at least two explicit bodies") + kind, selected = selections.pop(0) + if kind == "feature": + targets, target_instance_refs = [selected], [] + tools = [value for source_kind, value in selections if source_kind == "feature"] + tool_instance_refs = [value for source_kind, value in selections if source_kind == "pattern"] + else: + targets, target_instance_refs = [], [selected] + tools = [value for source_kind, value in selections if source_kind == "feature"] + tool_instance_refs = [value for source_kind, value in selections if source_kind == "pattern"] + target_keys = {("feature", source) for source in targets} | { + ("pattern", reference["pattern_feature_id"], reference["source_feature_id"], reference["instance_index"]) + for reference in target_instance_refs + } + tool_keys = {("feature", source) for source in tools} | { + ("pattern", reference["pattern_feature_id"], reference["source_feature_id"], reference["instance_index"]) + for reference in tool_instance_refs + } + if target_keys & tool_keys: 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)) + pattern_dependencies = list(dict.fromkeys([ + *(reference["pattern_feature_id"] for reference in target_instance_refs), + *(reference["pattern_feature_id"] for reference in tool_instance_refs), + ])) feature = { "id": fid, "name": item.feature_id, "atomic_id": "boolean_bodies", - "depends_on": list(dict.fromkeys(targets + tools + depends)), + "depends_on": list(dict.fromkeys(targets + tools + pattern_dependencies + depends)), "params": { "operation": operation_map[operation], - "target_feature_ids": targets, - "tool_feature_ids": tools, "keep_tools": _bool(p.get("keepTools")), }, "execution_status": "supported", } + if targets: + feature["params"]["target_feature_ids"] = targets + if tools: + feature["params"]["tool_feature_ids"] = tools + if target_instance_refs: + feature["params"]["target_pattern_instance_refs"] = target_instance_refs + if tool_instance_refs: + feature["params"]["tool_pattern_instance_refs"] = tool_instance_refs elif item.operation == "revolve": # surfaceOperationType alone does not make a body operation a # surface operation. CADFS emits it for closed sketch regions @@ -2532,7 +4490,24 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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} + if full: + frame = {"revolve_axis": {"origin_mm": start, "direction": direction}, "revolve_full": True} + # A SWEPT_EDGE circle is only lowerable when both profile + # and axis are still direct source-sketch entities. Keep + # this lowering-only provenance out of the public CDSL. + axis_direct = ( + axis_q.source_sketch == source + and axis_q.source_entity is not None + and _source_ref_entity(source, axis_q.source_entity, entity_by_sketch) is not None + ) + if ( + atomic == "revolve_add" + and params.get("result_mode") == "new_body" + and _profile_matches_direct_source(profile_sketch, sketch_by_source[source]) + and axis_direct + ): + frame["profile_source"] = source + feature_frames[item.feature_id] = frame 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() @@ -2570,29 +4545,18 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: "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 - if len(refs) >= 2: - left = (entity_by_sketch.get(refs[0][0]) or {}).get(refs[0][1]); right = (entity_by_sketch.get(refs[1][0]) or {}).get(refs[1][1]) - 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") - # 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": + if selector_kind == "edge" and query.topology_type == "SWEPT_EDGE": + geometry = _swept_edge_line_selector_geometry( + owner, refs, frame or {}, feature_by_id, sketch_by_source, entity_by_sketch, + ) + if geometry is None: + geometry = _swept_edge_revolve_circle_selector_geometry( + owner, refs, frame or {}, feature_by_id, sketch_by_source, entity_by_sketch, + ) + if geometry is None: + raise ValueError("swept edge source endpoint provenance is unsupported") + elif selector_kind == "edge" and source_entity and cap: + if source_entity["type"] == "circle": # Keep the source circle signature until the prefix has been # rebuilt. OCC may expose it as one edge or several arcs. 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 or {"curve_type": "circle", "source_circle_center_mm": _global(cap, source_entity["center"]), "source_circle_radius_mm": source_entity["radius_mm"], "source_plane_normal": cap["normal"]}}) @@ -2619,10 +4583,9 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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]] = {} + cap_removals: list[tuple[str, str]] = [] 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) @@ -2630,34 +4593,83 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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) + if query.owner_feature and query.is_start is not None: + cap_removals.append((query.owner_feature, "start" if query.is_start else "end")) 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 == "SWEPT_FACE": + selector = _direct_linear_extrude_swept_face_shell_reference( + query_value, feature_frames, sketch_by_source, sketches_by_id, + entity_by_sketch, feature_by_id, previous, + ) + elif topology == "OFFSET_FACE": + selector = _shell_offset_face_output_role_selector( + query_value, feature_by_id, sketches_by_id, previous, + ) 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}" + raise UnsupportedCapability("shell_face_selector", "current CDSL shell requires CAP_FACE, direct linear-extrude SWEPT_FACE, or COPY(CAP_FACE) removal selectors") + # Feature-output roles resolve only through the active + # kernel snapshot. A stable id would turn that semantic + # evidence into a stale geometric selector. + if selector.get("output_role") is None: + 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"} + # CADFS's oppositeDirection selects the exterior material + # side. The runtime contract carries this directly to OCC's + # signed offset; it is not a request to reverse removal-face + # ownership or a candidate for a current-body fallback. + shell_params = {"thickness_mm": thickness, "inward": not _bool(p.get("oppositeDirection"))} + if p.get("parts") is not None: + try: + shell_params["target_feature_id"] = _shell_target_body_source( + p["parts"], previous, body_transform_aliases, lowered_body_members, + ) + except (UnsupportedCapability, ValueError) as error: + # Keep the established face-scoped execution path for + # legacy histories, but make the omitted parts-owner + # proof visible instead of silently treating the active + # aggregate as an explicitly selected body. + diagnostics.append({ + "code": "unresolved_body_source", + "capability": "shell_parts_body_source", + "feature_id": item.feature_id, + "operation": item.operation, + "message": str(error), + }) + feature = {"id": fid, "name": item.feature_id, "atomic_id": "shell", "depends_on": depends, "params": shell_params, "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] = { + shell_frame = { **source_frame, "shell_source": source, "shell_thickness_mm": thickness, "shell_profile_sketch_id": source_feature["sketch_id"], } + # A derived inner wall has a bounded span only when + # this direct shell removes exactly one known cap of + # the same extrusion. Additional removal faces can + # change its trim topology, so do not infer a wall. + if ( + shell_params["inward"] + and len(cap_removals) == 1 + and cap_removals[0][0] == source + ): + shell_frame["shell_inward"] = True + shell_frame["shell_removed_cap"] = cap_removals[0][1] + feature_frames[item.feature_id] = shell_frame if offset_edge_planes: frame = feature_frames.setdefault(item.feature_id, {}) frame["shell_offset_edge_planes"] = offset_edge_planes @@ -2687,7 +4699,10 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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) + sources = _pattern_body_history_sources( + p.get("entities"), sources, previous, feature_by_id, + body_transform_aliases, + ) axis = _circular_pattern_axis(p.get("axis"), feature_frames, sketch_by_source, entity_by_sketch) count = int(_number(p.get("instanceCount"))) if count < 1: @@ -2733,7 +4748,9 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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) + plane = _mirror_plane_from_query( + plane_query, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, + ) if plane is None: raise ValueError("mirror plane is not a default or reference plane") plane_owner = f"{fid}_plane" features.append({"id": plane_owner, "name": f"{item.feature_id} plane", "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane}, "execution_status": "supported"}) @@ -2752,6 +4769,23 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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 + if ( + item.operation == "transform" + and feature.get("atomic_id") == "transform_bodies" + and not bool(feature["params"].get("make_copy")) + and not feature["params"].get("pattern_instance_refs") + ): + _record_non_copy_body_successors( + body_transform_aliases, + list(feature["params"].get("source_feature_ids") or ()), + fid, + ) + _record_single_body_successor( + body_transform_aliases, + single_body_successor_state, + feature, + ) + _record_lowered_body_members(lowered_body_members, feature) if item.operation == "extrude" and surface_profile_sketch is not None: features.append(surface_feature) surface_profiles.append({ diff --git a/cadfs_to_cdsl/pipeline.py b/cadfs_to_cdsl/pipeline.py index bccc6b82..c40a1f97 100644 --- a/cadfs_to_cdsl/pipeline.py +++ b/cadfs_to_cdsl/pipeline.py @@ -117,6 +117,12 @@ def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_se if outcome == "completed": result = read_json(worker_result); worker_result.unlink(missing_ok=True) bound_cdsl = result.pop("bound_cdsl", None) + prefix = result.get("last_executable_prefix") + if isinstance(prefix, dict): + prefix_bound_cdsl = prefix.pop("bound_cdsl", None) + if bound_cdsl is None and prefix_bound_cdsl is not None: + bound_cdsl = prefix_bound_cdsl + prefix["bound_feature_count"] = prefix.get("feature_count") if bound_cdsl is not None: write_json(directory / "bound.cdsl.json", bound_cdsl) else: step_path.unlink(missing_ok=True) @@ -144,6 +150,10 @@ def compare_one(sample: Sample, output: Path, *, force: bool = False, compare_mo message = f"comparison exceeded {timeout_seconds:g} seconds" if outcome == "timeout" else "comparison worker exited without a result" status["comparison_error"] = {"type": error_type, "message": message}; write_json(status_path, status); return status else: comparison = read_json(comparison_path) + # A forced retry can recover from a transient worker timeout. The final + # status must describe the successful comparison rather than retaining a + # stale infrastructure failure beside an accepted result. + status.pop("comparison_error", None) status["comparison_decision"] = comparison["decision"] accepted = comparison[compare_mode]["passed"] status["status"] = "rebuilt_strict" if comparison["strict"]["passed"] else "rebuilt_approximate" if accepted else "rebuilt_rejected" diff --git a/cadfs_to_cdsl/rebuild.py b/cadfs_to_cdsl/rebuild.py index 89543a2e..bafd00dc 100644 --- a/cadfs_to_cdsl/rebuild.py +++ b/cadfs_to_cdsl/rebuild.py @@ -1,9 +1,52 @@ from __future__ import annotations +from copy import deepcopy from pathlib import Path +import re from typing import Any +def _last_executable_prefix( + cdsl: dict[str, Any], + failed_feature_id: str | None, + output: Path, +) -> dict[str, Any] | None: + """Bind and export the longest verified prefix before a failed feature.""" + from .selector_binding import bind_candidate_selectors + from engine.cdsl_engine.runtime import rebuild_cdsl + + features = list(cdsl.get("features") or []) + failed_index = next( + (index for index, feature in enumerate(features) if feature.get("id") == failed_feature_id), + len(features), + ) + for feature_count in range(failed_index, 0, -1): + prefix = deepcopy(cdsl) + prefix["features"] = features[:feature_count] + try: + bound_prefix, _binding = bind_candidate_selectors(prefix) + result = rebuild_cdsl(bound_prefix, output, strict=True) + except Exception: + continue + return { + "failed_feature_id": failed_feature_id, + "feature_count": feature_count, + "last_feature_id": str(features[feature_count - 1].get("id") or ""), + "bound_cdsl": bound_prefix, + "result": result, + } + return None + + +def _failed_feature_id(error: Exception) -> str | None: + diagnostic = getattr(error, "diagnostic", None) + feature_id = getattr(diagnostic, "feature_id", None) + if isinstance(feature_id, str) and feature_id: + return feature_id + match = re.match(r"([^:\s]+): ", str(error)) + return match.group(1) if match else None + + def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]: from engine.cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl from .selector_binding import bind_candidate_selectors @@ -11,6 +54,7 @@ def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]: analysis_dict = analysis.as_dict() if hasattr(analysis, "as_dict") else {"runtime_eligible": analysis.runtime_eligible} if not analysis.runtime_eligible: return {"status": "runtime_ineligible", "analysis": analysis_dict} + bound: dict[str, Any] | None = None try: bound, binding = bind_candidate_selectors(cdsl) result = rebuild_cdsl(bound, output, strict=True) @@ -18,4 +62,8 @@ def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]: except Exception as exc: detail = {"type": type(exc).__name__, "message": str(exc)} if hasattr(exc, "selector_resolutions"): detail["selector_resolutions"] = exc.selector_resolutions - return {"status": "rebuild_failed", "analysis": analysis_dict, "error": detail} + prefix = _last_executable_prefix(cdsl, _failed_feature_id(exc), output) + result = {"status": "rebuild_failed", "analysis": analysis_dict, "error": detail} + if bound is not None: result["bound_cdsl"] = bound + if prefix is not None: result["last_executable_prefix"] = prefix + return result diff --git a/cadfs_to_cdsl/selector_binding.py b/cadfs_to_cdsl/selector_binding.py index 122be348..1f7b2145 100644 --- a/cadfs_to_cdsl/selector_binding.py +++ b/cadfs_to_cdsl/selector_binding.py @@ -10,7 +10,7 @@ from typing import Any def _score(expected: dict[str, Any], actual: dict[str, Any]) -> float | None: scores: list[float] = [] reversed_plane_normal = False - for key in ("center_mm", "start_mm", "end_mm", "normal", "axis_origin_mm", "axis_direction"): + for key in ("center_mm", "circle_center_mm", "start_mm", "end_mm", "normal", "axis_origin_mm", "axis_direction"): if key in expected: # plane_offset_mm 与平面方程绑定,必须使用记录平面方程时采用的 # plane_normal。face 的局部采样 normal 在 OCC 中可能与其相反。 @@ -101,6 +101,7 @@ def _bound_selector(placeholder: dict[str, Any], records: list[dict[str, Any]]) 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"] + if placeholder.get("owner_match_required"): selector["owner_match_required"] = True bound.append(selector) if placeholder.get("match_mode") == "all": selector = dict(placeholder); selector["matched_selectors"] = bound @@ -150,23 +151,76 @@ def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list resolved = [] for placeholder in targets: records = prefix_records(placeholder.get("binding_feature_id"), placeholder.get("owner_feature_id")) + output_role = str(placeholder.get("output_role") or "").strip() + if output_role: + # Builder output roles are not geometry placeholders. They + # remain in the bound CDSL so runtime can resolve the + # current active B-rep face through exact kernel history. + # Replacing one with stable_id/geometry would mix evidence + # and make a stale snapshot appear durable. + owner = placeholder.get("owner_feature_id") + role_source = placeholder.get("output_role_source") + source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None + source_role = role_source.get("output_role") if isinstance(role_source, dict) else None + candidates = [ + record for record in records + if record.get("kind") == placeholder.get("kind") + and owner in (record.get("owner_feature_ids") or [record.get("feature_id")]) + and output_role in (record.get("output_roles") or []) + and ( + role_source is None + or any( + item.get("output_role") == output_role + and item.get("owner_feature_id") == source_owner + and item.get("source_output_role") == source_role + for item in record.get("output_role_sources") or [] + ) + ) + ] + if len(candidates) != 1: + status = "not_found" if not candidates else "ambiguous" + raise ValueError( + f"{feature['id']}: selector_output_role_{status} after prefix rebuild" + ) + resolved.append(candidates[0]) + continue geometry = placeholder.get("geometry") or {} candidates = _circle_records(geometry, records) if geometry.get("source_circle_radius_mm") else [] if not candidates: same_kind = [record for record in records if record.get("kind") == placeholder.get("kind")] owner = placeholder.get("owner_feature_id") owner_matches = [record for record in same_kind if owner in (record.get("owner_feature_ids") or [record.get("feature_id")])] - pool = owner_matches or same_kind + # Pattern instance provenance is stronger than an ordinary + # feature owner: an unresolved instance cannot fall back to + # an aggregate face with matching geometry. Ordinary source + # selectors retain their established geometry-binding path + # across topology-changing dress-up operations. + pool = owner_matches if placeholder.get("owner_match_required") else (owner_matches or same_kind) if not geometry: # Context selectors (notably a generated mirror plane) # may have no geometric snapshot. Their owner-qualified # singleton identity is sufficient and must not be scored # as a zero-information geometric match. if len(pool) != 1: - raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild") + status = "not_found" if not pool else "ambiguous" + raise ValueError(f"{feature['id']}: selector_{status} after prefix rebuild") candidates = [pool[0]] else: scored = [(score, record) for record in pool if (score := _score(geometry, record.get("geometry") or {})) is not None and score >= 0.8] + # A source owner is preferred for ordinary geometry + # selectors, but can be retained by an unrelated exact + # continuation after a dress-up. If none of that + # owner's active records satisfies the full geometry + # signature, bind against the current active body and + # still require a unique threshold-qualified match. + # Instance-qualified selectors never take this path. + if not scored and not placeholder.get("owner_match_required"): + scored = [ + (score, record) + for record in same_kind + 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 placeholder.get("match_mode") != "all" and len(scored) > 1 and abs(scored[0][0] - scored[1][0]) <= 1e-9: @@ -177,7 +231,22 @@ def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list 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()) + def selector_key(selector: dict[str, Any]) -> tuple[Any, ...]: + stable_id = selector.get("stable_id") + if stable_id is not None: + return ("stable_id", str(stable_id)) + source = selector.get("output_role_source") + return ( + "output_role", + selector.get("owner_feature_id"), + selector.get("kind"), + selector.get("output_role"), + source.get("owner_feature_id") if isinstance(source, dict) else None, + source.get("output_role") if isinstance(source, dict) else None, + ) + + unique = {selector_key(selector): 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: diff --git a/cadfs_to_cdsl/tests/test_integration.py b/cadfs_to_cdsl/tests/test_integration.py index c83de4dc..1a62bd0b 100644 --- a/cadfs_to_cdsl/tests/test_integration.py +++ b/cadfs_to_cdsl/tests/test_integration.py @@ -1,5 +1,6 @@ from __future__ import annotations +from copy import deepcopy import tempfile, unittest from pathlib import Path from cadfs_to_cdsl.compare import compare_steps @@ -130,6 +131,32 @@ class IntegrationTests(unittest.TestCase): self.assertEqual(outcome["status"], "rebuilt") self.assertEqual(outcome["result"]["solid_count"], 2) + def test_outward_cap_shells_lower_and_rebuild_prefixes(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + cases = (("00090436", 2.5), ("00107631", 3.8)) + for sample_id, thickness in cases: + with self.subTest(sample_id=sample_id): + feature = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(feature.read_text(), sample_id), {}) + shell_index, shell = next( + (index, item) + for index, item in enumerate(result.cdsl["features"]) + if item["id"] == "f_F2" + ) + + self.assertEqual(shell["atomic_id"], "shell") + self.assertEqual(shell["params"], {"thickness_mm": thickness, "inward": False}) + self.assertNotIn("shell_outward", {item.get("capability") for item in result.diagnostics}) + + prefix = deepcopy(result.cdsl) + prefix["features"] = prefix["features"][:shell_index + 1] + with tempfile.TemporaryDirectory() as tmp: + outcome = rebuild_candidate(prefix, Path(tmp) / "outward-shell.step") + self.assertEqual(outcome["status"], "rebuilt") + self.assertEqual(outcome["result"]["solid_count"], 1) + 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" @@ -137,13 +164,57 @@ class IntegrationTests(unittest.TestCase): 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(result.status, "converted_complete") 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("F2", [item.get("feature_id") for item in result.diagnostics]) self.assertNotIn("F5", [item.get("feature_id") for item in result.diagnostics]) + def test_circular_pattern_00542223_preserves_all_sweep_arms(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"), {}) + candidate = dict(result.cdsl) + candidate["features"] = result.cdsl["features"][:4] + + from engine.cdsl_engine.runtime import rebuild_cdsl + + with tempfile.TemporaryDirectory() as tmp: + rebuilt = rebuild_cdsl(candidate, Path(tmp) / "pattern.step") + + self.assertEqual(rebuilt["solid_count"], 1) + bbox = rebuilt["bbox_mm"] + self.assertAlmostEqual(bbox["min"][0], -52.14101625137758) + self.assertAlmostEqual(bbox["min"][1], -57.500000100000065) + self.assertAlmostEqual(bbox["max"][0], 52.14101625137762) + self.assertAlmostEqual(bbox["max"][1], 37.5000001000001) + + def test_fused_body_circular_copy_faces_bind_and_shell_00542223(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") + + candidate = lower_model(parse_featurescript(feature.read_text(), "00542223"), {}).cdsl + with tempfile.TemporaryDirectory() as tmp: + outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step") + + self.assertEqual(outcome["status"], "rebuilt") + result = outcome["result"] + self.assertIn("f_F7", [item["feature_id"] for item in result["feature_results"]]) + shell_selectors = [ + item for item in result["selector_resolution"] + if item["feature_id"] == "f_F7" + ] + self.assertEqual( + {item["selector"]["owner_feature_id"] for item in shell_selectors}, + {"f_F5", "f_F6.c1.f_F5", "f_F6.c2.f_F5", "f_F1"}, + ) + self.assertTrue(all(item["status"] == "resolved" for item in shell_selectors)) + 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" diff --git a/cadfs_to_cdsl/tests/test_lowering.py b/cadfs_to_cdsl/tests/test_lowering.py index cac862c4..3c9b9950 100644 --- a/cadfs_to_cdsl/tests/test_lowering.py +++ b/cadfs_to_cdsl/tests/test_lowering.py @@ -1,10 +1,14 @@ from __future__ import annotations import json, math, tempfile, unittest +from copy import deepcopy from pathlib import Path +from cadfs_to_cdsl.compare import compare_steps from cadfs_to_cdsl.featurescript_parser import parse_featurescript -from cadfs_to_cdsl.lowering import _arc, _contours, _global, lower_model +from cadfs_to_cdsl.ir import Call +from cadfs_to_cdsl.lowering import _arc, _contours, _global, _number, _record_single_body_successor, lower_model from cadfs_to_cdsl.pipeline import compare_one, convert_one, rebuild_one +from cadfs_to_cdsl.rebuild import rebuild_candidate 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 @@ -16,10 +20,14 @@ class LoweringTests(unittest.TestCase): 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 / "status.json").write_text(json.dumps({ + "sample_id": sample.sample_id, "rebuild_status": "rebuilt", + "comparison_error": {"type": "TimeoutError", "message": "stale"}, + }), 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") + self.assertNotIn("comparison_error", status) def test_missing_dataset_fails_explicitly(self): with tempfile.TemporaryDirectory() as tmp: @@ -45,6 +53,121 @@ class LoweringTests(unittest.TestCase): self.assertAlmostEqual(arc["center"][1], 0.5) self.assertAlmostEqual(arc["radius_mm"], math.hypot(2.5, 0.5)) + def test_direct_extrude_and_two_section_loft_swept_edges_bind_for_fillet(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + cases = { + "00000715": ("f_F2", 2, True), + "00005267": ("f_F4", 4, False), + } + for sample_id, (fillet_id, selector_count, line_type) in cases.items(): + with self.subTest(sample_id=sample_id): + feature = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt" + result = lower_model(parse_featurescript(feature.read_text(), sample_id), {}) + self.assertEqual(result.status, "converted_complete") + fillet = next(item for item in result.cdsl["features"] if item["id"] == fillet_id) + self.assertEqual(len(fillet["selectors"]), selector_count) + self.assertTrue(all(item["owner_feature_id"] == fillet["depends_on"][0] for item in fillet["selectors"])) + self.assertTrue(all("bbox_mm" in item["geometry"] for item in fillet["selectors"])) + self.assertEqual(["curve_type" in item["geometry"] for item in fillet["selectors"]], [line_type] * selector_count) + with tempfile.TemporaryDirectory() as directory: + rebuilt = Path(directory) / "rebuild.step" + outcome = rebuild_candidate(result.cdsl, rebuilt) + self.assertEqual(outcome["status"], "rebuilt") + comparison = compare_steps(root / "step_abc" / sample_id[:4] / f"{sample_id}.step", rebuilt) + self.assertTrue(comparison["strict"]["passed"]) + + def test_swept_edge_requires_one_direct_shared_source_endpoint(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0000/00000715.txt" + source = feature.read_text(encoding="utf-8").replace( + 'sQuery(id+"F0.wireOp",EDGE,"E8")', + 'sQuery(id+"F0.wireOp",EDGE,"E1")', + 1, + ) + result = lower_model(parse_featurescript(source, "swept-edge-ambiguous"), {}) + self.assertEqual(result.status, "converted_partial") + self.assertEqual(result.diagnostics, [{ + "code": "feature_deferred", + "feature_id": "F2", + "operation": "fillet", + "message": "swept edge source endpoint provenance is unsupported", + }]) + + def test_direct_full_revolve_swept_circle_edges_bind_for_dressups(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + for sample_id, dressup_id, selector_count in ( + ("00048326", "f_F3", 2), + ("00025622", "f_F2", 1), + ): + with self.subTest(sample_id=sample_id): + feature = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt" + result = lower_model(parse_featurescript(feature.read_text(), sample_id), {}) + self.assertEqual(result.status, "converted_complete") + dressup = next(item for item in result.cdsl["features"] if item["id"] == dressup_id) + self.assertEqual(len(dressup["selectors"]), selector_count) + self.assertTrue(all( + selector["geometry"].get("curve_type") == "circle" + and len(selector["geometry"].get("circle_center_mm") or []) == 3 + and selector["geometry"].get("radius_mm", 0) > 0 + for selector in dressup["selectors"] + )) + with tempfile.TemporaryDirectory() as directory: + rebuilt = Path(directory) / "rebuild.step" + outcome = rebuild_candidate(result.cdsl, rebuilt) + self.assertEqual(outcome["status"], "rebuilt") + comparison = compare_steps(root / "step_abc" / sample_id[:4] / f"{sample_id}.step", rebuilt) + self.assertTrue(comparison["strict"]["passed"]) + + def test_full_revolve_swept_circle_requires_one_direct_shared_source_endpoint(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0000/00001352.txt" + source = feature.read_text(encoding="utf-8").replace( + 'sQuery(id+"F0.wireOp",EDGE,"E2")', + 'sQuery(id+"F0.wireOp",EDGE,"E1")', + 1, + ) + result = lower_model(parse_featurescript(source, "revolve-swept-edge-ambiguous"), {}) + self.assertEqual(result.status, "converted_partial") + self.assertIn({ + "code": "feature_deferred", + "feature_id": "F2", + "operation": "fillet", + "message": "swept edge source endpoint provenance is unsupported", + }, result.diagnostics) + + def test_equivalent_imprint_profile_retains_direct_full_revolve_swept_edges(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source = root / "featurescript_rp/0011/00112257.txt" + result = lower_model(parse_featurescript(source.read_text(), "00112257"), {}) + self.assertEqual(result.status, "converted_complete") + fillet = next(item for item in result.cdsl["features"] if item["id"] == "f_F2") + self.assertEqual(len(fillet["selectors"]), 5) + self.assertTrue(all( + selector["geometry"].get("curve_type") == "circle" + for selector in fillet["selectors"] + )) + + from cadfs_to_cdsl.selector_binding import bind_candidate_selectors + prefix = dict(result.cdsl) + prefix["features"] = result.cdsl["features"][:2] + bound, evidence = bind_candidate_selectors(prefix) + selectors = next(item for item in bound["features"] if item["id"] == "f_F2")["selectors"] + self.assertEqual(len(evidence), 1) + self.assertEqual(len(selectors), 5) + self.assertTrue(all(selector.get("snapshot_id") for selector in selectors)) + + def test_changed_imprint_profile_does_not_inherit_full_revolve_source_edges(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source = root / "featurescript_rp/0059/00590828.txt" + result = lower_model(parse_featurescript(source.read_text(), "00590828"), {}) + self.assertEqual(result.status, "converted_partial") + self.assertIn({ + "code": "feature_deferred", + "feature_id": "F2", + "operation": "fillet", + "message": "swept edge source endpoint provenance is unsupported", + }, result.diagnostics) + def test_unsupported_operation_is_audited_not_invented(self): source = SOURCE.replace('extrude(context, id + "F1",', 'draft(context, id + "F1",') result = lower_model(parse_featurescript(source, "00000173"), {}) @@ -60,12 +183,444 @@ class LoweringTests(unittest.TestCase): self.assertFalse(result.diagnostics) self.assertEqual(result.cdsl["geometry"]["sketches"][0]["workplane"]["origin_mm"], [10.0, 0.0, 0.0]) - def test_copy_transform_remains_an_explicit_engine_capability_gap(self): + def test_copy_transform_lowers_to_an_explicit_body_operation(self): source = TRANSFORM_SOURCE.replace('"makeCopy":false', '"makeCopy":true') result = lower_model(parse_featurescript(source, "transform-copy"), {}) + self.assertEqual(result.status, "converted_complete") + self.assertFalse(result.diagnostics) + transform = result.cdsl["features"][-1] + self.assertEqual(transform["atomic_id"], "transform_bodies") + self.assertEqual(transform["params"], { + "source_feature_ids": ["f_F1"], + "transform": {"type": "translation", "translation_mm": [10.0, 0.0, 0.0]}, + "make_copy": True, + }) + + def test_identity_copy_transform_creates_a_distinct_body_member(self): + source = TRANSFORM_SOURCE.replace( + '"transformType":TransformType.TRANSLATION_3D, "dx":10 * mm, "dy":0 * mm, "dz":0 * mm, "makeCopy":false', + '"transformType":TransformType.COPY', + ) + result = lower_model(parse_featurescript(source, "identity-copy"), {}) + self.assertEqual(result.status, "converted_complete") + self.assertFalse(result.diagnostics) + self.assertEqual(result.cdsl["features"][-1]["params"], { + "source_feature_ids": ["f_F1"], + "transform": {"type": "translation", "translation_mm": [0.0, 0.0, 0.0]}, + "make_copy": True, + }) + + from engine.cdsl_engine.runtime import rebuild_cdsl + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(result.cdsl, Path(directory) / "identity-copy.step") + self.assertEqual([item["feature_id"] for item in rebuilt["feature_results"]], ["f_F1", "f_F2"]) + self.assertEqual(rebuilt["solid_count"], 2) + self.assertTrue(any(item["operation"] == "translation" for item in rebuilt["topology_deltas"])) + + def test_identity_copy_transform_preserves_direct_source_provenance(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + cases = { + "00548763": {"f_F2": "f_F1"}, + "00853763": {"f_F6": "f_F5"}, + # F3 is the exact single-body successor of F1 after a regular + # additive fusion. COPY must reference that live member rather + # than resurrecting F1's consumed runtime body key. + "00981258": {"f_F4": "f_F3", "f_F11": "f_F10"}, + } + for sample_id, expected_sources in cases.items(): + with self.subTest(sample_id=sample_id): + feature = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt" + if not feature.exists(): + self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), sample_id), {}) + transforms = {item["id"]: item for item in result.cdsl["features"] if item["atomic_id"] == "transform_bodies"} + self.assertFalse(any( + item.get("feature_id") in {feature_id[2:] for feature_id in expected_sources} + and str(item.get("capability") or "").startswith("transform") + for item in result.diagnostics + )) + for feature_id, source_feature_id in expected_sources.items(): + self.assertEqual(transforms[feature_id]["params"], { + "source_feature_ids": [source_feature_id], + "transform": {"type": "translation", "translation_mm": [0.0, 0.0, 0.0]}, + "make_copy": True, + }) + + def test_transform_lowers_one_proven_mirror_copy_member(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + cases = { + "00120430": {"f_F5": {"pattern_feature_id": "f_F4", "source_feature_id": "f_F1", "instance_index": 1}}, + "00749755": {"f_F4": {"pattern_feature_id": "f_F3", "source_feature_id": "f_F1", "instance_index": 1}}, + } + for sample_id, expected in cases.items(): + with self.subTest(sample_id=sample_id): + feature = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt" + if not feature.exists(): + self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), sample_id), {}) + transforms = {item["id"]: item for item in result.cdsl["features"] if item["atomic_id"] == "transform_bodies"} + for feature_id, reference in expected.items(): + self.assertEqual(transforms[feature_id]["params"]["pattern_instance_refs"], [reference]) + self.assertFalse(any( + item.get("feature_id") in {feature_id[2:] for feature_id in expected} + and item.get("capability") == "transform_pattern_copy" + for item in result.diagnostics + )) + + def test_transform_lowers_source_qualified_multi_source_copy_members(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0069/00699847.txt" + if not feature.exists(): + self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(feature.read_text(), "00699847"), {}) + self.assertEqual(result.status, "converted_complete") + self.assertFalse(result.diagnostics) + transforms = {item["id"]: item for item in result.cdsl["features"] if item["atomic_id"] == "transform_bodies"} + self.assertEqual(transforms["f_F3"]["params"]["source_feature_ids"], ["f_F1", "f_F2"]) + self.assertEqual(transforms["f_F4"]["params"]["transform_copy_refs"], [ + {"transform_feature_id": "f_F3", "source_feature_id": "f_F1"}, + {"transform_feature_id": "f_F3", "source_feature_id": "f_F2"}, + ]) + self.assertEqual(transforms["f_F4"]["depends_on"], ["f_F1", "f_F2", "f_F3"]) + + def test_uniform_scale_lowers_to_a_non_rigid_body_transform(self): + source = TRANSFORM_SOURCE.replace( + '"transformType":TransformType.TRANSLATION_3D, "dx":10 * mm, "dy":0 * mm, "dz":0 * mm, "makeCopy":false', + '"transformType":TransformType.SCALE_UNIFORMLY, "scale":0.5, ' + '"scalePoint":qCreatedBy(makeId("Origin.pointOp"), VERTEX), "makeCopy":false', + ) + result = lower_model(parse_featurescript(source, "uniform-scale"), {}) + self.assertEqual(result.status, "converted_complete") + self.assertFalse(result.diagnostics) + transform = result.cdsl["features"][-1] + self.assertEqual(transform["atomic_id"], "transform_bodies") + self.assertEqual(transform["params"], { + "source_feature_ids": ["f_F1"], + "transform": {"type": "uniform_scale", "center_mm": [0.0, 0.0, 0.0], "scale_factor": 0.5}, + "make_copy": False, + }) + + from engine.cdsl_engine.runtime import rebuild_cdsl + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(result.cdsl, Path(directory) / "uniform-scale.step") + self.assertEqual(rebuilt["solid_count"], 1) + self.assertAlmostEqual(rebuilt["volume_mm3"], math.pi * 9.53 ** 2 * 120 / 8) + self.assertEqual(rebuilt["bbox_mm"], {"min": [-4.765, -4.765, 0.0], "max": [4.765, 4.765, 60.0]}) + self.assertTrue(any(item["operation"] == "uniform_scale" for item in rebuilt["topology_deltas"])) + + def test_uniform_scale_resolves_direct_circle_center_and_rejects_invalid_centers(self): + source = TRANSFORM_SOURCE.replace( + '"center":v(0, 0) * mm, "radius":9.53 * mm', + '"center":v(10, 0) * mm, "radius":9.53 * mm', + ).replace( + '"transformType":TransformType.TRANSLATION_3D, "dx":10 * mm, "dy":0 * mm, "dz":0 * mm, "makeCopy":false', + '"transformType":TransformType.SCALE_UNIFORMLY, "scale":0.5, ' + '"scalePoint":sQuery(id + "F0.wireOp", VERTEX, "E0.center"), "makeCopy":false', + ) + result = lower_model(parse_featurescript(source, "uniform-scale-circle-center"), {}) + self.assertEqual(result.status, "converted_complete") + self.assertEqual(result.cdsl["features"][-1]["params"]["transform"], { + "type": "uniform_scale", "center_mm": [10.0, 0.0, 0.0], "scale_factor": 0.5, + }) + + zero_factor = lower_model(parse_featurescript(source.replace('"scale":0.5', '"scale":0'), "uniform-scale-zero"), {}) + self.assertEqual(zero_factor.status, "converted_partial") + self.assertEqual(zero_factor.diagnostics[-1]["capability"], "transform_uniform_scale") + + unresolved_center = lower_model(parse_featurescript(source.replace( + 'sQuery(id + "F0.wireOp", VERTEX, "E0.center")', + 'qCreatedBy(id + "F1", VERTEX)', + ), "uniform-scale-unresolved-center"), {}) + self.assertEqual(unresolved_center.status, "converted_partial") + self.assertEqual(unresolved_center.diagnostics[-1]["capability"], "transform_uniform_scale_center") + + def test_non_copy_uniform_scale_preserves_a_later_source_owner_alias(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0041/00417936.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00417936"), {}) + self.assertEqual(result.status, "converted_complete") + transforms = [item for item in result.cdsl["features"] if item["atomic_id"] == "transform_bodies"] + self.assertEqual([item["params"]["source_feature_ids"] for item in transforms], [["f_F1"], ["f_F2"]]) + self.assertEqual([item["params"]["transform"]["scale_factor"] for item in transforms], [10.0, 1.5]) + + from engine.cdsl_engine.runtime import rebuild_cdsl + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(result.cdsl, Path(directory) / "sequential-uniform-scale.step") + self.assertEqual(rebuilt["solid_count"], 1) + self.assertEqual([item["feature_id"] for item in rebuilt["feature_results"]], ["f_F1", "f_F2", "f_F3"]) + self.assertEqual( + [item["operation"] for item in rebuilt["topology_deltas"]], + ["extrude", "uniform_scale", "uniform_scale"], + ) + + def test_single_body_successor_aliases_clear_at_body_graph_boundaries(self): + for boundary in ( + {"id": "f_copy", "atomic_id": "transform_bodies", "params": {"make_copy": True}}, + {"id": "f_boolean", "atomic_id": "boolean_bodies", "params": {}}, + {"id": "f_pattern", "atomic_id": "pattern_circular", "params": {}}, + {"id": "f_delete", "atomic_id": "delete_bodies", "params": {}}, + ): + with self.subTest(boundary=boundary["atomic_id"]): + aliases = {} + state = {"owner": None, "sources": set()} + _record_single_body_successor(aliases, state, { + "id": "f_base", "atomic_id": "extrude_add_blind", + "params": {"result_mode": "new_body"}, + }) + _record_single_body_successor(aliases, state, { + "id": "f_add", "atomic_id": "extrude_add_blind", "params": {}, + }) + self.assertEqual(aliases, {"f_base": "f_add"}) + + _record_single_body_successor(aliases, state, boundary) + self.assertEqual(aliases, {}) + self.assertIsNone(state["owner"]) + self.assertEqual(state["sources"], set()) + + def test_shell_parts_lowers_to_a_live_member_or_proven_sole_body_alias(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + + successor = root / "featurescript_rp/0010/00107631.txt" + result = lower_model(parse_featurescript(successor.read_text(), "00107631"), {}) + shell = next(feature for feature in result.cdsl["features"] if feature["id"] == "f_F3") + self.assertEqual(shell["params"]["target_feature_id"], "f_F2") + self.assertFalse(any( + diagnostic.get("capability") == "shell_parts_body_source" + for diagnostic in result.diagnostics + )) + + patterned = root / "featurescript_rp/0054/00542223.txt" + result = lower_model(parse_featurescript(patterned.read_text(), "00542223"), {}) + shell = next(feature for feature in result.cdsl["features"] if feature["id"] == "f_F7") + self.assertEqual(shell["params"]["target_feature_id"], "f_F5") + self.assertFalse(any( + diagnostic.get("capability") == "shell_parts_body_source" + for diagnostic in result.diagnostics + )) + + def test_translation_distance_lowers_from_a_linear_sketch_direction(self): + source = TRANSFORM_SOURCE.replace( + 'skCircle(sketch, "E0", {"center":v(0, 0) * mm, "radius":9.53 * mm}); skSolve(sketch);', + 'skCircle(sketch, "E0", {"center":v(0, 0) * mm, "radius":9.53 * mm}); ' + 'skLineSegment(sketch, "E1", {"start":v(0, 0) * mm, "end":v(1, 0) * mm, "construction":true}); skSolve(sketch);', + ).replace( + '"transformType":TransformType.TRANSLATION_3D, "dx":10 * mm, "dy":0 * mm, "dz":0 * mm, "makeCopy":false', + '"transformType":TransformType.TRANSLATION_DISTANCE, "transformDirection":sQuery(id + "F0.wireOp", EDGE, "E1"), ' + '"distance":10 * mm, "oppositeDirection":true, "makeCopy":false', + ) + moved = lower_model(parse_featurescript(source, "distance-move"), {}) + self.assertEqual(moved.status, "converted_complete") + self.assertFalse(moved.diagnostics) + self.assertEqual(moved.cdsl["geometry"]["sketches"][0]["workplane"]["origin_mm"], [-10.0, 0.0, 0.0]) + + datum_plane = TRANSFORM_SOURCE.replace( + '"transformType":TransformType.TRANSLATION_3D, "dx":10 * mm, "dy":0 * mm, "dz":0 * mm, "makeCopy":false', + '"transformType":TransformType.TRANSLATION_DISTANCE, ' + '"transformDirection":qCreatedBy(makeId("Front.planeOp"), FACE), ' + '"distance":10 * mm, "makeCopy":true', + ) + datum_result = lower_model(parse_featurescript(datum_plane, "distance-datum-plane"), {}) + self.assertEqual(datum_result.status, "converted_complete") + self.assertEqual(datum_result.cdsl["features"][-1]["params"], { + "source_feature_ids": ["f_F1"], + "transform": {"type": "translation", "translation_mm": [0.0, -10.0, 0.0]}, + "make_copy": True, + }) + + square = ( + 'skLineSegment(sketch, "E0", {"start":v(0, 0) * mm, "end":v(10, 0) * mm}); ' + 'skLineSegment(sketch, "E1", {"start":v(10, 0) * mm, "end":v(10, 10) * mm}); ' + 'skLineSegment(sketch, "E2", {"start":v(10, 10) * mm, "end":v(0, 10) * mm}); ' + 'skLineSegment(sketch, "E3", {"start":v(0, 10) * mm, "end":v(0, 0) * mm}); skSolve(sketch);' + ) + swept_face = TRANSFORM_SOURCE.replace( + 'skCircle(sketch, "E0", {"center":v(0, 0) * mm, "radius":9.53 * mm}); skSolve(sketch);', square, + ).replace( + '"transformType":TransformType.TRANSLATION_3D, "dx":10 * mm, "dy":0 * mm, "dz":0 * mm, "makeCopy":false', + '"transformType":TransformType.TRANSLATION_DISTANCE, ' + '"transformDirection":makeQuery(id + "F1.opExtrude", "SWEPT_FACE", FACE, ' + '{"derivedFrom":sQuery(id + "F0.wireOp", EDGE, "E0")}), ' + '"distance":10 * mm, "makeCopy":true', + ) + swept_result = lower_model(parse_featurescript(swept_face, "distance-swept-face"), {}) + self.assertEqual(swept_result.status, "converted_complete") + self.assertEqual(swept_result.cdsl["features"][-1]["params"], { + "source_feature_ids": ["f_F1"], + "transform": {"type": "translation", "translation_mm": [0.0, -10.0, 0.0]}, + "make_copy": True, + }) + + copied = lower_model(parse_featurescript(source.replace('"makeCopy":false', '"makeCopy":true'), "distance-copy"), {}) + self.assertEqual(copied.status, "converted_complete") + self.assertFalse(copied.diagnostics) + self.assertEqual(copied.cdsl["features"][-1]["params"], { + "source_feature_ids": ["f_F1"], + "transform": {"type": "translation", "translation_mm": [-10.0, 0.0, 0.0]}, + "make_copy": True, + }) + + from engine.cdsl_engine.runtime import rebuild_cdsl + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(copied.cdsl, Path(directory) / "distance-copy.step") + self.assertEqual(rebuilt["solid_count"], 2) + self.assertEqual(rebuilt["bbox_mm"]["min"], [-19.53, -9.53, 0.0]) + self.assertEqual(rebuilt["bbox_mm"]["max"], [9.53, 9.53, 120.0]) + + def test_translation_distance_cap_edge_uses_its_physical_frame(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0066/00665176.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00665176"), {}) + transforms = {item["id"]: item for item in result.cdsl["features"] if item["atomic_id"] == "transform_bodies"} + self.assertEqual(transforms["f_F2"]["params"]["transform"], {"type": "translation", "translation_mm": [0.0, 0.0, 381.0]}) + self.assertEqual(transforms["f_F3"]["params"]["transform"], {"type": "translation", "translation_mm": [127.0, 0.0, 0.0]}) + self.assertEqual(transforms["f_F4"]["params"]["transform"], {"type": "translation", "translation_mm": [-127.0, 0.0, 0.0]}) + self.assertNotIn("F2", [item.get("feature_id") for item in result.diagnostics]) + self.assertNotIn("F3", [item.get("feature_id") for item in result.diagnostics]) + self.assertNotIn("F4", [item.get("feature_id") for item in result.diagnostics]) + + def test_translation_distance_cap_face_uses_its_physical_normal(self): + source = TRANSFORM_SOURCE.replace( + '"transformType":TransformType.TRANSLATION_3D, "dx":10 * mm, "dy":0 * mm, "dz":0 * mm, "makeCopy":false', + '"transformType":TransformType.TRANSLATION_DISTANCE, ' + '"transformDirection":makeQuery(id + "F1.opExtrude", "CAP_FACE", FACE, ' + '{"derivedFrom":sQuery(id + "F0.wireOp", EDGE, "E0"), "isStart":false}), ' + '"distance":10 * mm, "makeCopy":true', + ) + result = lower_model(parse_featurescript(source, "distance-cap-face"), {}) + self.assertEqual(result.status, "converted_complete") + self.assertFalse(result.diagnostics) + self.assertEqual(result.cdsl["features"][-1]["params"], { + "source_feature_ids": ["f_F1"], + "transform": {"type": "translation", "translation_mm": [0.0, 0.0, 10.0]}, + "make_copy": True, + }) + + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0007/00078650.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + real = lower_model(parse_featurescript(feature.read_text(), "00078650"), {}) + self.assertEqual(real.status, "converted_complete") + transform = next(item for item in real.cdsl["features"] if item["id"] == "f_F4") + self.assertEqual(transform["params"], { + "source_feature_ids": ["f_F1", "f_F2", "f_F3"], + "transform": {"type": "translation", "translation_mm": [0.0, -431.8, 0.0]}, + "make_copy": True, + }) + + def test_translation_distance_rejects_a_curved_direction(self): + source = TRANSFORM_SOURCE.replace( + '"transformType":TransformType.TRANSLATION_3D, "dx":10 * mm, "dy":0 * mm, "dz":0 * mm, "makeCopy":false', + '"transformType":TransformType.TRANSLATION_DISTANCE, "transformDirection":sQuery(id + "F0.wireOp", EDGE, "E0"), ' + '"distance":10 * mm, "makeCopy":false', + ) + result = lower_model(parse_featurescript(source, "distance-curve"), {}) self.assertEqual(result.status, "converted_partial") - self.assertEqual(result.diagnostics[-1]["code"], "unsupported_engine_capability") - self.assertEqual(result.diagnostics[-1]["capability"], "transform") + self.assertEqual(result.diagnostics[-1]["capability"], "transform_translation_direction") + + cap_circle = lower_model(parse_featurescript(source.replace( + 'sQuery(id + "F0.wireOp", EDGE, "E0")', + 'makeQuery(id + "F1.opExtrude", "CAP_EDGE", EDGE, {"derivedFrom":sQuery(id + "F0.wireOp", EDGE, "E0"), "isStart":false})', + ), "distance-cap-circle"), {}) + self.assertEqual(cap_circle.status, "converted_partial") + self.assertEqual(cap_circle.diagnostics[-1]["capability"], "transform_translation_direction") + + def test_translation_entity_lowers_direct_line_and_vertex_vectors(self): + sketch = ( + 'skCircle(sketch, "E0", {"center":v(0, 0) * mm, "radius":9.53 * mm}); ' + 'skLineSegment(sketch, "E1", {"start":v(0, 0) * mm, "end":v(2, 0) * mm, "construction":true}); ' + 'skPoint(sketch, "E2", {"position":v(0, 0) * mm}); ' + 'skPoint(sketch, "E3", {"position":v(0, 5) * mm}); skSolve(sketch);' + ) + line_source = TRANSFORM_SOURCE.replace( + 'skCircle(sketch, "E0", {"center":v(0, 0) * mm, "radius":9.53 * mm}); skSolve(sketch);', sketch, + ).replace( + '"transformType":TransformType.TRANSLATION_3D, "dx":10 * mm, "dy":0 * mm, "dz":0 * mm, "makeCopy":false', + '"transformType":TransformType.TRANSLATION_ENTITY, "transformLine":sQuery(id + "F0.wireOp", EDGE, "E1"), ' + '"oppositeDirectionEntity":false, "makeCopy":true', + ) + copied = lower_model(parse_featurescript(line_source, "entity-line-copy"), {}) + self.assertEqual(copied.status, "converted_complete") + self.assertEqual(copied.cdsl["features"][-1]["params"], { + "source_feature_ids": ["f_F1"], + "transform": {"type": "translation", "translation_mm": [2.0, 0.0, 0.0]}, + "make_copy": True, + }) + + vertex_source = line_source.replace( + '"transformLine":sQuery(id + "F0.wireOp", EDGE, "E1"), "oppositeDirectionEntity":false, "makeCopy":true', + '"transformLine":qUnion([sQuery(id + "F0.wireOp", VERTEX, "E2"), sQuery(id + "F0.wireOp", VERTEX, "E3")]), ' + '"oppositeDirectionEntity":false, "makeCopy":false', + ) + moved = lower_model(parse_featurescript(vertex_source, "entity-vertices-move"), {}) + self.assertEqual(moved.status, "converted_complete") + self.assertEqual(moved.cdsl["geometry"]["sketches"][0]["workplane"]["origin_mm"], [0.0, 5.0, 0.0]) + + curved = lower_model(parse_featurescript(line_source.replace('EDGE, "E1"', 'EDGE, "E0"'), "entity-curve"), {}) + self.assertEqual(curved.status, "converted_partial") + self.assertEqual(curved.diagnostics[-1]["capability"], "transform_translation_entity") + + cap_circle = lower_model(parse_featurescript(line_source.replace( + 'sQuery(id + "F0.wireOp", EDGE, "E1")', + 'makeQuery(id + "F1.opExtrude", "CAP_EDGE", EDGE, {"derivedFrom":sQuery(id + "F0.wireOp", EDGE, "E0"), "isStart":false})', + ), "entity-cap-circle"), {}) + self.assertEqual(cap_circle.status, "converted_partial") + self.assertEqual(cap_circle.diagnostics[-1]["capability"], "transform_translation_entity") + + def test_translation_entity_real_line_cap_and_body_lifecycle_boundaries(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + direct = root / "featurescript_rp/0012/00122249.txt" + cap_edge = root / "featurescript_rp/0018/00184423.txt" + absorbed = root / "featurescript_rp/0022/00226751.txt" + if not all(path.exists() for path in (direct, cap_edge, absorbed)): + self.skipTest("CADFS samples are not installed") + + direct_result = lower_model(parse_featurescript(direct.read_text(), "00122249"), {}) + self.assertEqual(direct_result.status, "converted_complete") + moved_sketch = next(item for item in direct_result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F1__f_F2") + self.assertEqual(moved_sketch["workplane"]["origin_mm"], [-7.0, 0.0, 0.0]) + + cap_result = lower_model(parse_featurescript(cap_edge.read_text(), "00184423"), {}) + transform = next(item for item in cap_result.cdsl["features"] if item["id"] == "f_F2") + self.assertEqual(transform["params"]["transform"], {"type": "translation", "translation_mm": [12.7, 0.0, 0.0]}) + self.assertEqual(cap_result.status, "converted_complete") + self.assertFalse(cap_result.diagnostics) + transforms = {item["id"]: item for item in cap_result.cdsl["features"] if item["atomic_id"] == "transform_bodies"} + self.assertEqual( + {feature_id: item["params"]["source_feature_ids"] for feature_id, item in transforms.items()}, + {"f_F2": ["f_F1"], "f_F3": ["f_F2"], "f_F4": ["f_F3"], "f_F5": ["f_F4"], "f_F6": ["f_F4"]}, + ) + self.assertEqual(transforms["f_F5"]["params"]["transform"]["type"], "translation") + for actual, expected in zip(transforms["f_F5"]["params"]["transform"]["translation_mm"], [0.0, -12.7, 0.0]): + self.assertAlmostEqual(actual, expected) + from engine.cdsl_engine.runtime import rebuild_cdsl + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(cap_result.cdsl, Path(directory) / "transform-copy-chain.step") + self.assertEqual(rebuilt["solid_count"], 6) + self.assertEqual([item["feature_id"] for item in rebuilt["feature_results"]], [ + "f_F1", "f_F2", "f_F3", "f_F4", "f_F5", "f_F6", + ]) + self.assertFalse(rebuilt["runtime_diagnostics"]) + + absorbed_result = lower_model(parse_featurescript(absorbed.read_text(), "00226751"), {}) + self.assertEqual(absorbed_result.diagnostics[-1]["capability"], "transform_body_lifecycle") + + def test_direct_swept_body_delete_lowers_to_a_body_graph_operation(self): + source = SOURCE.replace( + '\n});\n', + ''' + { var Q0; Q0=makeQuery(id + "F1.opExtrude", "SWEPT_BODY", BODY, {}); + deleteBodies(context, id + "F2", {"entities":qUnion([Q0])}); } +}); +''', + ) + result = lower_model(parse_featurescript(source, "delete-body"), {}) + self.assertEqual(result.status, "converted_complete") + delete = result.cdsl["features"][-1] + self.assertEqual(delete["atomic_id"], "delete_bodies") + self.assertEqual(delete["params"], {"target_feature_ids": ["f_F1"]}) def test_symmetric_cut_lowers_to_two_sided_cut(self): source = SOURCE.replace('"depth":120 * mm', '"operationType":NewBodyOperationType.REMOVE, "depth":120 * mm, "symmetric":true') @@ -227,14 +782,77 @@ class LoweringTests(unittest.TestCase): 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): + def test_offset_face_profile_is_the_proven_linear_extrusion_wall(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_F15"]["sketch_id"], "sketch_F5__f_F15") + sketch = sketches["sketch_F5__f_F15"] + self.assertEqual(sketch["workplane"]["origin_mm"], [-0.0, 52.19, -0.0]) + self.assertEqual(sketch["workplane"]["normal"], [0.0, -1.0, 0.0]) + points = { + tuple(round(component, 6) for component in endpoint) + for segment in sketch["profile"]["contours"][0]["segments"] + for endpoint in (segment["start"], segment["end"]) + } + self.assertEqual(points, {(-45.0, 2.5), (45.0, 2.5), (45.0, 6.0), (-45.0, 6.0)}) + + def test_offset_face_profile_defers_when_query_has_multiple_source_edges(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") + source = feature.read_text(encoding="utf-8").replace( + 'Q0=makeQuery(id+"F5.opShell","OFFSET_FACE",FACE,{"disambiguationData":[OSD([sQuery(id+"F3.wireOp",EDGE,"E1.bottom")])]});', + 'Q0=makeQuery(id+"F5.opShell","OFFSET_FACE",FACE,{"disambiguationData":[OSD([sQuery(id+"F3.wireOp",EDGE,"E1.bottom"),sQuery(id+"F3.wireOp",EDGE,"E1.top")])]});', + 1, + ) + result = lower_model(parse_featurescript(source, "00789939-offset-face-multi-source"), {}) + + self.assertNotIn("f_F15", {item["id"] for item in result.cdsl["features"]}) + diagnostic = next(item for item in result.diagnostics if item.get("feature_id") == "F15") + self.assertEqual(diagnostic["capability"], "extrude_profile_topology:offset_face") + + def test_offset_face_profile_defers_for_non_direct_shell_provenance(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0058/00588094.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00588094"), {}) + + self.assertNotIn("f_F14", {item["id"] for item in result.cdsl["features"]}) + diagnostic = next(item for item in result.diagnostics if item.get("feature_id") == "F14") + self.assertEqual(diagnostic["capability"], "extrude_profile_topology:offset_face") + + def test_circular_pattern_uses_the_proven_fused_body_successor(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"]) + self.assertEqual(features["f_F6"]["params"]["source_feature_ids"], ["f_F5"]) + + def test_circular_copy_boolean_uses_the_proven_fused_body_successor(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0025/00253824.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00253824"), {}) + + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(result.status, "converted_complete") + self.assertEqual(features["f_F5"]["params"]["source_feature_ids"], ["f_F4"]) + self.assertEqual(features["f_F6"]["params"], { + "operation": "union", + "keep_tools": False, + "target_feature_ids": ["f_F4"], + "tool_pattern_instance_refs": [ + {"pattern_feature_id": "f_F5", "source_feature_id": "f_F4", "instance_index": 2}, + {"pattern_feature_id": "f_F5", "source_feature_id": "f_F4", "instance_index": 1}, + ], + }) def test_shared_sketch_edge_produces_each_bounded_region(self): segments = [ @@ -412,22 +1030,138 @@ class LoweringTests(unittest.TestCase): 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): + def test_drafted_cap_face_profile_executes_from_a_builder_proven_boolean_successor(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(result.status, "converted_complete") + cap_extrude = features["f_F5"] + self.assertEqual(cap_extrude["atomic_id"], "extrude_from_face") + self.assertNotIn("sketch_id", cap_extrude) + self.assertEqual(cap_extrude["selectors"], [{ + "kind": "face", "owner_feature_id": "f_F3", "output_role": "extrude.start", + "source": "runtime_snapshot", "confidence": 1.0, + }]) - 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]) + from engine.cdsl_engine.runtime import rebuild_cdsl + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(result.cdsl, Path(directory) / "drafted-cap-face.step") + resolution = next(item for item in rebuilt["selector_resolution"] if item["feature_id"] == "f_F5") + self.assertEqual(resolution["status"], "resolved") + self.assertEqual(resolution["selected"]["owner_feature_ids"], ["f_F3"]) + self.assertEqual(resolution["selected"]["output_roles"], ["extrude.start"]) + self.assertIn("f_F5", [item["feature_id"] for item in rebuilt["feature_results"]]) + + def test_drafted_cap_face_with_an_inner_loop_remains_an_explicit_capability_gap(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0026/00268467.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00268467"), {}) + + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertNotIn("f_F2", features) + diagnostics = [item for item in result.diagnostics if item.get("feature_id") == "F2"] + self.assertEqual(len(diagnostics), 1) + self.assertEqual(diagnostics[0]["capability"], "extrude_profile_topology:cap_face") + + def test_nested_cap_edge_union_preserves_outer_profile_and_runtime_prefix(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0095/00950564.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00950564"), {}) + + self.assertEqual(result.status, "converted_complete") + self.assertFalse(result.diagnostics) + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F3"]["sketch_id"], "sketch_F2") + self.assertEqual(features["f_F5"]["sketch_id"], "sketch_F4") + # F12's direct SWEPT_BODY(F1) names the one physical body after its + # additive and dress-up successors. The lowering must retain that + # proven successor, never substitute an implicit active aggregate. + self.assertEqual(features["f_F12"]["params"]["source_feature_ids"], ["f_F11"]) + self.assertIn("f_F11", features["f_F12"]["depends_on"]) + from engine.cdsl_engine.runtime import analyze_cdsl + f12_analysis = next(item for item in analyze_cdsl(result.cdsl).as_dict()["feature_results"] if item["feature_id"] == "f_F12") + self.assertEqual(f12_analysis["resolved_status"], "executable") + self.assertFalse(any(item["code"] == "body_source_unavailable" for item in f12_analysis["blockers"])) + + # Later chamfers have a separate selector gap. The retained prefix + # proves the two CAP_EDGE profile unions themselves execute through + # their dependent extrusions. + prefix = deepcopy(result.cdsl) + prefix["features"] = [item for item in prefix["features"] if item["id"] not in {"f_F10", "f_F11", "f_F12"}] + from engine.cdsl_engine.runtime import rebuild_cdsl + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(prefix, Path(directory) / "cap-edge-union-prefix.step") + self.assertFalse(rebuilt["runtime_diagnostics"]) + self.assertEqual( + [item["feature_id"] for item in rebuilt["feature_results"]], + ["f_F1", "f_F3", "f_F5", "f_F7", "f_F9"], + ) + + def test_two_sided_blind_extrude_records_physical_cap_workplanes(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0024/00247322.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00247322"), {}) + + # The CAP_FACE(F4, isStart:true) workplane is the blind cap opposite + # F4's primary direction, not the source sketch plane. F5 preserves + # that cap's exact F4 ownership, so its CAP_EDGE can form F7's hole. + self.assertEqual(result.status, "converted_complete") + self.assertFalse(result.diagnostics) + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + self.assertEqual(sketches["sketch_F3"]["workplane"]["origin_mm"], [0.0, 0.0, 40.0]) + self.assertEqual(sketches["sketch_F6"]["workplane"]["origin_mm"], [0.0, 0.0, 40.3]) + self.assertEqual(sketches["sketch_F6"]["workplane"]["normal"], [0.0, 0.0, 1.0]) + self.assertEqual(sketches["sketch_F8"]["workplane"]["origin_mm"], [0.0, 0.0, 41.8]) + self.assertEqual(sketches["sketch_F10"]["workplane"]["origin_mm"], [0.0, 0.0, 41.8]) + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F7"]["selectors"][0]["geometry"], { + "normal": [0.0, 0.0, 1.0], "plane_offset_mm": 40.3, + }) + + # F9 rotates F4 only after F5/F7 have consumed it. It cannot be baked + # into the earlier F4 sketch; the unsupported body continuation stays + # explicit and F1--F7 remains an executable prefix. + prefix = deepcopy(result.cdsl) + prefix["features"] = [ + item for item in prefix["features"] + if item["id"] in {"f_F1", "f_F2", "f_F4", "f_F5", "f_F7"} + ] + from engine.cdsl_engine.runtime import rebuild_cdsl + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(prefix, Path(directory) / "two-sided-cap-prefix.step") + self.assertFalse(rebuilt["runtime_diagnostics"]) + self.assertEqual( + [item["feature_id"] for item in rebuilt["feature_results"]], + ["f_F1", "f_F2", "f_F4", "f_F5", "f_F7"], + ) + + from engine.cdsl_engine.runtime import analyze_cdsl + f9 = next(item for item in analyze_cdsl(result.cdsl).as_dict()["feature_results"] if item["feature_id"] == "f_F9") + self.assertIn("body_source_unavailable", [item["code"] for item in f9["blockers"]]) + + def test_cap_face_profile_executes_from_a_builder_proven_dressup_successor(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0001/00016195.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00016195"), {}) + 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"], "extrude_from_face") + self.assertNotIn("sketch_id", feature) + + from engine.cdsl_engine.runtime import rebuild_cdsl + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(result.cdsl, Path(directory) / "cap-face.step") + resolution = next(item for item in rebuilt["selector_resolution"] if item["feature_id"] == "f_F3") + self.assertEqual(resolution["status"], "resolved") + self.assertEqual(resolution["selected"]["owner_feature_ids"], ["f_F1"]) + self.assertEqual(resolution["selected"]["output_roles"], ["extrude.end"]) def test_transformed_cap_face_uses_its_attachment_plane_origin(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -501,23 +1235,103 @@ class LoweringTests(unittest.TestCase): 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): + def test_full_revolve_planar_swept_face_lowers_as_a_mirror_plane(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0000/00000385.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + source = feature.read_text(encoding="utf-8") + result = lower_model(parse_featurescript(source, "00000385"), {}) + features = {item["id"]: item for item in result.cdsl["features"]} + + self.assertEqual(features["f_F5"]["atomic_id"], "pattern_mirror") + mirror_plane = features["f_F5_plane"]["params"]["plane"] + self.assertEqual(mirror_plane["origin_mm"], [0.0, 0.0, 0.0]) + self.assertEqual(mirror_plane["x_dir"], [1.0, 0.0, 0.0]) + self.assertEqual(mirror_plane["normal"], [0.0, 0.0, 1.0]) + self.assertFalse([item for item in result.diagnostics if item.get("feature_id") == "F5"]) + boolean = features["f_F6"] + self.assertEqual(boolean["atomic_id"], "boolean_bodies") + self.assertEqual(boolean["params"], { + "operation": "union", + "target_feature_ids": ["f_F1"], + "keep_tools": False, + "tool_pattern_instance_refs": [{ + "pattern_feature_id": "f_F5", + "source_feature_id": "f_F1", + "instance_index": 1, + }], + }) + self.assertFalse([item for item in result.diagnostics if item.get("feature_id") == "F6"]) + + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "rebuild.step") + self.assertEqual(rebuilt["status"], "rebuilt") + mirror = next(item for item in rebuilt["result"]["feature_results"] if item["feature_id"] == "f_F5") + self.assertEqual(mirror["status"], "executed") + boolean_result = next(item for item in rebuilt["result"]["feature_results"] if item["feature_id"] == "f_F6") + self.assertEqual(boolean_result["status"], "executed") + + partial = source.replace("RevolveType.FULL", "RevolveType.PARTIAL", 1) + rejected = lower_model(parse_featurescript(partial, "partial-revolve-mirror-plane"), {}) + self.assertNotIn("f_F5", {item["id"] for item in rejected.cdsl["features"]}) + self.assertEqual( + [item for item in rejected.diagnostics if item.get("feature_id") == "F5"], + [{ + "code": "feature_deferred", + "feature_id": "F5", + "operation": "mirror", + "message": "mirror plane is not a default or reference plane", + }], + ) + + def test_targetless_union_partitions_direct_body_queries_in_source_order(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0029/00293508.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), "00293508"), {}) + self.assertEqual(result.status, "converted_complete") + boolean = next(item for item in result.cdsl["features"] if item["id"] == "f_F4") + self.assertEqual(boolean["atomic_id"], "boolean_bodies") + self.assertEqual(boolean["params"], { + "operation": "union", + "target_feature_ids": ["f_F1"], + "tool_feature_ids": ["f_F3"], + "keep_tools": False, + }) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "rebuild.step") + self.assertEqual(rebuilt["status"], "rebuilt") + execution = next(item for item in rebuilt["result"]["feature_results"] if item["feature_id"] == "f_F4") + self.assertEqual(execution["status"], "executed") + + def test_intersect_partition_profile_retains_exact_planar_imprint_evidence(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"}) + self.assertEqual(result.status, "converted_complete") + self.assertFalse([item for item in result.diagnostics if item.get("feature_id") == "F5"]) features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F5"]["atomic_id"], "extrude_from_face") 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(first["type"], "planar_imprint") + self.assertEqual(second["type"], "planar_imprint") self.assertEqual( - {contour["role"]: contour["segments"][0]["radius_mm"] for contour in first["contours"]}, - {"outer": 2.75, "inner": 2.25}, + first["selections"], + [ + {"source_entity_id": "E11", "face_side": 1.0, + "fragment": {"anchor_entity_id": "E15", "side": -1.0, "intersection_index": 0}}, + {"source_entity_id": "E11", "face_side": -1.0, + "fragment": {"anchor_entity_id": "E15", "side": -1.0, "intersection_index": 0}}, + ], + ) + self.assertEqual({item["id"] for item in first["source_entities"]}, {"E11", "E13", "E14", "E15"}) + self.assertEqual( + [selection["fragment"]["anchor_entity_id"] for selection in second["selections"]], + ["E14", "E14"], ) - 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" @@ -602,6 +1416,65 @@ class LoweringTests(unittest.TestCase): 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_static_integral_round_pattern_count_is_lowered_without_guessing_rounding_mode(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + for sample_id, feature_id, count in ( + ("00003011", "f_F5", 8), + ("00039410", "f_F6", 9), + ): + with self.subTest(sample_id=sample_id): + feature = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt" + result = lower_model(parse_featurescript(feature.read_text(), sample_id), {}) + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features[feature_id]["atomic_id"], "pattern_circular") + self.assertEqual(features[feature_id]["params"]["pattern_count"], count) + self.assertNotIn(feature_id.removeprefix("f_"), { + item.get("feature_id") for item in result.diagnostics + }) + + integral_round = Call("round", [8.0]) + fractional_round = Call("round", [8.5]) + self.assertEqual(_number(integral_round), 8.0) + with self.assertRaisesRegex(ValueError, "not a constant number"): + _number(fractional_round) + + def test_two_point_fit_spline_requires_endpoint_derivatives_and_lowers_as_bspline(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0003/00039410.txt" + source = source_path.read_text(encoding="utf-8") + result = lower_model(parse_featurescript(source, "00039410"), {}) + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + profile = sketches["sketch_F4"]["profile"] + splines = [ + segment + for contour in profile["contours"] + for segment in contour["segments"] + if segment["type"] == "bspline" + ] + self.assertEqual(len(splines), 2) + self.assertTrue(all(len(segment["points"]) == 2 for segment in splines)) + self.assertTrue(all("start_tangent" in segment and "end_tangent" in segment for segment in splines)) + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F6"]["params"]["pattern_count"], 9) + self.assertNotIn("F4", {item.get("feature_id") for item in result.diagnostics}) + self.assertNotIn("F6", {item.get("feature_id") for item in result.diagnostics}) + + missing_tangent = source.replace(', "endDerivative": vector(4.02, 9.38) * mm', "", 1) + rejected = lower_model(parse_featurescript(missing_tangent, "two-point-spline-without-tangent"), {}) + self.assertIn({ + "code": "sketch_deferred", + "feature_id": "F4", + "message": "two-point fit spline requires both endpoint derivatives", + }, rejected.diagnostics) + + coincident = source.replace('v(0, 30) * mm', 'v(-1, 19.97) * mm', 1) + rejected = lower_model(parse_featurescript(coincident, "two-point-spline-with-coincident-endpoints"), {}) + self.assertIn({ + "code": "sketch_deferred", + "feature_id": "F4", + "message": "two-point fit spline endpoints must be distinct", + }, rejected.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" @@ -634,6 +1507,28 @@ class LoweringTests(unittest.TestCase): diagnostics = [item for item in result.diagnostics if item.get("operation") == "cPlane"] self.assertFalse(diagnostics) + def test_offset_reference_plane_honors_opposite_direction_without_reversing_its_frame(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0077/00777619.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + source = feature.read_text() + + opposite = lower_model(parse_featurescript(source, "00777619"), {}) + opposite_plane = next(item for item in opposite.cdsl["features"] if item["id"] == "f_F2")["params"]["plane"] + self.assertEqual(opposite_plane, { + "origin_mm": [-76.2, 0.0, 0.0], + "x_dir": [0.0, 1.0, 0.0], + "normal": [1.0, 0.0, 0.0], + }) + + default = lower_model(parse_featurescript(source.replace('"oppositeDirection" : true, ', ""), "00777619-default"), {}) + default_plane = next(item for item in default.cdsl["features"] if item["id"] == "f_F2")["params"]["plane"] + self.assertEqual(default_plane, { + "origin_mm": [76.2, 0.0, 0.0], + "x_dir": [0.0, 1.0, 0.0], + "normal": [1.0, 0.0, 0.0], + }) + 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" @@ -648,7 +1543,14 @@ class LoweringTests(unittest.TestCase): 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["selectors"][1]["geometry"], { + "normal": [0.0, 0.0, 1.0], "plane_offset_mm": -50.0, + "center_mm": [0.0, -40.0, -50.0], "minimum_area_mm2": 481.0563750809371, + }) + expected_centers = [[34.64101615137755, 20.0, -50.0], [0.0, -40.0, -50.0], [-34.64101615137754, 20.0, -50.0]] + for center, expected in zip([selector["geometry"]["center_mm"] for selector in shell["selectors"][:3]], expected_centers): + for value, target in zip(center, expected): self.assertAlmostEqual(value, target) + self.assertEqual(shell["selectors"][3]["geometry"]["center_mm"], [0.0, 0.0, 50.0]) self.assertEqual(shell["atomic_id"], "shell") self.assertEqual(len(shell["selectors"]), 4) self.assertEqual( @@ -657,9 +1559,103 @@ class LoweringTests(unittest.TestCase): ) 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.assertEqual(shell["params"]["target_feature_id"], "f_F5") + self.assertFalse(any( + diagnostic.get("capability") == "shell_parts_body_source" + for diagnostic in result.diagnostics + )) self.assertNotIn("F8", {item.get("feature_id") for item in result.diagnostics}) + def test_shell_lowers_and_executes_direct_linear_extrude_swept_faces(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0059/00594348.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(feature.read_text(), "00594348"), {}) + self.assertEqual(result.status, "converted_complete") + shell_index = next(index for index, item in enumerate(result.cdsl["features"]) if item["id"] == "f_F2") + shell = result.cdsl["features"][shell_index] + self.assertEqual(shell["atomic_id"], "shell") + self.assertEqual([item["owner_feature_id"] for item in shell["selectors"]], ["f_F1", "f_F1"]) + self.assertTrue(all("normal" in item["geometry"] for item in shell["selectors"])) + + prefix = deepcopy(result.cdsl) + prefix["features"] = prefix["features"][:shell_index + 1] + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(prefix, Path(directory) / "swept-face-shell.step") + self.assertEqual(outcome["status"], "rebuilt") + self.assertEqual( + [item["status"] for item in outcome["result"]["feature_results"]], + ["executed", "executed"], + ) + self.assertFalse(outcome["result"].get("diagnostics", [])) + + def test_shell_offset_face_requires_true_dependency_cap_evidence(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0010/00107631.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + # Keep the same nested CAP_FACE outside a true-dependency relation. + # It is source-profile evidence only and must not select an OFFSET_FACE. + source = feature.read_text(encoding="utf-8").replace( + 'TDD([makeQuery(id+"F1.opExtrude","CAP_FACE",FACE,{"disambiguationData":[OSD([subQ0])],"isStart":true})])', + 'OSD([subQ0])', + ) + result = lower_model(parse_featurescript(source, "00107631-without-tdd"), {}) + + diagnostic = next(item for item in result.diagnostics if item.get("feature_id") == "F3") + self.assertEqual(diagnostic["capability"], "shell_offset_face_selector") + self.assertIn("true dependency", diagnostic["message"]) + + def test_shell_runtime_failure_exports_its_bound_executable_prefix(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0024/00248377.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(feature.read_text(), "00248377"), {}) + self.assertEqual(result.status, "converted_complete") + with tempfile.TemporaryDirectory() as directory: + rebuilt = Path(directory) / "swept-face-shell-prefix.step" + outcome = rebuild_candidate(result.cdsl, rebuilt) + self.assertTrue(rebuilt.exists()) + self.assertEqual(outcome["status"], "rebuild_failed") + self.assertEqual(outcome["error"]["type"], "RuntimeExecutionError") + self.assertIn("bound_cdsl", outcome) + self.assertEqual(outcome["last_executable_prefix"]["failed_feature_id"], "f_F5") + self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F4") + self.assertEqual(outcome["last_executable_prefix"]["result"]["solid_count"], 1) + + def test_pattern_copy_selector_binding_rebuilds_the_full_history(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"), {}) + with tempfile.TemporaryDirectory() as directory: + rebuilt = Path(directory) / "selector-prefix.step" + outcome = rebuild_candidate(result.cdsl, rebuilt) + self.assertTrue(rebuilt.exists()) + self.assertEqual(outcome["status"], "rebuilt") + resolved = [ + item for item in outcome["result"]["selector_resolution"] + if item["feature_id"] == "f_F7" + and item["selector"].get("owner_feature_id") == "f_F4.c4.f_F1" + ] + self.assertEqual(len(resolved), 2) + self.assertTrue(all(item["status"] == "resolved" for item in resolved)) + + def test_shell_rejects_swept_faces_without_direct_linear_extrude_ownership(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + cases = (("00246716", "F7"), ("00051308", "F3")) + for sample_id, shell_id in cases: + with self.subTest(sample_id=sample_id): + feature = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), sample_id), {}) + diagnostic = next(item for item in result.diagnostics if item.get("feature_id") == shell_id) + self.assertEqual(diagnostic["code"], "unsupported_engine_capability") + self.assertEqual(diagnostic["capability"], "shell_face_selector") + self.assertIn("direct blind/two-sided linear extrusion", diagnostic["message"]) + 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" @@ -679,11 +1675,14 @@ class LoweringTests(unittest.TestCase): 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")], - ) + self.assertEqual(result.status, "converted_complete") + self.assertFalse(result.diagnostics) + self.assertEqual(features["f_F15"]["atomic_id"], "transform_bodies") + self.assertEqual(features["f_F15"]["params"]["source_feature_ids"], ["f_F1", "f_F5"]) + self.assertEqual(features["f_F15"]["params"]["pattern_instance_refs"], [ + {"pattern_feature_id": "f_F6", "source_feature_id": "f_F5", "instance_index": 1}, + {"pattern_feature_id": "f_F6", "source_feature_id": "f_F5", "instance_index": 3}, + ]) 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})') diff --git a/cadfs_to_cdsl/tests/test_selector_binding.py b/cadfs_to_cdsl/tests/test_selector_binding.py index 0e5ad462..32dc3ec4 100644 --- a/cadfs_to_cdsl/tests/test_selector_binding.py +++ b/cadfs_to_cdsl/tests/test_selector_binding.py @@ -1,10 +1,14 @@ from __future__ import annotations +from copy import deepcopy from pathlib import Path +import tempfile import unittest +from unittest.mock import patch from cadfs_to_cdsl.featurescript_parser import parse_featurescript from cadfs_to_cdsl.lowering import lower_model +from cadfs_to_cdsl.rebuild import rebuild_candidate from cadfs_to_cdsl.selector_binding import _score, bind_candidate_selectors @@ -33,6 +37,66 @@ class SelectorBindingTests(unittest.TestCase): 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_geometry_free_context_selector_uses_unique_active_record_when_owner_is_stale(self) -> None: + cdsl = { + "features": [ + {"id": "f_source", "atomic_id": "reference_plane"}, + { + "id": "f_mirror", + "atomic_id": "pattern_mirror", + "params": {}, + "selectors": [{"kind": "plane", "owner_feature_id": "f_source"}], + }, + ], + } + report = { + "feature_results": [], + "topology_records": [{ + "kind": "plane", + "record_id": "context:plane:1", + "owner_feature_ids": ["f_live_plane"], + "geometry": {}, + }], + } + + with patch("engine.cdsl_engine.runtime.rebuild_cdsl", return_value=report): + bound, _ = bind_candidate_selectors(cdsl) + + selector = bound["features"][1]["selectors"][0] + self.assertEqual(selector["owner_feature_id"], "f_live_plane") + self.assertEqual(selector["stable_id"], "context:plane:1") + self.assertEqual(bound["features"][1]["params"]["mirror_plane"], selector) + + def test_geometry_free_instance_selector_does_not_fall_back_to_active_record(self) -> None: + cdsl = { + "features": [ + {"id": "f_source", "atomic_id": "reference_plane"}, + { + "id": "f_mirror", + "atomic_id": "pattern_mirror", + "params": {}, + "selectors": [{ + "kind": "plane", + "owner_feature_id": "f_source", + "owner_match_required": True, + }], + }, + ], + } + report = { + "feature_results": [], + "topology_records": [{ + "kind": "plane", + "record_id": "context:plane:1", + "owner_feature_ids": ["f_live_plane"], + "geometry": {}, + }], + } + + with patch("engine.cdsl_engine.runtime.rebuild_cdsl", return_value=report): + with self.assertRaisesRegex(ValueError, "f_mirror: selector_not_found after prefix rebuild"): + bind_candidate_selectors(cdsl) + 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})) @@ -50,28 +114,79 @@ class SelectorBindingTests(unittest.TestCase): self.assertEqual(score, 1.0) - def test_intersection_vertex_binds_pattern_and_current_body_prefixes(self) -> None: + def test_cap_face_output_role_is_validated_without_snapshot_rebinding(self) -> None: + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0001/00016195.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + candidate = lower_model(parse_featurescript(feature.read_text(), "00016195"), {}) + + bound, evidence = bind_candidate_selectors(candidate.cdsl) + + selector = next(item for item in bound["features"] if item["id"] == "f_F3")["selectors"][0] + self.assertEqual(selector, { + "kind": "face", "owner_feature_id": "f_F1", "output_role": "extrude.end", + "source": "runtime_snapshot", "confidence": 1.0, + }) + self.assertNotIn("stable_id", selector) + self.assertNotIn("snapshot_id", selector) + self.assertNotIn("geometry", selector) + binding = next(item for item in evidence if item["feature_id"] == "f_F3") + self.assertEqual(len(binding["resolved"]), 1) + self.assertEqual(binding["resolved"][0]["output_roles"], ["extrude.end"]) + + def test_shell_offset_face_role_binds_only_its_true_dependency_source(self) -> None: + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0010/00107631.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + candidate = lower_model(parse_featurescript(feature.read_text(), "00107631"), {}) + self.assertEqual(candidate.status, "converted_complete") + + # F2 retains F1's end-cap provenance, but F3 also selects the distinct + # start-cap plane. Its direct owner has no geometry-qualified active + # candidate, so binding must consider the unique active F2 face without + # weakening the exact qualified OFFSET_FACE role. + prefix = deepcopy(candidate.cdsl) + prefix["features"] = prefix["features"][:3] + bound, evidence = bind_candidate_selectors(prefix) + + first, offset = bound["features"][-1]["selectors"] + self.assertEqual(first["owner_feature_id"], "f_F2") + self.assertEqual(first["stable_id"], "body:f_F2:face:1") + self.assertEqual(first["snapshot_id"], "body:f_F2:face:1") + self.assertEqual(first["source"], "runtime_snapshot") + self.assertEqual(first["confidence"], 1.0) + self.assertEqual(offset, { + "kind": "face", "owner_feature_id": "f_F2", "output_role": "shell.offset_face", + "output_role_source": {"owner_feature_id": "f_F1", "output_role": "extrude.start"}, + "source": "runtime_snapshot", "confidence": 1.0, + }) + binding = next(item for item in evidence if item["feature_id"] == "f_F3") + self.assertEqual(binding["resolved"][0]["snapshot_id"], "body:f_F2:face:1") + self.assertEqual(binding["resolved"][1]["record_id"], "body:f_F2:face:5") + + # Binding must not hide the OCC feasibility boundary. The requested + # second shell currently produces an invalid shape, so preserve the + # F2 prefix instead of changing thickness or removal faces. + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(candidate.cdsl, Path(directory) / "00107631.step") + self.assertEqual(outcome["status"], "rebuild_failed") + self.assertEqual(outcome["error"]["type"], "RuntimeExecutionError") + self.assertEqual(outcome["error"]["message"], "OCC shell operation produced an invalid shape") + self.assertEqual(outcome["last_executable_prefix"]["failed_feature_id"], "f_F3") + self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F2") + + def test_intersection_vertex_binds_a_pattern_copy_with_exact_instance_owner_evidence(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) + reference = next(item for item in candidate.cdsl["features"] if item["id"] == "f_F7")["params"]["end_condition"]["reference"] + self.assertTrue(all(item["owner_match_required"] for item in reference["intersection_of"][:2])) - 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) + bound, evidence = bind_candidate_selectors(candidate.cdsl) + reference = next(item for item in bound["features"] if item["id"] == "f_F7")["params"]["end_condition"]["reference"] + copy_components = reference["intersection_of"][:2] + self.assertTrue(all(item["owner_feature_id"] == "f_F4.c4.f_F1" for item in copy_components)) + self.assertTrue(all(item.get("snapshot_id") for item in copy_components)) + binding = next(item for item in evidence if item["feature_id"] == "f_F7") + self.assertGreaterEqual(len(binding["resolved"]), 2) diff --git a/json_to_cdsl/output/corpus-manifest.json b/json_to_cdsl/output/corpus-manifest.json index f729d154..27917825 100644 --- a/json_to_cdsl/output/corpus-manifest.json +++ b/json_to_cdsl/output/corpus-manifest.json @@ -1,20 +1,20 @@ { "schema": "cdsl.corpus-manifest.v1", - "corpus_version": "2026-09-07", + "corpus_version": "2026-08-31", "document_count": 2881, - "document_stems_sha256": "4c8660ed96f3b5427ad26adadd9b82df447951913920542501ef0fc479c02773", + "document_stems_sha256": "ba5b905191817cd0f7b9ad07d4002ad27730e200a395f71f9c4e31ebb1867cd6", "phase_pools": { "p3": { "selector_count": 771, - "selectors_sha256": "2c0f76833cd08592b71ef3a5b5b1548b649ac485e2b2388358426f815c21a047" + "selectors_sha256": "72093701357cd1858f2dbcc0f1ab9e6c7baea7db26357154e0586e5e1be6f801" }, "p4": { "selector_count": 826, - "selectors_sha256": "c031552f9ae29f03ca58545d90dc8b16e2b5963f0923ff075e3724f066a2bf3d" + "selectors_sha256": "ab9ea6edd8532ce7a0110d0725b5303c63a53617a0af502101b12f657285d371" }, "p6": { "selector_count": 893, - "selectors_sha256": "de44a06e55aa21940e9ccb02d0deaba90bb7669d8fb93a62bf976c903a18c42f" + "selectors_sha256": "ac9f91b690bc533b1fdd1772d65ee77b248d108f39a429615d909b78332d9a08" } } }