diff --git a/.gitignore b/.gitignore index d7fa50d5..42b987d1 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ build/ *.tsbuildinfo # Runtime data and generated local artifacts +output/ backend/data/ backend/live-evals/ cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 7577a913..b6651115 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -5,7 +5,7 @@ from __future__ import annotations import math from typing import Any, Iterable -from build123d import AngularDirection, Axis, Compound, Edge, Face, Location, Plane, ShapeList, Shell, Solid, Vector, Wire, export_step +from build123d import AngularDirection, Axis, Compound, Edge, Face, GeomType, 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 @@ -20,7 +20,7 @@ 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_EDGE, TopAbs_FACE, TopAbs_SHELL +from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_SHELL, TopAbs_VERTEX from OCP.TopExp import TopExp_Explorer from OCP.TopTools import TopTools_ListOfShape from OCP.TopoDS import TopoDS @@ -31,7 +31,7 @@ from .parametric_gears import build_gear_solid, build_rack_solid from .parametric_thread import build_thread_solid from .runtime_types import ( AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec, - TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologySectionRelation, Vector3, + TopologyBlendRelation, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologySectionRelation, Vector3, canonical_plane_signature, ) from .topology_export import ( @@ -656,8 +656,90 @@ class Build123dGeometryAdapter: 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.""" + @staticmethod + def _imprint_source_anchor_specs( + selected: list[Face], + source_edges: dict[str, list[Edge]], + splitter: BOPAlgo_Splitter, + source_sketch_id: str | None, + ) -> list[dict[str, Any]]: + """Return only selected IMPRINT boundary fragments with exact sources. + + A logical FeatureScript edge can be split at an arrangement + intersection. Its descendants are a source *set*, not an ambiguous + geometry match. Keep every descendant that is an exact ``IsSame`` + boundary of a selected region; do not expose unselected splitter + images or reconstruct a fragment from coordinates. + """ + if not isinstance(source_sketch_id, str) or not source_sketch_id: + return [] + mapped: list[tuple[str, Edge]] = [] + for source_entity_id, source in source_edges.items(): + # The IMPRINT selector path itself accepts only one native source + # edge per logical entity. Withhold a source label when that + # invariant is not true instead of flattening a multi-edge curve. + if len(source) != 1: + continue + for image in Build123dGeometryAdapter._split_images(splitter, source[0]): + for face in selected: + for boundary in face.edges(): + if not boundary.wrapped.IsSame(image.wrapped): + continue + if not any( + source_entity_id == existing_id + and boundary.wrapped.IsSame(existing.wrapped) + for existing_id, existing in mapped + ): + mapped.append((source_entity_id, boundary)) + + specs: list[dict[str, Any]] = [ + { + "kind": "edge", + "value": edge, + "source_entity": (source_sketch_id, source_entity_id), + } + for source_entity_id, edge in mapped + ] + vertex_groups: list[tuple[Any, set[str]]] = [] + for source_entity_id, edge in mapped: + for vertex in edge.vertices(): + group = next( + (candidate for candidate in vertex_groups if candidate[0].wrapped.IsSame(vertex.wrapped)), + None, + ) + if group is None: + vertex_groups.append((vertex, {source_entity_id})) + else: + group[1].add(source_entity_id) + for vertex, entity_ids in vertex_groups: + if len(entity_ids) < 2: + continue + specs.append({ + "kind": "vertex", + "value": vertex, + "source_entities": tuple( + (source_sketch_id, entity_id) for entity_id in sorted(entity_ids) + ), + }) + return specs + + def _faces_from_planar_imprint_with_source_anchors( + self, + sketch: dict[str, Any], + *, + support_face: Face | None = None, + external_anchor_edges: dict[str, Edge] | None = None, + ) -> tuple[list[Face], list[dict[str, Any]]]: + """Materialize bounded IMPRINT regions and their exact source anchors. + + ``external_anchor_edges`` is deliberately an adapter-only input. It + accepts an already proven native edge (for example a CAP edge on an + attached support face) as a splitter tool and fragment anchor. It is + not a geometry lookup, does not export an anchor record, and does not + make an external selector valid in CDSL by itself. The source lowerer + must establish that provenance before this hook is ever used by a + production profile contract. + """ source_entries = sketch.get("imprint_entities_mm") or [] selections = sketch.get("imprint_selections") or [] if not source_entries or not selections: @@ -674,12 +756,24 @@ class Build123dGeometryAdapter: raise ValueError(f"planar_imprint source entity {source_id!r} has no OCC edge") source_edges[source_id] = edges all_edges.extend(edges) + external_anchors = dict(external_anchor_edges or {}) + if any(not isinstance(anchor_id, str) or not anchor_id or not isinstance(edge, Edge) + for anchor_id, edge in external_anchors.items()): + raise ValueError("planar_imprint external anchors are invalid") + if set(source_edges).intersection(external_anchors): + raise ValueError("planar_imprint external anchor duplicates a source entity") plane_spec = PlaneSpec.from_mapping(sketch.get("workplane") or {}) - support = self._imprint_support_face(all_edges, plane_spec) + # An attached face is exact live topology, unlike the artificial + # planar box used for unattached source sketches. Rebuilding that box + # here would silently discard the support-face boundary semantics. + support = support_face or self._imprint_support_face(all_edges, plane_spec) + artificial_support = support_face is None splitter = BOPAlgo_Splitter() splitter.AddArgument(support.wrapped) for edge in all_edges: splitter.AddTool(edge.wrapped) + for edge in external_anchors.values(): + splitter.AddTool(edge.wrapped) splitter.Perform() if splitter.HasErrors(): raise ValueError("planar_imprint OCC splitter failed") @@ -706,7 +800,13 @@ class Build123dGeometryAdapter: 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 [] + external_anchor_id = str((fragment or {}).get("external_anchor_id") or "") + if fragment and bool(anchor_id) == bool(external_anchor_id): + raise ValueError("planar_imprint fragment must name exactly one anchor") + anchors = source_edges.get(anchor_id) or [] if anchor_id else [] + if external_anchor_id: + external = external_anchors.get(external_anchor_id) + anchors = [external] if external is not None else [] if fragment and not anchors: raise ValueError("planar_imprint fragment anchor is unavailable") edges = self._selected_imprint_edges(splitter, source[0], anchors, fragment) @@ -728,14 +828,15 @@ class Build123dGeometryAdapter: 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") + if artificial_support: + 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 @@ -743,9 +844,27 @@ class Build123dGeometryAdapter: for face in candidate_faces: if not any(face.wrapped.IsSame(existing.wrapped) for existing in selected): selected.append(face) - return selected + return selected, self._imprint_source_anchor_specs( + selected, + source_edges, + splitter, + sketch.get("source_sketch_id"), + ) - def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Face]: + def _faces_from_planar_imprint( + self, sketch: dict[str, Any], *, support_face: Face | None = None, + external_anchor_edges: dict[str, Edge] | None = None, + ) -> list[Face]: + """Materialize exact bounded IMPRINT regions with OCC planar splitting.""" + faces, _anchors = self._faces_from_planar_imprint_with_source_anchors( + sketch, support_face=support_face, external_anchor_edges=external_anchor_edges, + ) + return faces + + def faces_for_sketch( + self, sketch: dict[str, Any], *, support_face: Face | None = None, + external_anchor_edges: dict[str, Edge] | None = None, + ) -> list[Face]: # 从草图数据解析出可拉伸/旋转的轮廓面,按三种数据来源依次回退。 # 1. 单圆 contour 不应先被 sketch_solver 展开成四段圆弧。圆弧分段会 # 改变拉伸后的圆柱面拓扑:同一圆柱侧面被拆成四块,后续来自 @@ -754,7 +873,11 @@ class Build123dGeometryAdapter: # _faces_from_circles 的包含关系生成带孔面。 profile = sketch.get("profile") or {} if profile.get("type") == "planar_imprint": - return self._faces_from_planar_imprint(sketch) + return self._faces_from_planar_imprint( + sketch, + support_face=support_face, + external_anchor_edges=external_anchor_edges, + ) contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None if isinstance(contours, list) and contours and all( isinstance(contour, dict) @@ -797,16 +920,26 @@ class Build123dGeometryAdapter: def faces_for_sketch_with_source_anchors( self, sketch: dict[str, Any], + *, + support_face: Face | None = None, + external_anchor_edges: dict[str, Edge] | None = None, ) -> tuple[list[Face], list[dict[str, Any]]]: """Return profile faces plus direct source anchors for prism history. - This intentionally covers only the direct analytic profile builders. - IMPRINT, split, generated, or otherwise transformed profile paths keep - their normal geometry but expose no semantic source anchor. + This intentionally covers only direct analytic builders and the + separately proven planar-IMPRINT splitter path. Other generated or + transformed profile paths keep their normal geometry but expose no + semantic source anchor. """ profile = sketch.get("profile") or {} source_sketch_id = sketch.get("source_sketch_id") - if profile.get("type") == "planar_imprint" or not isinstance(source_sketch_id, str): + if profile.get("type") == "planar_imprint": + return self._faces_from_planar_imprint_with_source_anchors( + sketch, + support_face=support_face, + external_anchor_edges=external_anchor_edges, + ) + if not isinstance(source_sketch_id, str): return self.faces_for_sketch(sketch), [] contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None if isinstance(contours, list) and contours and all( @@ -879,8 +1012,19 @@ class Build123dGeometryAdapter: return faces, anchors if profile.get("type") == "circle": plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) + # A lowered direct circle keeps its sole source identity on the + # profile itself, not in the generic sketch entity list. Pass + # through only that declared identity; never recover it from the + # resulting circle's radius or centre. + circle = { + "type": "circle", + "center": profile.get("center"), + "radius_mm": profile.get("radius_mm"), + } + if isinstance(profile.get("source_entity_id"), str) and profile["source_entity_id"]: + circle["source_entity_id"] = profile["source_entity_id"] return self._faces_from_circles_with_source_anchors( - sketch.get("entities") or [], plane, source_sketch_id, + [circle], plane, source_sketch_id, ) return self.faces_for_sketch(sketch), [] @@ -943,6 +1087,21 @@ class Build123dGeometryAdapter: )) return result, TopologyDelta(operation="loft", relations=tuple(relations)) + def loft_surface(self, sketches: list[dict[str, Any]]) -> Shell: + """Loft closed source wires into an independent shell, never a solid.""" + wires = self._loft_wires(sketches) + builder = BRepOffsetAPI_ThruSections(False, False) + builder.CheckCompatibility(True) + for wire in wires: + builder.AddWire(wire.wrapped) + builder.Build() + if not builder.IsDone(): + raise ValueError("OCC surface loft operation did not complete") + shape = builder.Shape() + if shape.IsNull() or shape.ShapeType() != TopAbs_SHELL: + raise ValueError("OCC surface loft operation did not produce one shell") + return Shell(shape) + def loft_with_cap_face(self, cap_face: Face, sketches: list[dict[str, Any]]) -> Solid: """Loft from one cap face's outer wire to one closed sketch profile.""" if not sketches: @@ -988,6 +1147,41 @@ class Build123dGeometryAdapter: normal = face.normal_at() return (float(normal.X), float(normal.Y), float(normal.Z)) + @staticmethod + def planar_face_workplane(face: Face) -> PlaneSpec: + """Build a sketch frame from one already-proven active planar face. + + This is deliberately a query of the exact resolved B-rep face, not a + geometric selector. The face's native U direction retains the + attachment handedness through the boolean; its support plane projects + the global origin, matching FeatureScript's face-sketch convention. + """ + if str(face.geom_type).split(".")[-1].lower() != "plane": + raise ValueError("attached sketch requires a planar face") + normal = Build123dGeometryAdapter.face_normal(face) + try: + # build123d's Face.position_at takes normalized 0..1 coordinates, + # then maps them to the native OCC UV bounds internally. Passing + # raw UV values here re-applies that mapping and can put an attached + # sketch frame kilometres away from its resolved support face. + u_mid, v_mid = 0.5, 0.5 + step = 1e-6 + before, after = face.position_at(u_mid - step, v_mid), face.position_at(u_mid + step, v_mid) + except (AttributeError, TypeError, ValueError) as error: + raise ValueError("attached planar face has no stable native U direction") from error + x_dir = (float(after.X - before.X), float(after.Y - before.Y), float(after.Z - before.Z)) + point = face.position_at(u_mid, v_mid) + origin = (float(point.X), float(point.Y), float(point.Z)) + offset = sum(origin[index] * normal[index] for index in range(3)) + # The FeatureScript face-sketch convention uses the global origin + # projected onto the resolved support plane. ``origin - n * offset`` + # instead projects this arbitrary in-plane sample onto the global + # zero plane, retaining an accidental U/V offset for parallel faces. + projected_origin = tuple(offset * normal[index] for index in range(3)) + return PlaneSpec.from_mapping({ + "origin_mm": list(projected_origin), "x_dir": list(x_dir), "normal": list(normal), + }) + @staticmethod def extrude_with_topology_delta(face: Face, direction: Vector3) -> tuple[Solid, TopologyDelta]: """Extrude one face with exact cap, side-wall, and swept-edge history.""" @@ -1048,8 +1242,212 @@ class Build123dGeometryAdapter: coverage="complete" if len(swept_edges) == len(generated_edges) and swept_edges else "partial", status="proven" if len(swept_edges) == len(generated_edges) and swept_edges else "unknown", )) + for source_vertex in face.vertices(): + # Unlike a smooth loft, MakePrism exposes per-vertex FirstShape / + # LastShape overloads. Retain them only when OCC's exact handle + # is a vertex in the final snapshot; source coordinates are never + # used to reconstruct a cap vertex. + for role, cap_vertex in ( + ("extrude.start", builder.FirstShape(source_vertex.wrapped)), + ("extrude.end", builder.LastShape(source_vertex.wrapped)), + ): + is_final_vertex = ( + not cap_vertex.IsNull() + and cap_vertex.ShapeType() == TopAbs_VERTEX + and any(cap_vertex.IsSame(vertex.wrapped) for vertex in result.vertices()) + ) + relations.append(TopologyDeltaRelation( + "generated", "vertex", source_vertex.wrapped, + (cap_vertex,) if is_final_vertex else (), + output_role=role, + source_kind="vertex", + result_kind="vertex", + derivation="boundary", + coverage="complete" if is_final_vertex else "partial", + status="proven" if is_final_vertex else "unknown", + )) return result, TopologyDelta(operation="extrude", relations=tuple(relations)) + @staticmethod + def _dedupe_topology_values(values: Iterable[Any]) -> tuple[Any, ...]: + """Keep exact OCC handles in first-seen order without hashing them.""" + unique: list[Any] = [] + for value in values: + if value is None or value.IsNull(): + continue + if not any(value.IsSame(existing) for existing in unique): + unique.append(value) + return tuple(unique) + + @staticmethod + def _is_result_topology_member(result: Any, candidate: Any) -> bool: + """Return whether an exact history handle still belongs to a result.""" + if candidate is None or candidate.IsNull(): + return False + if result.IsSame(candidate): + return True + explorer = TopExp_Explorer(result, candidate.ShapeType()) + while explorer.More(): + if explorer.Current().IsSame(candidate): + return True + explorer.Next() + return False + + @classmethod + def _final_history_descendants( + cls, + operation: Any, + source_value: Any, + result: Any, + expected_type: Any, + ) -> tuple[Any, ...]: + """Project one exact intermediate handle through a later OCC builder.""" + candidates: list[Any] = [] + if cls._is_result_topology_member(result, source_value): + candidates.append(source_value) + for method_name in ("Modified", "Generated"): + method = getattr(operation, method_name, None) + if not callable(method): + continue + try: + candidates.extend(method(source_value)) + except (AttributeError, TypeError, ValueError): + continue + return cls._dedupe_topology_values( + candidate for candidate in candidates + if candidate.ShapeType() == expected_type and cls._is_result_topology_member(result, candidate) + ) + + @classmethod + def extrude_faces_with_composed_topology_delta( + cls, + faces: Iterable[Face], + direction: Vector3, + ) -> tuple[Any, TopologyDelta] | None: + """Fuse multi-region prisms while retaining only final-snapshot history. + + Each profile region gets its own exact prism builder. One OCC fuse + then maps those intermediate outputs into the final B-rep. Relations + are emitted only from original profile handles to final handles; an + intermediate handle never reaches the registry. Missing/deleted fuse + descendants remain an explicit partial relation, so an ``all_fragments`` + selector cannot turn a partial result into a successful selection. + """ + profile_faces = tuple(faces) + vector = _vector(direction) + if len(profile_faces) < 2 or vector.length <= 1e-9: + return None + prisms = [ + BRepPrimAPI_MakePrism(face.wrapped, gp_Vec(vector.X, vector.Y, vector.Z), True, True) + for face in profile_faces + ] + if not all(builder.IsDone() for builder in prisms): + return None + arguments = TopTools_ListOfShape(); arguments.Append(prisms[0].Shape()) + tools = TopTools_ListOfShape() + for prism in prisms[1:]: + tools.Append(prism.Shape()) + fuse = BRepAlgoAPI_Fuse() + fuse.SetRunParallel(True); fuse.SetUseOBB(True); fuse.SetToFillHistory(True) + fuse.SetArguments(arguments); fuse.SetTools(tools); fuse.Build() + if not fuse.IsDone(): + return None + result = Solid(fuse.Shape()) + if not result.is_valid or not cls.body_solids(result) or result.volume <= 1e-9: + return None + + relations: list[TopologyDeltaRelation] = [] + + def append_relation( + kind: str, + source_value: Any, + direct_values: Iterable[Any], + *, + expected_type: Any, + output_role: str | None = None, + source_kind: str | None = None, + result_kind: str | None = None, + ) -> None: + direct = cls._dedupe_topology_values(direct_values) + per_direct = [ + cls._final_history_descendants(fuse, value, fuse.Shape(), expected_type) + for value in direct + ] + final = cls._dedupe_topology_values(value for values in per_direct for value in values) + complete = bool(direct) and all(values for values in per_direct) + relations.append(TopologyDeltaRelation( + "generated", kind, source_value, final, + output_role=output_role, + derivation="fragment" if len(final) > 1 else "boundary", + source_kind=source_kind, + result_kind=result_kind, + coverage="complete" if complete else "partial", + status="proven" if complete else "unknown", + )) + + # Cap roles are per input face because a later fuse may merge two cap + # regions into one physical face. Output-role resolution consumes the + # final set, rather than a reconstructed profile face. + edge_groups: list[tuple[Any, list[tuple[Any, Face]]]] = [] + vertex_groups: list[tuple[Any, list[tuple[Any, Face]]]] = [] + for face, prism in zip(profile_faces, prisms): + for role, direct in ( + ("extrude.start", prism.FirstShape(face.wrapped)), + ("extrude.end", prism.LastShape(face.wrapped)), + ): + if not direct.IsNull() and direct.ShapeType() == TopAbs_FACE: + append_relation("face", face.wrapped, (direct,), expected_type=TopAbs_FACE, output_role=role) + for edge in face.edges(): + group = next((item for item in edge_groups if edge.wrapped.IsSame(item[0])), None) + if group is None: + group = (edge.wrapped, []) + edge_groups.append(group) + group[1].append((prism, edge)) + for vertex in edge.vertices(): + vertex_group = next((item for item in vertex_groups if vertex.wrapped.IsSame(item[0])), None) + if vertex_group is None: + vertex_group = (vertex.wrapped, []) + vertex_groups.append(vertex_group) + vertex_group[1].append((prism, vertex)) + + for source_edge, occurrences in edge_groups: + for role in ("extrude.start", "extrude.end"): + direct = [ + prism.FirstShape(edge.wrapped) if role == "extrude.start" else prism.LastShape(edge.wrapped) + for prism, edge in occurrences + ] + append_relation( + "edge", source_edge, direct, expected_type=TopAbs_EDGE, output_role=role, + source_kind="edge", result_kind="edge", + ) + generated = [ + value + for prism, edge in occurrences + for value in prism.Generated(edge.wrapped) + ] + side_faces = [value for value in generated if value.ShapeType() == TopAbs_FACE] + append_relation( + "edge", source_edge, + side_faces if len(side_faces) == len(generated) else (), + expected_type=TopAbs_FACE, source_kind="edge", result_kind="face", + ) + + for source_vertex, occurrences in vertex_groups: + generated = [ + value + for prism, vertex in occurrences + for value in prism.Generated(vertex.wrapped) + ] + swept_edges = [value for value in generated if value.ShapeType() == TopAbs_EDGE] + append_relation( + "vertex", source_vertex, + swept_edges if len(swept_edges) == len(generated) else (), + expected_type=TopAbs_EDGE, source_kind="vertex", result_kind="edge", + ) + return result, TopologyDelta( + operation="extrude", relations=tuple(relations), history_reason="exact_prism_fuse_history", + ) + @staticmethod def _single_face_from_shape(shape: Any) -> Face | None: """Return one face only when an OCC builder output contains exactly one. @@ -1200,6 +1598,9 @@ class Build123dGeometryAdapter: @staticmethod def vertex_coordinates(vertex: Any) -> Vector3: # 提取顶点的三维坐标元组。 + if not hasattr(vertex, "X"): + point = BRep_Tool.Pnt_s(TopoDS.Vertex_s(vertex)) + return (float(point.X()), float(point.Y()), float(point.Z())) return (float(vertex.X), float(vertex.Y), float(vertex.Z)) @staticmethod @@ -1316,11 +1717,106 @@ class Build123dGeometryAdapter: raise ValueError("extent target requires non-uniform profile trimming") return sum(distances) / len(distances) + @staticmethod + def target_has_forward_intersection(target: Any, faces: Iterable[Face], direction: Vector3) -> bool: + """Return whether any sampled profile ray reaches the finite target.""" + unit_direction = _vector(direction).normalized() + return any( + Build123dGeometryAdapter._forward_intersection_distance(target, point, unit_direction) is not None + for face in faces + for point in Build123dGeometryAdapter.profile_sample_points(face) + ) + + @staticmethod + def uniform_planar_supporting_surface_distance( + target: Any, + faces: Iterable[Face], + direction: Vector3, + ) -> float: + """Return a uniform forward distance to a planar target's support. + + ``UP_TO_SURFACE`` normally uses the selected face's finite trim: a + partial intersection is handled by ``extrude_trimmed``. A planar + face can, however, be a provenance-resolved support whose trim no + longer overlaps the complete downstream profile after a shell. In + that strictly separate case FeatureScript's surface termination is + represented by its unbounded supporting plane. This helper proves + only that plane construction; callers must first prove that the + finite face has no forward hit at all, so it cannot replace partial + face-trim semantics. + """ + if not isinstance(target, Face) or target.geom_type != GeomType.PLANE: + raise ValueError("extent target has no planar supporting surface") + unit_direction = _vector(direction).normalized() + plane_normal = target.normal_at().normalized() + denominator = plane_normal.dot(unit_direction) + if abs(denominator) <= 1e-9: + raise ValueError("extent target supporting surface is parallel to the extrusion") + plane_point = target.center() + distances: list[float] = [] + for face in faces: + for point in Build123dGeometryAdapter.profile_sample_points(face): + distance = (plane_point - point).dot(plane_normal) / denominator + if not math.isfinite(distance) or distance <= 1e-6: + raise ValueError("extent target supporting surface is not ahead of the profile") + distances.append(distance) + if not distances: + raise ValueError("extent feature has no profile samples") + minimum, maximum = min(distances), max(distances) + if maximum - minimum > 1e-5: + raise ValueError("extent target supporting surface is non-uniform") + return sum(distances) / len(distances) + @staticmethod def revolve(face: Face, angle_deg: float, axis: AxisSpec) -> Solid: # 绕给定轴将面旋转指定角度,生成回转实体。 return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis)) + @staticmethod + def revolve_with_topology_delta(face: Face, angle_deg: float, axis: AxisSpec) -> tuple[Solid, TopologyDelta]: + """Revolve one face while retaining exact source-vertex edge history. + + ``BRepPrimAPI_MakeRevol.Generated(vertex)`` is the only accepted + witness for a CADFS ``SWEPT_EDGE`` from a full solid revolve. Circle + centre/radius signatures are not used to reconstruct this relation. + """ + if abs(abs(float(angle_deg)) - 360.0) > 1e-8: + raise ValueError("revolve topology history requires a full 360 degree revolution") + operation = BRepPrimAPI_MakeRevol( + face.wrapped, + gp_Ax1(gp_Pnt(*axis.origin_mm), gp_Dir(*axis.direction)), + math.radians(float(angle_deg)), + True, + ) + operation.Build() + if not operation.IsDone(): + raise ValueError("OCC revolve operation did not complete") + result = Solid(operation.Shape()) + if not result.is_valid or not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9: + raise ValueError("OCC revolve operation did not produce a valid solid") + relations: list[TopologyDeltaRelation] = [] + source_vertices: list[Any] = [] + for source_edge in face.edges(): + for source_vertex in source_edge.vertices(): + if any(source_vertex.wrapped.IsSame(existing) for existing in source_vertices): + continue + source_vertices.append(source_vertex.wrapped) + for source_vertex in source_vertices: + generated = tuple(operation.Generated(source_vertex)) + swept_edges = tuple( + value for value in generated + if value.ShapeType() == TopAbs_EDGE + and Build123dGeometryAdapter._is_result_topology_member(result.wrapped, value) + ) + complete = bool(swept_edges) and len(swept_edges) == len(generated) + relations.append(TopologyDeltaRelation( + "generated", "vertex", source_vertex, swept_edges if complete else (), + source_kind="vertex", result_kind="edge", derivation="boundary", + coverage="complete" if complete else "partial", + status="proven" if complete else "unknown", + )) + return result, TopologyDelta(operation="revolve", relations=tuple(relations)) + @staticmethod def revolve_surface(wire: Wire, angle_deg: float, axis: AxisSpec) -> Shell: # 表面回转必须以 profile wire 而非 Face 输入。Face 回转会由 OCC 封闭为 @@ -1341,8 +1837,19 @@ class Build123dGeometryAdapter: @staticmethod def surface_wires_for_sketch(sketch: dict[str, Any]) -> list[Wire]: - # 曲面拉伸只消费 CADFS 显式选择的闭合曲线,不把同心圆转换成带孔 Face。 - # 后者适用于实体拉伸,但会丢失每条 source edge 对应的一张独立曲面。 + # CADFS pure-surface extrusion may consume one original open wire. + # Its resolved edges are supplied directly by the sketch solver, so + # this path never adds the solid-cut closing edge or guesses a loop. + source_wires = sketch.get("surface_wires_mm") + if isinstance(source_wires, list) and source_wires: + wires = [] + for edges in source_wires: + if not isinstance(edges, list) or not edges: + raise ValueError("surface extrude source wire is unresolved") + wires.append(Wire(Build123dGeometryAdapter._wire_edges(edges))) + return wires + # Mixed surface extrusion consumes explicitly selected closed circles + # without converting concentric wires into a solid face with holes. profile = sketch.get("profile") or {} plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) if profile.get("type") == "circle": @@ -1538,27 +2045,44 @@ class Build123dGeometryAdapter: @staticmethod def cut_with_topology_delta(body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]: - """Subtract single explicit bodies while preserving exact OCC history. + """Subtract each independent target solid with 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. + A multi-solid body is a set of independent CAD members. Each member + has its own `BRepAlgoAPI_Cut` builder, so its history can be composed + only by concatenating those disjoint kernel relations. No aggregate + boolean relation or member-order correspondence is invented here. """ - if len(Build123dGeometryAdapter.body_solids(body)) != 1 or len(Build123dGeometryAdapter.body_solids(tool)) != 1: + target_solids = Build123dGeometryAdapter.body_solids(body) + if not target_solids 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", + results: list[Any] = [] + deltas: list[TopologyDelta] = [] + for target in target_solids: + arguments = TopTools_ListOfShape(); arguments.Append(target.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())) + if result is not None: + results.append(result) + deltas.append(Build123dGeometryAdapter._builder_topology_delta( + operation, (target, tool), "subtract", + )) + combined = None + for result in results: + combined = Build123dGeometryAdapter.combine(combined, result) + if combined is None: + raise ValueError("OCC cut operation produced no shape") + return combined, TopologyDelta( + operation="subtract", + relations=tuple(relation for delta in deltas for relation in delta.relations), + section_values=tuple(value for delta in deltas for value in delta.section_values), + section_relations=tuple(relation for delta in deltas for relation in delta.section_relations), + blend_relations=tuple(relation for delta in deltas for relation in delta.blend_relations), + history_reason="per_member_exact_cut_history" if len(target_solids) > 1 else None, ) @staticmethod @@ -1686,11 +2210,110 @@ class Build123dGeometryAdapter: raise ValueError("OCC body transform produced an invalid shape") return result, Build123dGeometryAdapter._builder_topology_delta(operation, (body,), kind) + @classmethod + def _builder_blend_relations( + cls, + operation: Any, + sources: Iterable[Any], + result: Any, + operation_name: str, + ) -> tuple[TopologyBlendRelation, ...]: + """Return exact fillet/chamfer patch-boundary transition facts. + + For each source edge, OCC reports generated patch faces. A CADFS + ``BLEND_EDGE`` also qualifies that patch by its source face, so retain + only patch boundary edges that are exactly shared with a final + ``Modified(source_face)`` handle. This is an adapter fact, not a + geometry search or an executable selector by itself. + """ + if operation_name not in {"fillet", "chamfer"} or result is None: + return () + relations: list[TopologyBlendRelation] = [] + for source in sources: + source_edges = tuple(edge.wrapped for edge in source.edges()) + source_faces = tuple(face.wrapped for face in source.faces()) + for source_edge in source_edges: + try: + generated = tuple(operation.Generated(source_edge)) + except (AttributeError, TypeError, ValueError): + continue + patch_faces = cls._dedupe_topology_values( + value for value in generated + if value.ShapeType() == TopAbs_FACE and cls._is_result_topology_member(result, value) + ) + patch_history_complete = ( + bool(patch_faces) + and len(patch_faces) == len(generated) + and all(value.ShapeType() == TopAbs_FACE for value in generated) + ) + if not patch_faces: + continue + for source_face in source_faces: + try: + modified = tuple(operation.Modified(source_face)) + except (AttributeError, TypeError, ValueError): + modified = () + final_faces = cls._dedupe_topology_values( + value for value in modified + if value.ShapeType() == TopAbs_FACE and cls._is_result_topology_member(result, value) + ) + face_history_complete = ( + bool(final_faces) + and len(final_faces) == len(modified) + and all(value.ShapeType() == TopAbs_FACE for value in modified) + ) + if not final_faces: + continue + final_face = final_faces[0] if len(final_faces) == 1 else None + final_face_edges = cls._topology_members(final_face, TopAbs_EDGE) if final_face is not None else () + for patch_face in patch_faces: + shared = cls._dedupe_topology_values( + edge for edge in cls._topology_members(patch_face, TopAbs_EDGE) + if any(edge.IsSame(face_edge) for face_edge in final_face_edges) + and cls._is_result_topology_member(result, edge) + ) + complete = ( + patch_history_complete + and len(patch_faces) == 1 + and face_history_complete + and final_face is not None + and len(shared) == 1 + ) + relations.append(TopologyBlendRelation( + source_edge_value=source_edge, + source_face_value=source_face, + patch_face_value=patch_face, + blend_into_result_value=final_face, + result_values=shared if complete else (), + coverage="complete" if complete else "partial", + status="proven" if complete else "unknown", + )) + return tuple(relations) + + @staticmethod + def _topology_members(shape: Any, shape_type: Any) -> tuple[Any, ...]: + """Collect exact OCC subshape handles without converting their geometry.""" + explorer = TopExp_Explorer(shape, shape_type) + values: list[Any] = [] + while explorer.More(): + values.append(explorer.Current()) + explorer.Next() + return tuple(values) + @staticmethod def _builder_topology_delta(operation: Any, sources: Iterable[Any], operation_name: str) -> TopologyDelta: """Translate OCC builder history into adapter-neutral opaque relations.""" source_bodies = tuple(sources) relations: list[TopologyDeltaRelation] = [] + kind_by_shape_type = { + TopAbs_FACE: "face", + TopAbs_EDGE: "edge", + TopAbs_VERTEX: "vertex", + } + try: + result_shape = operation.Shape() + except (AttributeError, TypeError, ValueError): + result_shape = None source_faces: list[tuple[int, Any]] = [] for source_index, source in enumerate(source_bodies): source_faces.extend((source_index, face.wrapped) for face in source.faces()) @@ -1723,7 +2346,36 @@ class Build123dGeometryAdapter: # 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)) + # Dress-up builders commonly generate a FACE from an + # input EDGE. Retain that exact cross-kind fact + # rather than labelling a face as an edge and losing + # it during final-snapshot registration. This is + # still only builder history: a future BLEND_EDGE + # consumer must separately prove an exact boundary + # incidence between this patch face and its source + # face, not select one of its edges by geometry. + generated_by_kind: dict[str, list[Any]] = {} + for value in generated: + result_kind = kind_by_shape_type.get(value.ShapeType()) + if result_kind is None: + continue + generated_by_kind.setdefault(result_kind, []).append(value) + for result_kind, values in generated_by_kind.items(): + final_values = tuple( + value for value in values + if result_shape is not None + and Build123dGeometryAdapter._is_result_topology_member(result_shape, value) + ) + complete = bool(final_values) and len(final_values) == len(values) + relations.append(TopologyDeltaRelation( + "generated", kind, source_value, + final_values if complete else (), + source_kind=kind, + result_kind=result_kind, + derivation="boundary", + coverage="complete" if complete else "partial", + status="proven" if complete else "unknown", + )) section_values: tuple[Any, ...] = () section_relations: list[TopologySectionRelation] = [] section_edges = getattr(operation, "SectionEdges", None) @@ -1766,6 +2418,9 @@ class Build123dGeometryAdapter: relations=tuple(relations), section_values=unqualified_section_values, section_relations=tuple(section_relations), + blend_relations=Build123dGeometryAdapter._builder_blend_relations( + operation, source_bodies, result_shape, operation_name, + ), ) @staticmethod @@ -1932,6 +2587,49 @@ class Build123dGeometryAdapter: # 对指定边以给定半径做圆角。 return body.fillet(radius_mm, list(edges)) + @staticmethod + def _single_member_dressup_with_topology_delta( + body: Any, + selected: list[Edge], + operation_name: str, + configure_builder: Any, + ) -> tuple[Any, TopologyDelta] | None: + """Run one dress-up builder against one exact member of a Compound. + + ``BRepFilletAPI`` history applies to its input solid, not to the + aggregate Compound. When every selected edge belongs to exactly one + member, retain that builder's history and carry unrelated members + through unchanged. An edge crossing members, ambiguous membership, + multiple selected members, or any builder failure remains on the + established history-free fallback path. + """ + solids = Build123dGeometryAdapter.body_solids(body) + if len(solids) <= 1 or not selected: + return None + target_indexes: set[int] = set() + for edge in selected: + matches = [ + index for index, solid in enumerate(solids) + if any(edge.is_same(candidate) for candidate in solid.edges()) + ] + if len(matches) != 1: + return None + target_indexes.add(matches[0]) + if len(target_indexes) != 1: + return None + target_index = next(iter(target_indexes)) + target = solids[target_index] + builder = configure_builder(target, selected) + builder.Build() + if not builder.IsDone(): + return None + changed = Solid(builder.Shape()) + if not changed.is_valid: + return None + members = [changed if index == target_index else solid for index, solid in enumerate(solids)] + result = members[0] if len(members) == 1 else Compound(members) + return result, Build123dGeometryAdapter._builder_topology_delta(builder, (target,), operation_name) + @staticmethod def fillet_with_topology_delta( body: Any, radius_mm: float, edges: Iterable[Edge], @@ -1939,6 +2637,17 @@ class Build123dGeometryAdapter: """Fillet a single body and retain its direct OCC builder history.""" selected = list(edges) if len(Build123dGeometryAdapter.body_solids(body)) != 1: + def configure_member_builder(member: Solid, member_edges: list[Edge]) -> Any: + builder = BRepFilletAPI_MakeFillet(member.wrapped) + for edge in member_edges: + builder.Add(radius_mm, edge.wrapped) + return builder + + member_result = Build123dGeometryAdapter._single_member_dressup_with_topology_delta( + body, selected, "fillet", configure_member_builder, + ) + if member_result is not None: + return member_result return Build123dGeometryAdapter.fillet(body, radius_mm, selected), None builder = BRepFilletAPI_MakeFillet(body.wrapped) for edge in selected: @@ -2030,6 +2739,18 @@ class Build123dGeometryAdapter: """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: + if distance_2_mm is None and face is None: + def configure_member_builder(member: Solid, member_edges: list[Edge]) -> Any: + builder = BRepFilletAPI_MakeChamfer(member.wrapped) + for edge in member_edges: + builder.Add(distance_mm, edge.wrapped) + return builder + + member_result = Build123dGeometryAdapter._single_member_dressup_with_topology_delta( + body, selected, "chamfer", configure_member_builder, + ) + if member_result is not None: + return member_result return Build123dGeometryAdapter.chamfer(body, distance_mm, distance_2_mm, selected, face=face), None builder = BRepFilletAPI_MakeChamfer(body.wrapped) for edge in selected: @@ -2246,7 +2967,15 @@ class Build123dGeometryAdapter: } if transition is not None: sweep_options["transition"] = transition - result = Solid.sweep(section, spine, **sweep_options) + try: + result = Solid.sweep(section, spine, **sweep_options) + except Exception as error: + # Some build123d/OCC sweep failures surface as a bare + # ``AssertionError`` (whose text is empty), notably for certain + # hollow profiles along segmented wires. Translate that kernel + # boundary into the same attributable feature failure contract as + # the direct pipe-shell path. + raise ValueError("OCC sweep operation raised while building the native sweep") from error if make_solid and (not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9): # OCC 在截面与路径不构成有效实体 sweep 时可能返回零体积形状, # 而不报告 Build() 失败。该结果不能作为 CADFS 的 solid body 继续传播。 @@ -2281,14 +3010,21 @@ class Build123dGeometryAdapter: 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() + try: + 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() + except Exception as error: + raise ValueError("OCC sweep operation raised while preparing or building the pipe shell") from error if not builder.IsDone(): raise ValueError("OCC sweep operation did not complete") - if not builder.MakeSolid(): + try: + made_solid = builder.MakeSolid() + except Exception as error: + raise ValueError("OCC sweep operation raised while converting the pipe shell to a solid") from error + if not made_solid: 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: @@ -2299,6 +3035,81 @@ class Build123dGeometryAdapter: relations.append(TopologyDeltaRelation( "generated", "face", section.wrapped, (output,), output_role=role, )) + # PipeShell exposes cap faces but, unlike MakePrism, has no + # FirstShape/LastShape(source_edge) overload. A source edge can + # therefore receive a cap-edge relation only in the one-edge profile + # case: the builder-proven cap face has one exact final boundary edge. + # Multi-edge/inner-wire profiles deliberately emit no inferred edge + # correspondence. + profile_edges = list(section.edges()) + if len(profile_edges) == 1: + for output, role in ((builder.FirstShape(), "sweep.start"), (builder.LastShape(), "sweep.end")): + if output.IsNull() or output.ShapeType() != TopAbs_FACE: + continue + cap_edges = list(Face(output).edges()) + if len(cap_edges) != 1: + continue + cap_edge = cap_edges[0].wrapped + is_final_edge = Build123dGeometryAdapter._is_result_topology_member(result.wrapped, cap_edge) + relations.append(TopologyDeltaRelation( + "generated", "edge", profile_edges[0].wrapped, + (cap_edge,) if is_final_edge else (), + output_role=role, + source_kind="edge", + result_kind="edge", + derivation="boundary", + coverage="complete" if is_final_edge else "partial", + status="proven" if is_final_edge else "unknown", + )) + for source_edge in profile_edges: + # Unlike the cap-edge form, PipeShell does expose Generated(edge). + # Keep every final face returned for this exact source handle; + # omitted/deleted builder values remain explicit partial evidence. + try: + generated = tuple(builder.Generated(source_edge.wrapped)) + except (AttributeError, TypeError, ValueError): + generated = () + side_faces = tuple( + shape for shape in generated + if shape.ShapeType() == TopAbs_FACE + and Build123dGeometryAdapter._is_result_topology_member(result.wrapped, shape) + ) + complete = bool(generated) and len(side_faces) == len(generated) + relations.append(TopologyDeltaRelation( + "generated", "edge", source_edge.wrapped, side_faces, + source_kind="edge", + result_kind="face", + derivation="boundary", + coverage="complete" if complete else "partial", + status="proven" if complete else "unknown", + )) + source_vertices: list[Any] = [] + for source_edge in profile_edges: + for source_vertex in source_edge.vertices(): + if any(source_vertex.wrapped.IsSame(existing) for existing in source_vertices): + continue + source_vertices.append(source_vertex.wrapped) + for source_vertex in source_vertices: + # PipeShell exposes the exact generated path edge for a profile + # vertex. Keep only builder handles that remain in the final + # solid; a missing/split/deleted output is explicit partial + # evidence rather than an inferred edge correspondence. + try: + generated = tuple(builder.Generated(source_vertex)) + except (AttributeError, TypeError, ValueError): + generated = () + swept_edges = tuple( + shape for shape in generated + if shape.ShapeType() == TopAbs_EDGE + and Build123dGeometryAdapter._is_result_topology_member(result.wrapped, shape) + ) + complete = bool(generated) and len(swept_edges) == len(generated) + relations.append(TopologyDeltaRelation( + "generated", "vertex", source_vertex, swept_edges, + source_kind="vertex", result_kind="edge", derivation="boundary", + coverage="complete" if complete else "partial", + status="proven" if complete else "unknown", + )) return result, TopologyDelta(operation="sweep", relations=tuple(relations)) @staticmethod @@ -2341,6 +3152,66 @@ class Build123dGeometryAdapter: return Edge.make_line(vertices[0], vertices[-1]) return Edge.make_spline(vertices, tangents=tangents, parameters=parameters, scale=False) + @staticmethod + def sweep_arc_path( + start: Vector3, + end: Vector3, + center: Vector3, + normal: Vector3, + *, + radius_mm: float, + clockwise: bool, + ) -> Edge: + """Build one explicit directed source arc without approximating it.""" + return Build123dGeometryAdapter._wire_edges([{ + "type": "arc", + "start_mm": list(start), + "end_mm": list(end), + "center_mm": list(center), + "normal": list(normal), + "radius_mm": float(radius_mm), + "clockwise": clockwise, + }])[0] + + @staticmethod + def sweep_circle_path( + center: Vector3, + x_dir: Vector3, + normal: Vector3, + *, + radius_mm: float, + ) -> Wire: + """Build one explicit closed source circle as a one-edge wire. + + PipeShell treats a bare closed edge as a degenerate spine. Retaining + the closed wire is part of the CDSL path contract and avoids inventing + an endpoint or a cap for a FeatureScript ``skCircle`` path. + """ + radius = float(radius_mm) + if not math.isfinite(radius) or radius <= 0: + raise ValueError("sweep circle path requires a positive radius") + plane = Plane(origin=_vector(center), x_dir=_vector(x_dir), z_dir=_vector(normal)) + return Wire(Edge.make_circle(radius, plane)) + + @staticmethod + def sweep_path_segments(segments: Iterable[dict[str, Any]]) -> Wire: + """Build one explicit open path wire from source-ordered curve segments. + + This does not infer or repair a path from an output body. The CDSL + lowerer has already proved the source wire's ordered, non-branching + endpoints; this builder merely sends those exact line/arc/B-spline + definitions to OCC's wire construction API. + """ + definitions = list(segments) + if len(definitions) < 2: + raise ValueError("sweep segmented path requires at least two segments") + builder = BRepBuilderAPI_MakeWire() + for edge in Build123dGeometryAdapter._wire_edges(definitions): + builder.Add(edge.wrapped) + if not builder.IsDone(): + raise ValueError("sweep segmented path is not a connected wire") + return Wire(builder.Wire()) + @staticmethod def helix_path( radius_mm: float, diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index 6c3619f0..66c9b3a1 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -8,6 +8,7 @@ state from registered atomic executors, profile support, and complete inputs. from __future__ import annotations import json +import math from dataclasses import dataclass from pathlib import Path from typing import Any, Iterable @@ -17,7 +18,23 @@ from .runtime_types import ( pattern_instance_member_id, transform_copy_member_id, ) from .operation_contracts import materialized_feature_contracts -from .selector_capabilities import is_direct_blind_extrude_cap_output_role +from .selector_capabilities import ( + is_direct_blind_extrude_cap_output_role, + is_immediate_retained_source_prism_swept_face_extent, + is_direct_prism_shell_offset_edge_tdd, + is_direct_prism_shell_offset_edge_vertex, + is_initial_direct_loft_cap_output_role, + is_initial_direct_sweep_cap_edge, + is_initial_direct_sweep_cap_output_role, + is_initial_direct_sweep_swept_edge, + is_initial_direct_sweep_swept_face, + is_initial_two_sided_circle_shell_cap_output_role, + is_planar_imprint_extrude_cap_output_role, + is_primary_add_dressup_cap_output_role, + is_primary_add_shell_cap_output_role, + is_primary_add_up_to_surface_cap_output_role, + is_symmetric_direct_prism_two_sided_up_to_surface_cap_pair, +) _SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_") @@ -61,7 +78,7 @@ _OPEN_PROFILE_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if _PRIMARY_ATOMICS = frozenset({ "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", + "revolve_add", "revolve_cut", "revolve_surface", "sweep_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", }) _ACTIVE_BODY_REQUIRED = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["requires_active_body"]) @@ -74,6 +91,148 @@ _BODY_MUTATING_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() i _REPLAYABLE_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["replayable"]) +def _sweep_segmented_path_error(segments: list[Any]) -> tuple[str, str] | None: + """Validate the self-contained, ordered open-wire CDSL path contract.""" + if len(segments) < 2 or not all(isinstance(item, dict) for item in segments): + return "invalid_sweep_path", "Sweep segmented path requires at least two captured curve segments" + + def point(value: Any) -> tuple[float, float] | None: + if not isinstance(value, list) or len(value) != 2: + return None + try: + result = (float(value[0]), float(value[1])) + except (TypeError, ValueError): + return None + return result if all(math.isfinite(component) for component in result) else None + + def same(left: tuple[float, float], right: tuple[float, float]) -> bool: + return math.dist(left, right) <= 1e-8 + + endpoints: list[tuple[tuple[float, float], tuple[float, float]]] = [] + for item in segments: + kind = item.get("type") + if kind not in {"line", "arc", "bspline"}: + return "unsupported_sweep_path", "Sweep segmented path supports only line, arc, or B-spline segments" + if kind == "bspline": + points = item.get("points") + if not isinstance(points, list) or len(points) < 2: + return "invalid_sweep_path", "Sweep B-spline path requires at least two interpolation points" + converted = [point(value) for value in points] + if any(value is None for value in converted): + return "invalid_sweep_path", "Sweep B-spline path requires two-dimensional interpolation points" + if item.get("periodic"): + return "invalid_sweep_path", "Sweep segmented path requires open, non-periodic B-spline segments" + start, end = converted[0], converted[-1] + has_start_tangent = item.get("start_tangent") is not None + has_end_tangent = item.get("end_tangent") is not None + if has_start_tangent != has_end_tangent: + return "invalid_sweep_path", "Sweep B-spline path requires both endpoint tangents when either is provided" + if has_start_tangent and (point(item.get("start_tangent")) is None or point(item.get("end_tangent")) is None): + return "invalid_sweep_path", "Sweep B-spline path tangents require two finite coordinates" + if len(converted) == 2 and not has_start_tangent: + return "invalid_sweep_path", "A two-point B-spline sweep path requires both endpoint tangents" + else: + start, end = point(item.get("start")), point(item.get("end")) + if start is None or end is None: + return "invalid_sweep_path", "Sweep segmented line and arc paths require two-dimensional start and end points" + if kind == "arc" and ( + point(item.get("center")) is None + or not isinstance(item.get("radius_mm"), (int, float)) + or not math.isfinite(float(item["radius_mm"])) + or float(item["radius_mm"]) <= 0 + ): + return "invalid_sweep_path", "Sweep arc path requires a finite center and positive source radius" + if start is None or end is None or same(start, end): + return "invalid_sweep_path", "Sweep segmented path contains a degenerate curve segment" + endpoints.append((start, end)) + + if any(not same(left[1], right[0]) for left, right in zip(endpoints, endpoints[1:])): + return "invalid_sweep_path", "Sweep segmented path must be source-ordered and connected" + vertices = [endpoints[0][0], *(end for _start, end in endpoints)] + if any( + later > index + 1 and same(left, right) + for index, left in enumerate(vertices) + for later, right in enumerate(vertices[index + 1:], start=index + 1) + ): + return "invalid_sweep_path", "Sweep segmented path must be an open non-branching wire" + return None + + +def _spatial_sweep_segmented_path_error(segments: list[Any]) -> tuple[str, str] | None: + """Validate ordered global sweep curves from multiple source sketch frames.""" + if len(segments) < 2 or not all(isinstance(item, dict) for item in segments): + return "invalid_sweep_path", "Sweep spatial path requires at least two captured curve segments" + + def point(value: Any) -> tuple[float, float, float] | None: + if not isinstance(value, list) or len(value) != 3: + return None + try: + result = tuple(float(component) for component in value) + except (TypeError, ValueError): + return None + return result if all(math.isfinite(component) for component in result) else None + + def same(left: tuple[float, float, float], right: tuple[float, float, float]) -> bool: + return math.dist(left, right) <= 1e-8 + + endpoints: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] + source_entities: set[tuple[str, str]] = set() + for item in segments: + if not isinstance(item.get("source_sketch_id"), str) or not item["source_sketch_id"] or not isinstance(item.get("source_entity_id"), str) or not item["source_entity_id"]: + return "invalid_sweep_path", "Sweep spatial path requires explicit source sketch and entity identities" + source_entity = (item["source_sketch_id"], item["source_entity_id"]) + if source_entity in source_entities: + return "invalid_sweep_path", "Sweep spatial path must not repeat one source sketch entity" + source_entities.add(source_entity) + kind = item.get("type") + if kind not in {"line", "arc", "bspline"}: + return "unsupported_sweep_path", "Sweep spatial path supports only line, arc, or B-spline segments" + if kind == "bspline": + points = item.get("points_mm") + if not isinstance(points, list) or len(points) < 2: + return "invalid_sweep_path", "Sweep spatial B-spline path requires at least two interpolation points" + converted = [point(value) for value in points] + if any(value is None for value in converted): + return "invalid_sweep_path", "Sweep spatial B-spline path requires three-dimensional interpolation points" + if item.get("periodic"): + return "invalid_sweep_path", "Sweep spatial path requires open, non-periodic B-spline segments" + start, end = converted[0], converted[-1] + has_start_tangent = item.get("start_tangent_mm") is not None + has_end_tangent = item.get("end_tangent_mm") is not None + if has_start_tangent != has_end_tangent: + return "invalid_sweep_path", "Sweep spatial B-spline path requires both endpoint tangents when either is provided" + if has_start_tangent and (point(item.get("start_tangent_mm")) is None or point(item.get("end_tangent_mm")) is None): + return "invalid_sweep_path", "Sweep spatial B-spline path tangents require three finite coordinates" + if len(converted) == 2 and not has_start_tangent: + return "invalid_sweep_path", "A two-point spatial B-spline sweep path requires both endpoint tangents" + else: + start, end = point(item.get("start_mm")), point(item.get("end_mm")) + if start is None or end is None: + return "invalid_sweep_path", "Sweep spatial line and arc paths require three-dimensional start and end points" + if kind == "arc" and ( + point(item.get("center_mm")) is None + or point(item.get("normal")) is None + or not isinstance(item.get("radius_mm"), (int, float)) + or not math.isfinite(float(item["radius_mm"])) + or float(item["radius_mm"]) <= 0 + ): + return "invalid_sweep_path", "Sweep spatial arc path requires a finite center, normal, and positive source radius" + if start is None or end is None or same(start, end): + return "invalid_sweep_path", "Sweep spatial path contains a degenerate curve segment" + endpoints.append((start, end)) + + if any(not same(left[1], right[0]) for left, right in zip(endpoints, endpoints[1:])): + return "invalid_sweep_path", "Sweep spatial path must be source-ordered and connected" + vertices = [endpoints[0][0], *(end for _start, end in endpoints)] + if any( + later > index + 1 and same(left, right) + for index, left in enumerate(vertices) + for later, right in enumerate(vertices[index + 1:], start=index + 1) + ): + return "invalid_sweep_path", "Sweep spatial path must be an open non-branching wire" + return None + + def _mappings(value: Any): """Yield nested feature mappings for capability-only contract checks.""" if isinstance(value, dict): @@ -85,6 +244,17 @@ def _mappings(value: Any): yield from _mappings(child) +def _query_selector_leaves(selector: dict[str, Any]): + """Yield only executable leaves of a recursive QUERY_SET selector.""" + operands = selector.get("query_operands") + if not isinstance(operands, list) or not operands: + yield selector + return + for operand in operands: + if isinstance(operand, dict): + yield from _query_selector_leaves(operand) + + def _contract_selectors(node: FeaturePlanNode, contract: dict[str, Any] | None) -> list[dict[str, Any]]: """Read only the selector slot declared by the operation contract.""" slot = str((contract or {}).get("selector_slot") or "") @@ -105,7 +275,7 @@ def _up_to_surface_output_role_reference(node: FeaturePlanNode, contract: dict[s not isinstance(policy, dict) or policy.get("end_condition_type") != "up_to_surface" or policy.get("token_kind") != "face" - or policy.get("output_role_contract") != "direct_blind_extrude_cap" + or policy.get("output_role_contract") != "direct_or_primary_add_blind_extrude_cap" or policy.get("requires_immediate_owner") is not True ): return None @@ -116,6 +286,63 @@ def _up_to_surface_output_role_reference(node: FeaturePlanNode, contract: dict[s return None +def _retained_source_prism_swept_face_extent_reference(node: FeaturePlanNode) -> dict[str, Any] | None: + """Return the one non-output-role wall selector admitted for an extent.""" + end_condition = node.params.get("end_condition") or {} + reference = end_condition.get("reference") if isinstance(end_condition, dict) else None + intent = reference.get("selector_intent") if isinstance(reference, dict) else None + if ( + end_condition.get("type") == "up_to_surface" + and isinstance(intent, dict) + and intent.get("consumer_contract") == "immediate_retained_source_prism_swept_face_up_to_surface" + ): + return reference + return None + + +def _two_sided_up_to_surface_cap_pair_references(node: FeaturePlanNode, contract: dict[str, Any] | None) -> tuple[dict[str, Any], dict[str, Any]] | None: + """Return the complete forward/reverse pair for the symmetric CAP contract.""" + policies = ((contract or {}).get("nested_selector_policies") or {}) + required = "symmetric_direct_prism_two_sided_up_to_surface_cap_pair" + forward_policy = policies.get("params.end_condition.reference") + reverse_policy = policies.get("params.reverse_end_condition.reference") + if not ( + isinstance(forward_policy, dict) + and isinstance(reverse_policy, dict) + and forward_policy.get("output_role_contract") == required + and reverse_policy.get("output_role_contract") == required + and forward_policy.get("requires_immediate_owner") is True + and reverse_policy.get("requires_immediate_owner") is True + ): + return None + forward = node.params.get("end_condition") or {} + reverse = node.params.get("reverse_end_condition") or {} + forward_reference = forward.get("reference") if isinstance(forward, dict) else None + reverse_reference = reverse.get("reference") if isinstance(reverse, dict) else None + if ( + forward.get("type") == "up_to_surface" + and reverse.get("type") == "up_to_surface" + and isinstance(forward_reference, dict) + and isinstance(reverse_reference, dict) + and forward_reference.get("output_role") is not None + and reverse_reference.get("output_role") is not None + ): + return forward_reference, reverse_reference + return None + + +def _transform_copy_member_sources(params: dict[str, Any], parameter: str) -> set[str]: + """Project source-qualified multi-body COPY refs to body-member keys.""" + return { + transform_copy_member_id( + str(reference.get("transform_feature_id") or ""), + str(reference.get("source_feature_id") or ""), + ) + for reference in params.get(parameter) or () + if isinstance(reference, dict) + } + + 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 ()} @@ -128,14 +355,7 @@ def _transform_member_sources(params: dict[str, Any]) -> set[str]: 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) - ) + source_ids.update(_transform_copy_member_sources(params, "transform_copy_refs")) return source_ids @@ -172,8 +392,10 @@ def _next_body_graph( 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")) + target_ids.update(_transform_copy_member_sources(node.params, "target_transform_copy_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")) + tool_ids.update(_transform_copy_member_sources(node.params, "tool_transform_copy_refs")) next_members = members - target_ids - tool_ids next_members.add(feature_id) if bool(node.params.get("keep_tools")): @@ -370,7 +592,7 @@ def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]: atomic_id = str(feature.get("atomic_id") or "") if feature.get("sketch_id") is not None and (contracts.get(atomic_id) or {}).get("requires_sketch"): required.add(str(feature["sketch_id"])) - if atomic_id in {"loft_add", "loft_add_with_cap_face"}: + if atomic_id in {"loft_add", "loft_add_with_cap_face", "loft_surface"}: for sketch_id in (feature.get("params") or {}).get("profile_sketch_ids") or (): required.add(str(sketch_id)) return frozenset(required) @@ -514,7 +736,14 @@ class CapabilityAnalyzer: )) if profile_type not in self.profile_types: blockers.append(self._blocker(node.feature_id, "unsupported_profile", "The current runtime cannot resolve the sketch profile", profile_type=profile_type)) - elif not resolution_error and not _has_closed_region(sketches[node.sketch_id]): + elif ( + not resolution_error + and not _has_closed_region(sketches[node.sketch_id]) + and not ( + node.atomic_id == "extrude_surface" + and bool(sketches[node.sketch_id].get("surface_wires_mm")) + ) + ): blockers.append(self._blocker( node.feature_id, "profile_no_closed_region", "The resolved sketch contains no closed profile region", @@ -531,7 +760,7 @@ class CapabilityAnalyzer: sketch_id=node.sketch_id, atomic_id=node.atomic_id, )) - if node.atomic_id in {"loft_add", "loft_add_with_cap_face"}: + if node.atomic_id in {"loft_add", "loft_add_with_cap_face", "loft_surface"}: profile_ids = params.get("profile_sketch_ids") minimum_profiles = 1 if node.atomic_id == "loft_add_with_cap_face" else 2 if not isinstance(profile_ids, list) or len(profile_ids) < minimum_profiles: @@ -582,10 +811,27 @@ class CapabilityAnalyzer: )) contract_selectors = _contract_selectors(node, contract) extent_selector = _up_to_surface_output_role_reference(node, contract) + two_sided_extent_pair = _two_sided_up_to_surface_cap_pair_references(node, contract) output_role_selectors = [ *contract_selectors, *([extent_selector] if extent_selector is not None else []), + *(list(two_sided_extent_pair) if two_sided_extent_pair is not None else []), ] + retained_source_swept_face_extent = _retained_source_prism_swept_face_extent_reference(node) + if retained_source_swept_face_extent is not None: + required.append("selector:retained_source_prism_swept_face_extent") + if ( + previous_node is None + or retained_source_swept_face_extent.get("owner_feature_id") != previous_node.feature_id + or not is_immediate_retained_source_prism_swept_face_extent( + retained_source_swept_face_extent, previous_node.source_feature, sketches, + ) + ): + blockers.append(self._blocker( + node.feature_id, + "unsupported_retained_source_prism_swept_face_extent", + "up_to_surface SWEPT_FACE requires the immediately preceding direct new_body prism with one exact retained source edge", + )) contract_selector_ids = {id(selector) for selector in output_role_selectors} selector_intent_ids = { id(selector.get("selector_intent")) @@ -609,16 +855,10 @@ class CapabilityAnalyzer: continue required.append("selector:feature_output_role") is_extent_reference = selector is extent_selector - if ( - not is_extent_reference - and (contract is None or not contract.get("selector_slot") 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, - )) + is_two_sided_extent_reference = ( + two_sided_extent_pair is not None + and any(selector is reference for reference in two_sided_extent_pair) + ) if selector.get("kind") != "face" or not isinstance(selector.get("owner_feature_id"), str): blockers.append(self._blocker( node.feature_id, @@ -642,30 +882,93 @@ class CapabilityAnalyzer: )) role_source = selector.get("output_role_source") selector_intent = selector.get("selector_intent") + is_retained_shell_cap_offset_profile = ( + node.atomic_id == "extrude_from_face" + and isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "OFFSET_FACE" + and selector_intent.get("consumer_contract") + == "shell_retained_direct_prism_cap_offset_face_profile" + and selector.get("output_role") == "shell.offset_face" + and previous_node is not None + and selector.get("owner_feature_id") == previous_node.feature_id + ) is_shell_cap_face_output_role = ( node.atomic_id == "shell" and isinstance(selector_intent, dict) and selector_intent.get("query_family") == "CAP_FACE" - and selector.get("output_role") in {"extrude.start", "extrude.end"} + and selector.get("output_role") in { + "extrude.start", "extrude.end", "loft.start", "loft.end", "sweep.start", "sweep.end", + } ) - if is_extent_reference and ( + is_imprint_dressup_cap_output_role = ( + node.atomic_id in {"fillet", "chamfer"} + and isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "CAP_FACE" + and selector.get("output_role") in {"extrude.start", "extrude.end"} + and selector_intent.get("consumer_contract") != "primary_add_dressup_union_continuation" + ) + is_primary_add_dressup_cap_role = ( + node.atomic_id in {"fillet", "chamfer"} + and isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "CAP_FACE" + and selector.get("output_role") in {"extrude.start", "extrude.end"} + and is_primary_add_dressup_cap_output_role( + selector, previous_node.source_feature if previous_node is not None else None, sketches, + ) + ) + if ( + not is_extent_reference + and not is_two_sided_extent_reference + and not is_imprint_dressup_cap_output_role + and not is_primary_add_dressup_cap_role + and (contract is None or not contract.get("selector_slot") 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 is_two_sided_extent_reference and ( previous_node is None or selector.get("owner_feature_id") != previous_node.feature_id - or not is_direct_blind_extrude_cap_output_role( - selector, previous_node.source_feature, sketches, + or not is_symmetric_direct_prism_two_sided_up_to_surface_cap_pair( + two_sided_extent_pair[0], two_sided_extent_pair[1], previous_node.source_feature, sketches, + ) + ): + blockers.append(self._blocker( + node.feature_id, + "unsupported_two_sided_extent_output_role_pair", + "two-sided up_to_surface CAP roles require the immediately preceding direct symmetric new_body prism pair", + selector_index=selector_index, + )) + elif is_extent_reference and ( + previous_node is None + or selector.get("owner_feature_id") != previous_node.feature_id + or not ( + is_direct_blind_extrude_cap_output_role( + selector, previous_node.source_feature, sketches, + ) + or is_primary_add_up_to_surface_cap_output_role( + selector, previous_node.source_feature, sketches, + ) ) ): blockers.append(self._blocker( node.feature_id, "unsupported_extent_output_role_selector", - "up_to_surface output roles require the immediately preceding direct new_body blind extrusion cap", + "up_to_surface output roles require the immediately preceding direct new_body or primary ADD blind extrusion cap", selector_index=selector_index, )) elif is_shell_cap_face_output_role and ( previous_node is None or selector.get("owner_feature_id") != previous_node.feature_id - or not is_direct_blind_extrude_cap_output_role( - selector, previous_node.source_feature, sketches, + or not ( + is_direct_blind_extrude_cap_output_role(selector, previous_node.source_feature, sketches) + or is_primary_add_shell_cap_output_role(selector, previous_node.source_feature, sketches) + or is_initial_direct_loft_cap_output_role(selector, previous_node.source_feature, sketches) + or is_initial_direct_sweep_cap_output_role(selector, previous_node.source_feature, sketches) + or is_initial_two_sided_circle_shell_cap_output_role(selector, previous_node.source_feature, sketches) ) ): blockers.append(self._blocker( @@ -674,6 +977,139 @@ class CapabilityAnalyzer: "CAP_FACE output roles require the immediately preceding direct new_body blind extrusion cap", selector_index=selector_index, )) + elif is_primary_add_dressup_cap_role and ( + previous_node is None + or selector.get("owner_feature_id") != previous_node.feature_id + ): + blockers.append(self._blocker( + node.feature_id, + "unsupported_cap_face_output_role_selector", + "primary ADD CAP_FACE output roles require the immediately preceding blind extrusion", + selector_index=selector_index, + )) + elif is_imprint_dressup_cap_output_role and ( + previous_node is None + or selector.get("owner_feature_id") != previous_node.feature_id + or not is_planar_imprint_extrude_cap_output_role( + selector, previous_node.source_feature, sketches, + ) + ): + blockers.append(self._blocker( + node.feature_id, + "unsupported_cap_face_output_role_selector", + "IMPRINT CAP_FACE output roles require the immediately preceding complete new_body blind prism", + selector_index=selector_index, + )) + elif is_primary_add_dressup_cap_role or is_imprint_dressup_cap_output_role: + # The normal fillet/chamfer contract consumes edges, but a + # complete CAP_FACE set is intentionally expanded into its + # physical boundary edges by the shared dress-up executor. + pass + if ( + isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "OFFSET_EDGE" + and selector_intent.get("consumer_contract") == "direct_prism_shell_offset_edge_tdd" + and ( + node.atomic_id not in {"fillet", "chamfer"} + or previous_node is None + or not isinstance(selector_intent.get("disambiguation"), dict) + or selector_intent["disambiguation"].get("shell_feature_id") != previous_node.feature_id + or not is_direct_prism_shell_offset_edge_tdd( + selector, + previous_node.source_feature, + nodes_by_id.get(str(selector.get("owner_feature_id") or "")).source_feature + if nodes_by_id.get(str(selector.get("owner_feature_id") or "")) is not None else None, + sketches, + ) + ) + ): + blockers.append(self._blocker( + node.feature_id, + "unsupported_offset_edge_tdd_selector", + "OFFSET_EDGE TDD requires the immediately preceding direct-prism shell retained-cap continuation", + selector_index=selector_index, + )) + if ( + isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "OFFSET_EDGE" + and selector_intent.get("consumer_contract") == "direct_prism_shell_offset_edge_vertex" + and ( + node.atomic_id not in {"fillet", "chamfer"} + or previous_node is None + or not isinstance(selector_intent.get("disambiguation"), dict) + or selector_intent["disambiguation"].get("shell_feature_id") != previous_node.feature_id + or not is_direct_prism_shell_offset_edge_vertex( + selector, + previous_node.source_feature, + nodes_by_id.get(str(selector.get("owner_feature_id") or "")).source_feature + if nodes_by_id.get(str(selector.get("owner_feature_id") or "")) is not None else None, + sketches, + ) + ) + ): + blockers.append(self._blocker( + node.feature_id, + "unsupported_offset_edge_vertex_selector", + "OFFSET_EDGE vertex requires the immediately preceding direct-prism shell continuation", + selector_index=selector_index, + )) + if ( + isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "CAP_EDGE" + and selector_intent.get("lineage_role") in {"sweep.start", "sweep.end"} + and ( + node.atomic_id not in {"fillet", "chamfer"} + or previous_node is None + or selector.get("owner_feature_id") != previous_node.feature_id + or not is_initial_direct_sweep_cap_edge( + selector, previous_node.source_feature, sketches, + ) + ) + ): + blockers.append(self._blocker( + node.feature_id, + "unsupported_sweep_cap_edge_selector", + "CAP_EDGE sweep roles require the immediately preceding direct new_body one-edge sweep", + selector_index=selector_index, + )) + if ( + isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "SWEPT_FACE" + and (selector_intent.get("disambiguation") or {}).get("type") == "sweep_profile_path" + and ( + node.atomic_id not in {"fillet", "chamfer"} + or previous_node is None + or selector.get("owner_feature_id") != previous_node.feature_id + or not is_initial_direct_sweep_swept_face( + selector, previous_node.source_feature, sketches, + ) + ) + ): + blockers.append(self._blocker( + node.feature_id, + "unsupported_sweep_swept_face_selector", + "SWEPT_FACE sweep relations require the immediately preceding direct new_body analytic-profile sweep", + selector_index=selector_index, + )) + if ( + isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "SWEPT_EDGE" + and (selector_intent.get("disambiguation") or {}).get("type") == "sweep_profile_vertex_path" + and ( + node.atomic_id not in {"fillet", "chamfer"} + or previous_node is None + or selector.get("owner_feature_id") != previous_node.feature_id + or not is_initial_direct_sweep_swept_edge( + selector, previous_node.source_feature, sketches, + ) + ) + ): + blockers.append(self._blocker( + node.feature_id, + "unsupported_sweep_swept_edge_selector", + "SWEPT_EDGE sweep relations require the immediately preceding direct new_body analytic-profile sweep", + selector_index=selector_index, + )) 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 @@ -685,10 +1121,13 @@ class CapabilityAnalyzer: "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": + elif ( + selector.get("output_role") != "shell.offset_face" + or not (node.atomic_id == "shell" or is_retained_shell_cap_offset_profile) + ): blockers.append(self._blocker( node.feature_id, "unsupported_output_role_source", - "Output role sources are currently supported only for shell.offset_face", + "Output role sources are currently supported only for a shell offset-face contract", selector_index=selector_index, )) elif source_role not in {"extrude.start", "extrude.end"} or ( @@ -702,14 +1141,110 @@ class CapabilityAnalyzer: "shell.offset_face requires a direct new_body blind extrusion cap source", selector_index=selector_index, )) - if node.atomic_id == "sweep_add": + elif is_retained_shell_cap_offset_profile: + shell = previous_node.source_feature or {} + shell_selectors = shell.get("selectors") or [] + shell_params = shell.get("params") or {} + removed = shell_selectors[0] if len(shell_selectors) == 1 else {} + removed_intent = removed.get("selector_intent") if isinstance(removed, dict) else {} + disambiguation = selector_intent.get("disambiguation") or {} + source_ids = disambiguation.get("source_profile_entity_ids") if isinstance(disambiguation, dict) else None + expected_retained = "extrude.end" if removed.get("output_role") == "extrude.start" else "extrude.start" + if ( + shell.get("atomic_id") != "shell" + or shell.get("depends_on") != [source_owner] + or not isinstance(removed, dict) + or removed.get("owner_feature_id") != source_owner + or removed.get("output_role") not in {"extrude.start", "extrude.end"} + or not isinstance(removed_intent, dict) + or removed_intent.get("query_family") != "CAP_FACE" + or source_role != expected_retained + or not isinstance(source_ids, list) + or not source_ids + or len(set(source_ids)) != len(source_ids) + or disambiguation.get("removed_cap_role") != removed.get("output_role") + or params.get("operation") != "add" + or params.get("result_mode") != "new_body" + or (params.get("end_condition") or {}).get("type") != "blind" + or params.get("two_sided") + or params.get("draft") is not None + ): + blockers.append(self._blocker( + node.feature_id, + "unsupported_retained_shell_cap_offset_profile", + "Retained shell cap profiles require one immediate opposite direct-prism cap removal", + selector_index=selector_index, + )) + # Query-set parents carry no producer themselves. Their leaves + # still need the same lifecycle proof as a direct selector before + # runtime can attempt any recursive set evaluation. + for selector_index, selector in enumerate(output_role_selectors): + for leaf in _query_selector_leaves(selector): + if leaf is selector: + continue + selector_intent = leaf.get("selector_intent") + producer_node = nodes_by_id.get(str(leaf.get("owner_feature_id") or "")) + if ( + isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "OFFSET_EDGE" + and selector_intent.get("consumer_contract") == "direct_prism_shell_offset_edge_tdd" + and ( + node.atomic_id not in {"fillet", "chamfer"} + or previous_node is None + or not isinstance(selector_intent.get("disambiguation"), dict) + or selector_intent["disambiguation"].get("shell_feature_id") != previous_node.feature_id + or not is_direct_prism_shell_offset_edge_tdd( + leaf, previous_node.source_feature, + producer_node.source_feature if producer_node is not None else None, + sketches, + ) + ) + ): + blockers.append(self._blocker( + node.feature_id, + "unsupported_offset_edge_tdd_selector", + "OFFSET_EDGE TDD requires the immediately preceding direct-prism shell retained-cap continuation", + selector_index=selector_index, + )) + if ( + isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "OFFSET_EDGE" + and selector_intent.get("consumer_contract") == "direct_prism_shell_offset_edge_vertex" + and ( + node.atomic_id not in {"fillet", "chamfer"} + or previous_node is None + or not isinstance(selector_intent.get("disambiguation"), dict) + or selector_intent["disambiguation"].get("shell_feature_id") != previous_node.feature_id + or not is_direct_prism_shell_offset_edge_vertex( + leaf, previous_node.source_feature, + producer_node.source_feature if producer_node is not None else None, + sketches, + ) + ) + ): + blockers.append(self._blocker( + node.feature_id, + "unsupported_offset_edge_vertex_selector", + "OFFSET_EDGE vertex requires the immediately preceding direct-prism shell continuation", + selector_index=selector_index, + )) + if node.atomic_id in {"sweep_add", "sweep_cut"}: path = params.get("path") segment = path.get("segment") if isinstance(path, dict) else None + segments = path.get("segments") if isinstance(path, dict) else None kind = segment.get("type") if isinstance(segment, dict) else None - if kind not in {"line", "bspline"}: + if isinstance(segments, list): + spatial = not isinstance(path.get("workplane"), dict) if isinstance(path, dict) else False + error = _spatial_sweep_segmented_path_error(segments) if spatial else _sweep_segmented_path_error(segments) + if error is not None: + code, message = error + blockers.append(self._blocker( + node.feature_id, code, message, + )) + elif kind not in {"line", "arc", "circle", "bspline"}: blockers.append(self._blocker( node.feature_id, "unsupported_sweep_path", - "Sweep requires one captured line or B-spline path", + "Sweep requires one captured line, arc, circle, or B-spline path", )) elif kind == "line" and not all( isinstance(segment.get(key), list) and len(segment[key]) == 2 @@ -719,6 +1254,34 @@ class CapabilityAnalyzer: node.feature_id, "invalid_sweep_path", "Sweep line path requires two-dimensional start and end points", )) + elif kind == "arc" and ( + not all( + isinstance(segment.get(key), list) + and len(segment[key]) == 2 + and all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in segment[key]) + for key in ("start", "end", "center") + ) + or not isinstance(segment.get("radius_mm"), (int, float)) + or not math.isfinite(float(segment["radius_mm"])) + or float(segment["radius_mm"]) <= 0 + or not isinstance(segment.get("clockwise"), bool) + ): + blockers.append(self._blocker( + node.feature_id, "invalid_sweep_path", + "Sweep arc path requires finite start, end, center, positive radius, and direction", + )) + elif kind == "circle" and ( + not isinstance(segment.get("center"), list) + or len(segment["center"]) != 2 + or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in segment["center"]) + or not isinstance(segment.get("radius_mm"), (int, float)) + or not math.isfinite(float(segment["radius_mm"])) + or float(segment["radius_mm"]) <= 0 + ): + blockers.append(self._blocker( + node.feature_id, "invalid_sweep_path", + "Sweep circle path requires a finite center and positive radius", + )) elif kind == "bspline" and ( not isinstance(segment.get("points"), list) or len(segment.get("points") or []) < 2 @@ -744,8 +1307,10 @@ class CapabilityAnalyzer: if node.atomic_id == "boolean_bodies": target_ids = params.get("target_feature_ids") target_instance_refs = params.get("target_pattern_instance_refs") + target_transform_refs = params.get("target_transform_copy_refs") tool_ids = params.get("tool_feature_ids") tool_instance_refs = params.get("tool_pattern_instance_refs") + tool_transform_refs = params.get("tool_transform_copy_refs") operation = params.get("operation") if operation not in {"union", "subtract", "intersect"}: blockers.append(self._blocker( @@ -754,15 +1319,17 @@ class CapabilityAnalyzer: )) 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), + for parameter, instance_parameter, transform_parameter, feature_ids, instance_refs, transform_refs, selected_members in ( + ("target_feature_ids", "target_pattern_instance_refs", "target_transform_copy_refs", target_ids, target_instance_refs, target_transform_refs, target_members), + ("tool_feature_ids", "tool_pattern_instance_refs", "tool_transform_copy_refs", tool_ids, tool_instance_refs, tool_transform_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): + if transform_refs is None: + transform_refs = [] + if not isinstance(feature_ids, list) or not isinstance(instance_refs, list) or not isinstance(transform_refs, list) or not (feature_ids or instance_refs or transform_refs): blockers.append(self._blocker( node.feature_id, "missing_boolean_bodies", "booleanBodies requires explicit target and tool body references", parameter=parameter, @@ -818,6 +1385,45 @@ class CapabilityAnalyzer: )) continue selected_members.add(member_id) + for index, reference in enumerate(transform_refs): + if not isinstance(reference, dict): + blockers.append(self._blocker( + node.feature_id, "invalid_transform_copy_ref", + "Transform COPY body reference must be an object", parameter=transform_parameter, index=index, + )) + 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" or transform.feature_id not in completed: + blockers.append(self._blocker( + node.feature_id, "transform_copy_unavailable", + "Transform COPY owner is not an executable preceding body transform", + 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, + )) + continue + selected_members.add(member_id) if target_members & tool_members: blockers.append(self._blocker( node.feature_id, "boolean_body_overlap", @@ -865,12 +1471,6 @@ class CapabilityAnalyzer: "hole scope body is no longer an independently selectable body output", scope_feature_id=scope_feature_id, )) - elif len(body_members) != 1: - blockers.append(self._blocker( - node.feature_id, "hole_scope_body_ambiguous", - "hole scope body must be the sole active member", - scope_feature_id=scope_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) @@ -1041,19 +1641,23 @@ class CapabilityAnalyzer: blockers.append(self._blocker(node.feature_id, "unsupported_extent", "The extent needs a resolved topology selector or is not implemented", extent=end_type)) target_kind = _EXTENT_TARGET_KINDS.get(end_type or "") if target_kind: - required.append(f"selector:extent_target:{target_kind}") reference = end_condition.get("reference") if not isinstance(reference, dict): blockers.append(self._blocker( node.feature_id, "missing_extent_reference", "This end condition requires a captured target selector", extent=end_type, )) + elif end_type == "up_to_vertex" and reference.get("kind") == "source_vertex": + required.append("source_vertex_extent") elif reference.get("kind") != target_kind: + required.append(f"selector:extent_target:{target_kind}") blockers.append(self._blocker( node.feature_id, "unsupported_extent_target", "The captured target kind is incompatible with this end condition", extent=end_type, expected_kind=target_kind, actual_kind=reference.get("kind"), )) + else: + required.append(f"selector:extent_target:{target_kind}") if end_type == "offset_from_surface" and abs(float(params.get("distance_mm") or 0.0)) <= 1e-12: blockers.append(self._blocker( node.feature_id, "missing_offset_distance", @@ -1070,20 +1674,24 @@ class CapabilityAnalyzer: )) reverse_target_kind = _EXTENT_TARGET_KINDS.get(reverse_type or "") if reverse_target_kind: - required.append(f"selector:reverse_extent_target:{reverse_target_kind}") reverse_reference = reverse_condition.get("reference") if not isinstance(reverse_reference, dict): blockers.append(self._blocker( node.feature_id, "missing_reverse_extent_reference", "This reverse end condition requires a captured target selector", extent=reverse_type, )) + elif reverse_type == "up_to_vertex" and reverse_reference.get("kind") == "source_vertex": + required.append("source_vertex_reverse_extent") elif reverse_reference.get("kind") != reverse_target_kind: + required.append(f"selector:reverse_extent_target:{reverse_target_kind}") blockers.append(self._blocker( node.feature_id, "unsupported_reverse_extent_target", "The reverse target kind is incompatible with this end condition", extent=reverse_type, expected_kind=reverse_target_kind, actual_kind=reverse_reference.get("kind"), )) + else: + required.append(f"selector:reverse_extent_target:{reverse_target_kind}") if reverse_type == "offset_from_surface" and abs(float(params.get("reverse_distance_mm") or 0.0)) <= 1e-12: blockers.append(self._blocker( node.feature_id, "missing_reverse_offset_distance", @@ -1129,6 +1737,33 @@ class CapabilityAnalyzer: blockers.append(self._blocker(node.feature_id, "missing_reference_orientation", "Reference plane requires an explicit plane frame")) if node.atomic_id == "reference_plane" and isinstance(params.get("plane"), dict) and params["plane"].get("unresolved"): blockers.append(self._blocker(node.feature_id, "missing_reference_orientation", "Reference plane orientation was not captured")) + if node.atomic_id == "reference_point": + point = params.get("point_mm") + if ( + not isinstance(point, list) + or len(point) != 3 + or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in point) + ): + blockers.append(self._blocker( + node.feature_id, + "invalid_reference_point", + "Reference point requires one finite three-dimensional coordinate", + )) + if node.atomic_id == "assign_variable": + value = params.get("value") + if ( + not isinstance(params.get("name"), str) + or not params["name"] + or not isinstance(value, (int, float)) + or isinstance(value, bool) + or not math.isfinite(float(value)) + or params.get("value_kind") not in {"any", "length"} + ): + blockers.append(self._blocker( + node.feature_id, + "invalid_assign_variable", + "Source variable requires one finite named scalar value", + )) if node.atomic_id == "reference_axis": axis = params.get("axis") or {} if not (axis.get("origin_mm") and axis.get("direction")): @@ -1195,17 +1830,21 @@ class CapabilityAnalyzer: if status == "executable": completed.add(node.feature_id) body_members, body_available = _next_body_graph(node, body_members, body_available, nodes_by_id) - previous_node = node + # Source variables affect lowering expressions only. They are not + # topology producers and must not break an immediate selector's + # producer/lifecycle relationship. + if node.atomic_id != "assign_variable": + previous_node = node body_producers = { "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", + "extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "sweep_cut", "boolean_bodies", "revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add", "thread_add", "bend_add", "gear_add", "rack_add", # thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由 # active_body 前置阻止,文档含该类特征即视为携带可执行几何。 "thread_cut", } - surface_producers = {"extrude_surface", "revolve_surface"} + surface_producers = {"extrude_surface", "revolve_surface", "loft_surface"} document_blockers: list[RuntimeDiagnostic] = [] if not any(node.atomic_id in body_producers | surface_producers for node in plan): document_blockers.append(RuntimeDiagnostic( diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index 0c9bac18..9c6d01c7 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -95,12 +95,23 @@ "properties": { "type": {"type": "string", "minLength": 1}, "solidworks_code": {"type": "integer"}, - "reference": {"$ref": "#/$defs/selectorRef"}, + "reference": {"oneOf": [{"$ref": "#/$defs/selectorRef"}, {"$ref": "#/$defs/sourceVertexDatumRef"}]}, "offset_mm": {"type": "number", "minimum": 0} }, "required": ["type", "solidworks_code"], "additionalProperties": false }, + "sourceVertexDatumRef": { + "type": "object", + "properties": { + "kind": {"const": "source_vertex"}, + "source_sketch_id": {"type": "string", "minLength": 1, "maxLength": 160}, + "source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}, + "point_mm": {"$ref": "#/$defs/point3"} + }, + "required": ["kind", "source_sketch_id", "source_entity_id", "point_mm"], + "additionalProperties": false + }, "selectorOrUnresolved": { "oneOf": [ {"$ref": "#/$defs/selectorRef"}, @@ -140,6 +151,14 @@ "maxItems": 16, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"} + }, + "initial_output_roles": {"type": "boolean"}, + "cap_output_profile_sources": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"} } }, "required": ["profile_sketch_ids"], @@ -160,20 +179,92 @@ "additionalProperties": false }, "sweepPath": { - "type": "object", - "properties": { - "workplane": {"$ref": "#/$defs/workplane"}, - "segment": {"$ref": "#/$defs/analyticSegment"} - }, - "required": ["workplane", "segment"], - "additionalProperties": false + "oneOf": [ + { + "type": "object", + "properties": { + "workplane": {"$ref": "#/$defs/workplane"}, + "segment": {"$ref": "#/$defs/analyticSegment"}, + "segments": { + "type": "array", + "minItems": 2, + "items": {"$ref": "#/$defs/analyticSegment"} + } + }, + "required": ["workplane"], + "oneOf": [ + {"required": ["segment"], "not": {"required": ["segments"]}}, + {"required": ["segments"], "not": {"required": ["segment"]}} + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "segments": { + "type": "array", + "minItems": 2, + "items": {"$ref": "#/$defs/spatialSweepSegment"} + } + }, + "required": ["segments"], + "additionalProperties": false + } + ] }, "sweepParams": { "type": "object", "properties": { "path": {"$ref": "#/$defs/sweepPath"}, "is_frenet": {"type": "boolean"}, - "result_mode": {"enum": ["fuse", "new_body"]} + "result_mode": {"enum": ["fuse", "new_body"]}, + "initial_output_roles": {"type": "boolean"}, + "cap_output_contract": { + "type": "object", + "properties": { + "profile_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, + "profile_entity": {"type": "string", "minLength": 1, "maxLength": 160}, + "path_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, + "path_entity": {"type": "string", "minLength": 1, "maxLength": 160}, + "path_reversed": {"type": "boolean"} + }, + "required": ["profile_source", "profile_entity", "path_source", "path_entity", "path_reversed"], + "additionalProperties": false + }, + "swept_face_contract": { + "type": "object", + "properties": { + "profile_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, + "profile_entities": { + "type": "array", + "items": {"type": "string", "minLength": 1, "maxLength": 160}, + "minItems": 1, + "uniqueItems": true + }, + "path_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, + "path_entity": {"type": "string", "minLength": 1, "maxLength": 160}, + "path_reversed": {"type": "boolean"} + }, + "required": ["profile_source", "profile_entities", "path_source", "path_entity", "path_reversed"], + "additionalProperties": false + }, + "swept_edge_contract": { + "type": "object", + "properties": { + "profile_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, + "profile_entities": { + "type": "array", + "items": {"type": "string", "minLength": 1, "maxLength": 160}, + "minItems": 2, + "uniqueItems": true + }, + "path_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, + "path_entity": {"type": "string", "minLength": 1, "maxLength": 160}, + "path_reversed": {"type": "boolean"} + }, + "required": ["profile_source", "profile_entities", "path_source", "path_entity", "path_reversed"], + "additionalProperties": false + } }, "required": ["path"], "additionalProperties": false @@ -405,14 +496,17 @@ "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"}}, + "target_transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}}, "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"} + "tool_transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}}, + "keep_tools": {"type": "boolean"}, + "targetless_body_set": {"type": "boolean"} }, "required": ["operation"], "allOf": [ - {"anyOf": [{"required": ["target_feature_ids"]}, {"required": ["target_pattern_instance_refs"]}]}, - {"anyOf": [{"required": ["tool_feature_ids"]}, {"required": ["tool_pattern_instance_refs"]}]} + {"anyOf": [{"required": ["target_feature_ids"]}, {"required": ["target_pattern_instance_refs"]}, {"required": ["target_transform_copy_refs"]}]}, + {"anyOf": [{"required": ["tool_feature_ids"]}, {"required": ["tool_pattern_instance_refs"]}, {"required": ["tool_transform_copy_refs"]}]} ], "additionalProperties": false }, @@ -453,12 +547,22 @@ "required": ["transform_feature_id", "source_feature_id"], "additionalProperties": false }, + "transformSourceMemberAlias": { + "type": "object", + "properties": { + "source_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, + "active_member_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"} + }, + "required": ["source_feature_id", "active_member_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"}}, + "source_member_aliases": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformSourceMemberAlias"}}, "transform": {"$ref": "#/$defs/bodyTransform"}, "make_copy": {"type": "boolean"} }, @@ -533,6 +637,22 @@ "required": ["axis"], "additionalProperties": false }, + "referencePointParams": { + "type": "object", + "properties": {"point_mm": {"$ref": "#/$defs/point3"}}, + "required": ["point_mm"], + "additionalProperties": false + }, + "assignVariableParams": { + "type": "object", + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 256}, + "value": {"type": "number"}, + "value_kind": {"enum": ["any", "length"]} + }, + "required": ["name", "value", "value_kind"], + "additionalProperties": false + }, "holeWizardParams": { "type": "object", "properties": { @@ -588,6 +708,132 @@ "required": ["ast"], "additionalProperties": false }, + "selectorIntentQueryExprNode": { + "oneOf": [ + { + "type": "object", + "properties": { + "node": {"const": "set"}, + "operator": {"enum": ["union", "intersection", "subtraction"]}, + "operands": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}} + }, + "required": ["node", "operator", "operands"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "node": {"const": "filter"}, + "filter": {"enum": ["adjacent", "owner_body", "body_type", "construction"]}, + "input": {"$ref": "#/$defs/selectorIntentQueryExprNode"}, + "arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}} + }, + "required": ["node", "filter", "input", "arguments"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "node": {"const": "topology_query"}, + "owner": {"$ref": "#/$defs/selectorIntentQueryExprNode"}, + "topology_type": {"$ref": "#/$defs/selectorIntentQueryExprNode"}, + "entity_type": {"$ref": "#/$defs/selectorIntentQueryExprNode"}, + "arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}} + }, + "required": ["node", "owner", "topology_type", "entity_type", "arguments"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "node": {"const": "created_by"}, + "owner": {"$ref": "#/$defs/selectorIntentQueryExprNode"}, + "arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}} + }, + "required": ["node", "owner", "arguments"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "node": {"const": "source_entity"}, + "sketch": {"$ref": "#/$defs/selectorIntentQueryExprNode"}, + "entity_type": {"$ref": "#/$defs/selectorIntentQueryExprNode"}, + "entity": {"$ref": "#/$defs/selectorIntentQueryExprNode"}, + "arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}} + }, + "required": ["node", "sketch", "entity_type", "entity", "arguments"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "node": {"const": "sketch_region"}, + "sketch": {"$ref": "#/$defs/selectorIntentQueryExprNode"}, + "arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}} + }, + "required": ["node", "sketch", "arguments"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "node": {"const": "opaque_call"}, + "name": {"type": "string", "minLength": 1}, + "arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}} + }, + "required": ["node", "name", "arguments"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "node": {"const": "list"}, + "items": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}} + }, + "required": ["node", "items"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "node": {"const": "map"}, + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": {"type": "string"}, + "value": {"$ref": "#/$defs/selectorIntentQueryExprNode"} + }, + "required": ["key", "value"], + "additionalProperties": false + } + } + }, + "required": ["node", "entries"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "node": {"const": "literal"}, + "value": {} + }, + "required": ["node", "value"], + "additionalProperties": false + } + ] + }, + "selectorIntentQueryExpr": { + "type": "object", + "properties": { + "version": {"const": "1.0"}, + "root": {"$ref": "#/$defs/selectorIntentQueryExprNode"} + }, + "required": ["version", "root"], + "additionalProperties": false + }, "selectorIntent": { "type": "object", "properties": { @@ -595,6 +841,7 @@ "kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]}, "query_family": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,79}$"}, "source_query": {"$ref": "#/$defs/selectorIntentSourceQuery"}, + "query_expr": {"$ref": "#/$defs/selectorIntentQueryExpr"}, "source_entity": { "type": "object", "properties": { @@ -606,7 +853,7 @@ }, "source_entities": { "type": "array", - "minItems": 2, + "minItems": 1, "uniqueItems": true, "items": { "type": "object", @@ -642,9 +889,81 @@ "additionalProperties": false } }, + "blend_sources": { + "type": "object", + "properties": { + "edge": { + "type": "object", + "properties": { + "query_family": {"const": "CAP_EDGE"}, + "owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160}, + "source_entity": { + "type": "object", + "properties": { + "sketch_id": {"type": "string", "minLength": 1}, + "entity_id": {"type": "string", "minLength": 1} + }, + "required": ["sketch_id", "entity_id"], + "additionalProperties": false + }, + "lineage_role": {"enum": ["extrude.start", "extrude.end"]} + }, + "required": ["query_family", "owner_feature_id", "source_entity", "lineage_role"], + "additionalProperties": false + }, + "face": { + "type": "object", + "properties": { + "query_family": {"enum": ["CAP_FACE", "SWEPT_FACE"]}, + "owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160}, + "output_role": {"enum": ["extrude.start", "extrude.end"]}, + "source_entity": { + "type": "object", + "properties": { + "sketch_id": {"type": "string", "minLength": 1}, + "entity_id": {"type": "string", "minLength": 1} + }, + "required": ["sketch_id", "entity_id"], + "additionalProperties": false + } + }, + "required": ["query_family", "owner_feature_id"], + "additionalProperties": false + } + }, + "required": ["edge", "face"], + "additionalProperties": false + }, + "blend_face_source": { + "type": "object", + "properties": { + "query_family": {"const": "CAP_EDGE"}, + "owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160}, + "source_entity": { + "type": "object", + "properties": { + "sketch_id": {"type": "string", "minLength": 1}, + "entity_id": {"type": "string", "minLength": 1} + }, + "required": ["sketch_id", "entity_id"], + "additionalProperties": false + }, + "lineage_role": {"enum": ["extrude.start", "extrude.end"]} + }, + "required": ["query_family", "owner_feature_id", "source_entity", "lineage_role"], + "additionalProperties": false + }, "output_role": {"$ref": "#/$defs/featureOutputRole"}, + "consumer_contract": {"enum": ["direct_prism_cap_face_workplane", "primary_add_shell_union_continuation", "primary_add_up_to_surface_union_continuation", "primary_add_dressup_union_continuation", "symmetric_direct_prism_two_sided_up_to_surface_cap_pair", "symmetric_direct_prism_shell_swept_face_up_to_surface_pair", "immediate_retained_source_prism_swept_face_up_to_surface", "shell_retained_direct_prism_cap_offset_face_profile", "direct_prism_shell_offset_edge_tdd", "direct_prism_shell_offset_edge_vertex"]}, "lineage_role": {"enum": ["extrude.start", "extrude.end"]}, "body_member_contract": {"enum": ["direct_new_body"]}, + "query_set_contract": {"enum": ["proven_operand_union", "proven_operand_intersection", "proven_operand_subtraction"]}, + "owner_body_contract": {"enum": ["exact_input_owner"]}, + "copy_contract": {"enum": ["primary_cut_cap_edge", "primary_cut_cap_face_workplane", "primary_cut_swept_face_workplane"]}, + "set_kind": {"enum": ["face", "edge"]}, + "body_scope": {"enum": ["active_member"]}, + "empty_policy": {"enum": ["reject"]}, + "multiple_policy": {"enum": ["all", "one"]}, "derivation_policy": { "type": "object", "properties": { @@ -674,6 +993,8 @@ "snapshot_id": {"type": "string", "minLength": 1}, "binding_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "match_mode": {"enum": ["unique", "all"]}, + "query_input": {"$ref": "#/$defs/selectorRef"}, + "query_operands": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}}, "matched_selectors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorRef"}}, "intersection_of": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}}, "selector_intent_version": {"const": "1.0"}, @@ -715,6 +1036,33 @@ ], "additionalProperties": false }, + "spatialSweepSegment": { + "type": "object", + "properties": { + "type": {"enum": ["line", "arc", "bspline"]}, + "start_mm": {"$ref": "#/$defs/point3"}, + "end_mm": {"$ref": "#/$defs/point3"}, + "center_mm": {"$ref": "#/$defs/point3"}, + "normal": {"$ref": "#/$defs/point3"}, + "radius_mm": {"$ref": "#/$defs/positive"}, + "points_mm": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/point3"}}, + "parameters": {"type": "array", "minItems": 2, "items": {"type": "number"}}, + "periodic": {"type": "boolean"}, + "clockwise": {"type": "boolean"}, + "start_tangent_mm": {"$ref": "#/$defs/point3"}, + "end_tangent_mm": {"$ref": "#/$defs/point3"}, + "source_sketch_id": {"type": "string", "minLength": 1, "maxLength": 160}, + "source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160} + }, + "required": ["type", "source_sketch_id", "source_entity_id"], + "allOf": [ + {"if": {"properties": {"type": {"const": "line"}}}, "then": {"required": ["start_mm", "end_mm"]}}, + {"if": {"properties": {"type": {"const": "arc"}}}, "then": {"required": ["start_mm", "end_mm", "center_mm", "normal", "radius_mm"]}}, + {"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["points_mm"]}}, + {"if": {"properties": {"type": {"const": "bspline"}, "points_mm": {"maxItems": 2}}}, "then": {"required": ["start_tangent_mm", "end_tangent_mm", "parameters"], "properties": {"periodic": {"const": false}}}} + ], + "additionalProperties": false + }, "constructionBsplineSegment": { "type": "object", "properties": { @@ -738,7 +1086,8 @@ "type": "object", "properties": { "role": {"enum": ["outer", "inner", "open", "unknown"]}, - "closed": {"type": "boolean"}, + "closed": {"type": "boolean"}, + "surface_wire": {"type": "boolean"}, "segments": {"type": "array", "items": {"$ref": "#/$defs/analyticSegment"}} }, "required": ["role", "closed", "segments"], @@ -767,10 +1116,24 @@ "type": "object", "properties": { "anchor_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}, + "external_anchor_id": {"type": "string", "minLength": 1, "maxLength": 160}, "side": {"enum": [-1, 1]}, "intersection_index": {"type": "integer", "minimum": 0} }, - "required": ["anchor_entity_id", "side"], + "required": ["side"], + "oneOf": [ + {"required": ["anchor_entity_id"]}, + {"required": ["external_anchor_id"]} + ], + "additionalProperties": false + }, + "imprintExternalAnchor": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 160}, + "selector": {"$ref": "#/$defs/selectorRef"} + }, + "required": ["id", "selector"], "additionalProperties": false }, "imprintSelection": { @@ -787,13 +1150,37 @@ "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"}} + "source_entities": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSourceEntity"}}, + "selections": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSelection"}}, + "external_anchors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintExternalAnchor"}} }, "required": ["type", "source_entities", "selections"], + "allOf": [ + { + "if": {"not": {"required": ["external_anchors"]}}, + "then": {"properties": {"source_entities": {"minItems": 2}}} + } + ], "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", "gear_add", "rack_add", "fillet", "chamfer", "shell", "boolean_bodies", "transform_bodies", "delete_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]}, + "multiSourceRegionsProfile": { + "type": "object", + "properties": { + "type": {"const": "multi_source_regions"}, + "source_sketch_ids": {"type": "array", "minItems": 2, "items": {"type": "string", "minLength": 1, "maxLength": 160}, "uniqueItems": true}, + "profiles": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/directProfile"}} + }, + "required": ["type", "source_sketch_ids", "profiles"], + "additionalProperties": false + }, + "directProfile": { + "oneOf": [ + {"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, "source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}}, "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"} + ] + }, + "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", "loft_surface", "sweep_add", "sweep_cut", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", "fillet", "chamfer", "shell", "boolean_bodies", "transform_bodies", "delete_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "reference_point", "assign_variable", "hole_wizard"]}, "feature": { "type": "object", "properties": { @@ -819,7 +1206,9 @@ {"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": "loft_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "sweep_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sweepParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "sweep_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sweepParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "revolve_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "revolve_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "revolve_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveSurfaceParams"}}}}, @@ -846,15 +1235,18 @@ {"if": {"properties": {"atomic_id": {"const": "pattern_circular"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/circularPatternParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "reference_plane"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referencePlaneParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "reference_axis"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referenceAxisParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "reference_point"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referencePointParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "assign_variable"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/assignVariableParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}} ] }, - "profile_type": {"enum": ["circle", "polygon", "analytic_contours", "planar_imprint"]}, + "profile_type": {"enum": ["circle", "polygon", "analytic_contours", "multi_source_regions", "planar_imprint"]}, "profile": { "oneOf": [ {"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, "source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}}, "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/multiSourceRegionsProfile"}, {"$ref": "#/$defs/planarImprintProfile"} ] }, diff --git a/backend/engine/cdsl_engine/executors/bodies.py b/backend/engine/cdsl_engine/executors/bodies.py index a2b1e17a..3651be37 100644 --- a/backend/engine/cdsl_engine/executors/bodies.py +++ b/backend/engine/cdsl_engine/executors/bodies.py @@ -24,9 +24,11 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession") params = node.params target_ids = _member_sources( node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs", + allow_transform_copies=True, transform_copy_parameter="target_transform_copy_refs", ) tool_ids = _member_sources( node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs", + allow_transform_copies=True, transform_copy_parameter="tool_transform_copy_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} @@ -49,6 +51,12 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession") } members[node.feature_id] = result if bool(params.get("keep_tools")): + # In a targetless FeatureScript body-set operation every selected + # member is semantically a tool. Lowering chooses the first source + # member only to satisfy CDSL's binary executor shape, so retain that + # left operand too when the explicit targetless contract requests it. + if bool(params.get("targetless_body_set")): + members.update(targets) members.update(tools) session.register_body( node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta, diff --git a/backend/engine/cdsl_engine/executors/common.py b/backend/engine/cdsl_engine/executors/common.py index c03afe1e..3991b7fe 100644 --- a/backend/engine/cdsl_engine/executors/common.py +++ b/backend/engine/cdsl_engine/executors/common.py @@ -6,13 +6,14 @@ used by exactly one family lives in that family's module instead. from __future__ import annotations +from dataclasses import replace import math from typing import TYPE_CHECKING, Any, Callable from ..extents import _extent_vectors_from_normal, _normal_from_sketch from ..runtime_base import ExtentVector, FeatureExecutionError from ..specs import AxisSpec, HoleSpec, PlaneSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit -from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta, TopologyRecord +from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord if TYPE_CHECKING: # pragma: no cover - import for type checkers only from ..session import ExecutionSession @@ -57,20 +58,41 @@ 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]: +def _cut_explicit_body_members( + session: "ExecutionSession", tool: Any, +) -> tuple[dict[str, Any], tuple[TopologyDelta, ...]]: """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. + and drops only members that the tool removes completely. Each non-empty + member result retains its own OCC builder history; callers may compose + those disjoint exact relations into one operation-wide delta. """ members: dict[str, Any] = {} + deltas: list[TopologyDelta] = [] for feature_id, body in session.body_members.items(): - result = session.adapter.cut(body, tool) - if abs(float(result.volume)) > 1e-12: + result, delta = session.adapter.cut_with_topology_delta(body, tool) + if delta is not None: + deltas.append(delta) + if result is not None and abs(float(result.volume)) > 1e-12: members[feature_id] = result - return members + return members, tuple(deltas) + + +def _compose_member_cut_deltas(deltas: tuple[TopologyDelta, ...]) -> TopologyDelta | None: + """Combine exact independent member-cut histories without inventing links.""" + if not deltas or any(delta.history_status != "proven" for delta in deltas): + return None + return TopologyDelta( + operation="subtract", + relations=tuple(relation for delta in deltas for relation in delta.relations), + section_values=tuple(value for delta in deltas for value in delta.section_values), + section_relations=tuple(relation for delta in deltas for relation in delta.section_relations), + blend_relations=tuple(relation for delta in deltas for relation in delta.blend_relations), + history_reason="per_member_exact_cut_history", + ) def _can_register_primary_cut_tool_history( @@ -82,12 +104,14 @@ def _can_register_primary_cut_tool_history( """Return whether a primary REMOVE can retain a transient tool snapshot. The implicit CADFS primary boolean normally has no independently active - tool body. It may contribute selector provenance only when the target, - tool, profile anchors, and prism builder history are all singular and - exact. This gate applies only to the tool-side transient snapshot used by - source-qualified section queries. The cut builder can independently - prove a target-side continuation even when a trimmed or fallback tool has - no direct-prism history. + tool body. It may retain its direct-prism input snapshot whenever the + tool, profile anchors, and prism builder history are singular and exact. + The target may contain several independent members: that changes the cut + result cardinality but not the identity of the one transient tool input. + This gate does not make any multi-member selector executable; a later + resolver must still prove its complete operation-wide relation and active + member. The cut builder can independently prove a target-side continuation + even when a trimmed or fallback tool has no direct-prism history. """ if ( session.body is None @@ -96,8 +120,6 @@ def _can_register_primary_cut_tool_history( or topology_delta.history_status != "proven" or not topology_delta.relations or not topology_anchors - or len(session.body_members) != 1 - or len(session.adapter.body_solids(session.body)) != 1 or len(session.adapter.body_solids(tool)) != 1 ): return False @@ -115,6 +137,7 @@ def _extruded_tool( session: "ExecutionSession", *, record_multiface_prism_history: bool = False, + use_operation_wide_prism_history: bool = False, ) -> tuple[Any, TopologyDelta | None]: """Build an extrude tool, retaining complete direct builder history.""" extents = _extent_vectors_from_normal(node, faces, profile_normal, session) @@ -124,21 +147,38 @@ def _extruded_tool( taper_deg = float(draft["angle_deg"]) if not bool(draft["pull_direction"]): taper_deg = -taper_deg - topology_deltas: list[TopologyDelta] = [] + if ( + use_operation_wide_prism_history + and draft is None + and len(faces) > 1 + and len(extents) == 1 + and extents[0].trim_to is None + ): + composed = session.adapter.extrude_faces_with_composed_topology_delta(faces, extents[0].vector) + if composed is not None: + return composed + topology_deltas: list[tuple[int, TopologyDelta]] = [] solids: list[Any] = [] + exact_two_sided_prism = ( + node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"} + and draft is None + and len(faces) == 1 + and len(extents) == 2 + and all(extent.trim_to is None for extent in extents) + ) for face in faces: - for extent in extents: + for extent_index, extent in enumerate(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, ) if topology_delta is not None: - topology_deltas.append(topology_delta) + topology_deltas.append((extent_index, topology_delta)) solids.append(solid) else: solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg)) - elif extent.trim_to is None and len(extents) == 1 and ( + elif extent.trim_to is None and (len(extents) == 1 or exact_two_sided_prism) and ( len(faces) == 1 or record_multiface_prism_history ): # Each independently constructed profile face has its own OCC @@ -147,8 +187,19 @@ def _extruded_tool( # multi-face profiles keep the established general-extrude # path; forcing them through MakePrism can make a previously # executable profile invalid without adding usable evidence. - solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector) - topology_deltas.append(topology_delta) + try: + solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector) + except ValueError: + # Keep the established executable profile result when a + # selected IMPRINT region is valid as a face but cannot be + # a standalone valid prism. Its later fuse may still be + # valid. There is no complete builder witness in this + # case, so the entire multi-region topology delta is + # withheld below instead of mixing proven and guessed + # source anchors. + solid = session.adapter.extrude(face, extent.vector) + else: + topology_deltas.append((extent_index, topology_delta)) solids.append(solid) elif extent.trim_to is None: solids.append(session.adapter.extrude(face, extent.vector)) @@ -161,9 +212,21 @@ def _extruded_tool( raise ValueError("extrude produced no solid") if len(topology_deltas) != len(solids): return tool, None + relations: list[TopologyDeltaRelation] = [] + for extent_index, topology_delta in topology_deltas: + for relation in topology_delta.relations: + # Both prism builders start on the source plane. Its two source + # caps are internal to the fused two-sided result. The reverse + # extent's far ``LastShape`` is the FeatureScript start cap; map + # that exact builder handle before the registry checks final + # membership. No source-plane or geometry-derived relation is + # promoted to a CAP role. + if exact_two_sided_prism and extent_index == 1 and relation.output_role == "extrude.end": + relation = replace(relation, output_role="extrude.start") + relations.append(relation) return tool, TopologyDelta( operation="extrude", - relations=tuple(relation for delta in topology_deltas for relation in delta.relations), + relations=tuple(relations), ) @@ -181,7 +244,7 @@ def _apply_primary_tool( if cutting: if session.body is None: raise ValueError("cut feature has no body") - members = _cut_explicit_body_members(session, tool) + members, member_cut_deltas = _cut_explicit_body_members(session, tool) if not members: session.clear_body() return session.result(node) @@ -190,12 +253,10 @@ def _apply_primary_tool( retain_transient_tool = _can_register_primary_cut_tool_history( session, tool, tool_delta, topology_anchors, ) - if ( - len(session.body_members) == 1 - and len(session.adapter.body_solids(session.body)) == 1 - and len(session.adapter.body_solids(tool)) == 1 - ): - body, cut_delta = session.adapter.cut_with_topology_delta(session.body, tool) + if len(session.body_members) == 1: + member_id = next(iter(session.body_members)) + body = members[member_id] + cut_delta = member_cut_deltas[0] if len(member_cut_deltas) == 1 else None # The target-side boolean history is independent of the source # tool's construction history. A trimmed tool cannot support a # source-qualified section query, but its exact BRepAlgoAPI_Cut @@ -219,23 +280,64 @@ def _apply_primary_tool( topology_delta = cut_delta else: topology_delta = None - member_id = next(iter(session.body_members)) members = {member_id: body} else: - body = session.adapter.cut(session.body, tool) - topology_delta = None + if retain_transient_tool: + try: + topology_predecessors = session.register_transient_prism_tool( + node.feature_id, + tool, + topology_delta=tool_delta, + topology_anchors=tool_anchors, + ) + except ValueError: + # A transient tool is optional evidence. Keep the exact + # per-member target history when its tool snapshot cannot + # be registered completely. + topology_predecessors = None + body = session.adapter.combine(None, next(iter(members.values()))) + for member in list(members.values())[1:]: + body = session.adapter.combine(body, member) + topology_delta = _compose_member_cut_deltas(member_cut_deltas) topology_anchors = 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) + # An ADD can replace both the prior active solid and its direct prism + # tool. Retain a selector relation only when both snapshots are + # singular and OCC supplies the exact union history. The direct-prism + # snapshot remains transient: its source anchors cannot be selected + # until the union proves a complete successor in the active result. + can_trace_add = ( + session.body is not None + and topology_delta is not None + and topology_delta.operation == "extrude" + and topology_delta.history_status == "proven" + and bool(topology_delta.relations) + and bool(topology_anchors) + and len(session.adapter.body_solids(session.body)) == 1 + and len(session.adapter.body_solids(tool)) == 1 + ) + if can_trace_add: + fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, tool) + if fuse_delta is not None: + topology_predecessors = session.register_transient_prism_tool( + node.feature_id, + tool, + topology_delta=topology_delta, + topology_anchors=list(topology_anchors or ()), + ) + body = fused + topology_delta = fuse_delta + topology_anchors = None + else: + body = fused + topology_delta = None + topology_anchors = None + 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 - topology_anchors = None session.register_body( node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta, topology_predecessors=topology_predecessors, topology_anchors=topology_anchors, @@ -252,7 +354,11 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s if selected_sketch is None: raise ValueError("primary feature has no resolved sketch") # 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。 - faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(selected_sketch) + support_face = session.sketch_attachment_faces.get(str(node.sketch_id)) + external_anchor_edges = session.sketch_imprint_external_edges.get(str(node.sketch_id)) + faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors( + selected_sketch, support_face=support_face, external_anchor_edges=external_anchor_edges, + ) if not faces: raise ValueError("sketch does not create a closed profile region") if node.atomic_id == "extrude_add_blind_with_hole": @@ -274,6 +380,7 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s and (contour.get("segments") or [{}])[0].get("type") == "circle" for contour in contours ) + planar_imprint_profile = profile.get("type") == "planar_imprint" # 3. 按特征类型生成子实体: if node.atomic_id.startswith("extrude_"): # 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量, @@ -282,7 +389,15 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s # 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。 tool, topology_delta = _extruded_tool( node, faces, _normal_from_sketch(selected_sketch), session, - record_multiface_prism_history=direct_all_circle_profile and len(source_anchor_specs) >= len(faces), + # A multi-region prism gets per-region builder history only when + # every profile region has at least one exact source boundary. + # This covers direct circles and the adapter's bounded IMPRINT + # splitter path, without turning arbitrary multi-face profiles + # into a different construction algorithm. + record_multiface_prism_history=(direct_all_circle_profile or planar_imprint_profile) + and len(source_anchor_specs) >= len(faces), + use_operation_wide_prism_history=planar_imprint_profile + and len(source_anchor_specs) >= len(faces), ) if topology_delta is not None: for index, spec in enumerate(source_anchor_specs): @@ -318,10 +433,44 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s if bool(node.params.get("reverse")): angle = -angle tool = None - for solid in (session.adapter.revolve(face, angle, axis) for face in faces): - tool = session.adapter.fuse(tool, solid) + can_record_revolve_history = ( + node.atomic_id == "revolve_add" + and node.params.get("result_mode") == "new_body" + and len(faces) == 1 + ) + for face in faces: + if can_record_revolve_history: + try: + solid, topology_delta = session.adapter.revolve_with_topology_delta(face, angle, axis) + except ValueError: + # Retain the established executable revolve when OCC + # cannot expose a complete builder-history witness. + solid = session.adapter.revolve(face, angle, axis) + topology_delta = None + else: + solid = session.adapter.revolve(face, angle, axis) + tool = solid if tool is None else session.adapter.fuse(tool, solid) if tool is None: raise ValueError("revolve produced no solid") + if topology_delta is not None: + for index, spec in enumerate(source_anchor_specs): + kind = spec.get("kind") + value = spec.get("value") + if kind not in {"edge", "vertex"} or value is None: + continue + source_entity = spec.get("source_entity") + source_entities = tuple(spec.get("source_entities") or ()) + if source_entity is None and not source_entities: + continue + topology_anchors.append(TopologyRecord( + record_id=f"anchor:{node.feature_id}:{kind}:{index}", + kind=kind, + feature_id=node.feature_id, + geometry={}, + value=value, + source_entity=source_entity if isinstance(source_entity, tuple) else None, + source_entities=source_entities, + )) return _apply_primary_tool( node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta, topology_anchors=topology_anchors, @@ -374,10 +523,14 @@ def _pattern_instance_sources( return resolved -def _transform_copy_sources(node: FeaturePlanNode, session: "ExecutionSession") -> list[str]: +def _transform_copy_sources( + node: FeaturePlanNode, + session: "ExecutionSession", + parameter: str = "transform_copy_refs", +) -> 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 (): + for reference in node.params.get(parameter) or (): if not isinstance(reference, dict): raise ValueError("transform COPY reference must be an object") transform_id = str(reference.get("transform_feature_id") or "") @@ -411,12 +564,13 @@ def _member_sources( *, pattern_instance_parameter: str | None = None, allow_transform_copies: bool = False, + transform_copy_parameter: str = "transform_copy_refs", ) -> 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)) + source_ids.extend(_transform_copy_sources(node, session, transform_copy_parameter)) 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] @@ -430,37 +584,119 @@ def _sweep_path(node: FeaturePlanNode, session: "ExecutionSession") -> Any: path = node.params.get("path") or {} if not isinstance(path, dict): raise ValueError("sweep path must be an object") - plane = PlaneSpec.from_mapping(path.get("workplane") or {}) + spatial = path.get("workplane") is None + plane = None if spatial else PlaneSpec.from_mapping(path.get("workplane") or {}) segment = path.get("segment") or {} - if not isinstance(segment, dict): - raise ValueError("sweep path segment must be an object") - kind = str(segment.get("type") or "") - if kind == "line": - local_points = [segment.get("start"), segment.get("end")] - elif kind == "bspline": - local_points = segment.get("points") or [] - else: - raise ValueError(f"unsupported sweep path segment {kind!r}") - if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points): - raise ValueError("sweep path requires two-dimensional points") + segments = path.get("segments") - def point(value: list[float]) -> Vector3: + def local_point(value: Any) -> Vector3: + if plane is None: + raise ValueError("planar sweep path requires a workplane") + if not isinstance(value, list) or len(value) != 2: + raise ValueError("sweep path requires two-dimensional points") return vector_add( plane.origin_mm, vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))), ) - def tangent(value: Any) -> Vector3 | None: - if value is None: - return None + def local_vector(value: Any) -> Vector3: + if plane is None: + raise ValueError("planar sweep path requires a workplane") if not isinstance(value, list) or len(value) != 2: raise ValueError("sweep path tangent must contain two coordinates") return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))) + if segments is not None: + if segment: + raise ValueError("sweep path cannot mix segment and segments") + if not isinstance(segments, list) or len(segments) < 2: + raise ValueError("sweep segmented path requires at least two segments") + materialized: list[dict[str, Any]] = [] + for index, source in enumerate(segments): + if not isinstance(source, dict): + raise ValueError("sweep path segment must be an object") + kind = str(source.get("type") or "") + if kind not in {"line", "arc", "bspline"}: + raise ValueError(f"unsupported sweep path segment {kind!r}") + target: dict[str, Any] = {"type": kind} + if spatial: + if kind in {"line", "arc"}: + target["start_mm"] = source.get("start_mm") + target["end_mm"] = source.get("end_mm") + if kind == "arc": + target["center_mm"] = source.get("center_mm") + target["normal"] = source.get("normal") + target["radius_mm"] = source.get("radius_mm") + target["clockwise"] = bool(source.get("clockwise", False)) + if kind == "bspline": + target["points_mm"] = source.get("points_mm") + if source.get("start_tangent_mm") is not None: + target["start_tangent_mm"] = source.get("start_tangent_mm") + if source.get("end_tangent_mm") is not None: + target["end_tangent_mm"] = source.get("end_tangent_mm") + if source.get("parameters") is not None: + target["parameters"] = [float(value) for value in source.get("parameters") or []] + if source.get("periodic") is not None: + target["periodic"] = bool(source.get("periodic")) + materialized.append(target) + continue + if kind in {"line", "arc"}: + target["start_mm"] = local_point(source.get("start")) + target["end_mm"] = local_point(source.get("end")) + if kind == "arc": + target["center_mm"] = local_point(source.get("center")) + target["normal"] = plane.normal + target["clockwise"] = bool(source.get("clockwise", False)) + if kind == "bspline": + points = source.get("points") + if not isinstance(points, list) or len(points) < 2: + raise ValueError("sweep B-spline path requires at least two interpolation points") + target["points_mm"] = [local_point(value) for value in points] + if source.get("start_tangent") is not None: + target["start_tangent_mm"] = local_vector(source.get("start_tangent")) + if source.get("end_tangent") is not None: + target["end_tangent_mm"] = local_vector(source.get("end_tangent")) + if source.get("parameters") is not None: + target["parameters"] = [float(value) for value in source.get("parameters") or []] + if source.get("periodic") is not None: + target["periodic"] = bool(source.get("periodic")) + materialized.append(target) + return session.adapter.sweep_path_segments(materialized) + + if spatial: + raise ValueError("spatial sweep path requires captured segments") + if not isinstance(segment, dict): + raise ValueError("sweep path segment must be an object") + kind = str(segment.get("type") or "") + if kind == "line": + local_points = [segment.get("start"), segment.get("end")] + elif kind == "circle": + return session.adapter.sweep_circle_path( + local_point(segment.get("center")), + plane.x_dir, + plane.normal, + radius_mm=float(segment["radius_mm"]), + ) + elif kind == "arc": + return session.adapter.sweep_arc_path( + local_point(segment.get("start")), + local_point(segment.get("end")), + local_point(segment.get("center")), + plane.normal, + radius_mm=float(segment["radius_mm"]), + clockwise=bool(segment["clockwise"]), + ) + elif kind == "bspline": + local_points = segment.get("points") or [] + else: + raise ValueError(f"unsupported sweep path segment {kind!r}") + if len(local_points) < 2: + raise ValueError("sweep path requires two-dimensional points") + return session.adapter.sweep_path( - [point(value) for value in local_points], - start_tangent=tangent(segment.get("start_tangent")), - end_tangent=tangent(segment.get("end_tangent")), + [local_point(value) for value in local_points], + start_tangent=local_vector(segment["start_tangent"]) if segment.get("start_tangent") is not None else None, + end_tangent=local_vector(segment["end_tangent"]) if segment.get("end_tangent") is not None else None, parameters=[float(value) for value in segment.get("parameters") or []] or None, ) @@ -536,15 +772,10 @@ def _direct_output_role_records( return result -def _host_plane(resolution: SelectorResolution) -> PlaneSpec: +def _host_plane(resolution: SelectorResolution, adapter: Any) -> PlaneSpec: if resolution.record is None: raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved") - geometry = resolution.record.geometry - return PlaneSpec.from_mapping({ - "origin_mm": geometry["center_mm"], - "x_dir": [1, 0, 0] if abs(float(geometry["normal"][0])) < 0.9 else [0, 1, 0], - "normal": geometry["normal"], - }) + return adapter.planar_face_workplane(resolution.record.value) def _hole_starts( diff --git a/backend/engine/cdsl_engine/executors/context.py b/backend/engine/cdsl_engine/executors/context.py index a5f5771c..8d41ab43 100644 --- a/backend/engine/cdsl_engine/executors/context.py +++ b/backend/engine/cdsl_engine/executors/context.py @@ -6,6 +6,7 @@ that later features resolve through owner-qualified selectors. from __future__ import annotations +import math from typing import TYPE_CHECKING, Any from ..registry import atomic_executor @@ -61,3 +62,28 @@ def _reference_axis_executor(node: FeaturePlanNode, session: "ExecutionSession", # 7. 注册为拓扑上下文,并返回结果对象(携带该轴)。 session.topology.register_context(node.feature_id, axis) return session.result(node, context=axis) + + +@atomic_executor("reference_point") +def _reference_point_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + """Execute an explicit source-datum point without changing model topology.""" + del sketch + point = node.params.get("point_mm") + if ( + not isinstance(point, list) + or len(point) != 3 + or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in point) + ): + raise ValueError("reference point requires one finite three-dimensional point") + return session.result(node, include_body=False) + + +@atomic_executor("assign_variable") +def _assign_variable_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + """Replay a source variable declaration after lowering has consumed it.""" + del sketch + name = node.params.get("name") + value = node.params.get("value") + if not isinstance(name, str) or not name or not isinstance(value, (int, float)) or not math.isfinite(float(value)): + raise ValueError("assign_variable requires one finite named value") + return session.result(node, include_body=False) diff --git a/backend/engine/cdsl_engine/executors/dressup.py b/backend/engine/cdsl_engine/executors/dressup.py index 11335019..d7c20d4b 100644 --- a/backend/engine/cdsl_engine/executors/dressup.py +++ b/backend/engine/cdsl_engine/executors/dressup.py @@ -17,6 +17,60 @@ if TYPE_CHECKING: # pragma: no cover - import for type checkers only from ..session import ExecutionSession +def _single_member_dressup_members( + node: FeaturePlanNode, + session: "ExecutionSession", + body: Any, + selected_edges: list[Any], +) -> dict[str, Any] | None: + """Preserve unchanged body members after one exact-member dress-up. + + The adapter only reports Compound history when every selected edge maps to + one source solid. Keep the same proof at the body-graph layer: all other + members must appear unchanged in the result and exactly one result solid + must remain for the changed member. Otherwise aggregate replay remains + valid, but no member-lifecycle transfer is asserted. + """ + if not selected_edges or len(session.body_members) < 2: + return None + source_members: dict[str, Any] = {} + for member_id, member in session.body_members.items(): + solids = session.adapter.body_solids(member) + if len(solids) != 1: + return None + source_members[member_id] = solids[0] + selected_members = { + member_id + for edge in selected_edges + for member_id, member in source_members.items() + if any(edge.is_same(candidate) for candidate in member.edges()) + } + if len(selected_members) != 1: + return None + changed_member_id = next(iter(selected_members)) + result_solids = session.adapter.body_solids(body) + unchanged: dict[str, Any] = {} + matched_result_indexes: set[int] = set() + for member_id, member in source_members.items(): + if member_id == changed_member_id: + continue + matches = [ + index for index, result in enumerate(result_solids) + if session.topology._same_topology_value(member, result) + ] + if len(matches) != 1 or matches[0] in matched_result_indexes: + return None + matched_result_indexes.add(matches[0]) + unchanged[member_id] = result_solids[matches[0]] + changed = [ + result for index, result in enumerate(result_solids) + if index not in matched_result_indexes + ] + if len(changed) != 1: + return None + return {**unchanged, node.feature_id: changed[0]} + + def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: # 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。 @@ -28,11 +82,15 @@ def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> Featu if radius <= 0: raise ValueError("fillet radius_mm must be > 0") # 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。 + edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))) body, topology_delta = session.adapter.fillet_with_topology_delta( - session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))), + session.body, radius, edges, ) # 4. 登记新主体并返回结果。 - session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) + session.register_body( + node.feature_id, body, replay_node=node, topology_delta=topology_delta, + body_members=_single_member_dressup_members(node, session, body, edges), + ) return session.result(node) @@ -86,7 +144,10 @@ def _execute_chamfer(node: FeaturePlanNode, session: "ExecutionSession") -> Feat detail={"distance_mm": distance, "surface_count": len(session.surface_members)}, )) # 5. 登记新主体并返回结果。 - session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) + session.register_body( + node.feature_id, body, replay_node=node, topology_delta=topology_delta, + body_members=_single_member_dressup_members(node, session, body, edges), + ) return session.result(node, diagnostics=diagnostics) diff --git a/backend/engine/cdsl_engine/executors/holes.py b/backend/engine/cdsl_engine/executors/holes.py index bd6ad94d..7f8b631d 100644 --- a/backend/engine/cdsl_engine/executors/holes.py +++ b/backend/engine/cdsl_engine/executors/holes.py @@ -32,22 +32,16 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard: if session.body is None: raise ValueError("hole feature has no body") scope_feature_id = node.params.get("scope_feature_id") + scoped_body = None if scope_feature_id is not None: if not isinstance(scope_feature_id, str) or not scope_feature_id: raise ValueError("hole scope_feature_id is invalid") - if len(session.body_members) != 1: - raise ValueError("hole scope body is no longer the sole active member") scoped_body = session.body_members.get(scope_feature_id) if scoped_body is None: raise ValueError("hole scope body is no longer an independently selectable member") scoped_solids = session.adapter.body_solids(scoped_body) - active_solids = session.adapter.body_solids(session.body) - if ( - len(scoped_solids) != 1 - or len(active_solids) != 1 - or not scoped_solids[0].is_same(active_solids[0]) - ): - raise ValueError("hole scope body does not match the active body") + if not scoped_solids: + raise ValueError("hole scope body has no active solid") # 2. 确定宿主面 host_face: host_selector = node.params.get("host_face") if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict): @@ -62,8 +56,15 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard: selector = next((item for item in selectors if item.get("kind") == "face"), None) if selector is None: raise ValueError("hole requires host_face selector or frame") - host = _host_plane(session.resolve(selector)) - positions_are_local = False + host = _host_plane(session.resolve(selector), session.adapter) + intent = selector.get("selector_intent") if isinstance(selector, dict) else None + # Existing selector-hosted holes store world positions. Only the new + # runtime-attached COPY(CAP_FACE) sketch preserves local coordinates + # until its exact host relation has materialized. + positions_are_local = ( + isinstance(intent, dict) + and intent.get("copy_contract") == "primary_cut_cap_face_workplane" + ) # 3. 解析孔规格 HoleSpec(直径、深度、类型等,wizard 模式提供额外默认值)。 spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard) # 4. A host-face normal is an outward B-rep orientation, so its inverse @@ -78,7 +79,7 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard: spec, _hole_starts(spec, host_plane=host, positions_are_local=positions_are_local), inward, - session.adapter.body_span(session.body, inward) + 2.0, + session.adapter.body_span(scoped_body or session.body, inward) + 2.0, ) # 6. 从主体上减去工具实体,登记新主体并返回结果。 # thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔 @@ -91,8 +92,21 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard: message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore", feature_id=node.feature_id, )) - result_body = session.adapter.cut(session.body, tool) - members = {scope_feature_id: result_body} if scope_feature_id is not None else None + if scope_feature_id is None: + result_body = session.adapter.cut(session.body, tool) + members = None + else: + # CADFS ``scope`` identifies the target body member. Never apply the + # cutter to the aggregate merely because unrelated live members share + # the exported part; unchanged members remain exact body-graph nodes. + result_body = session.adapter.cut(scoped_body, tool) + members = {**session.body_members, scope_feature_id: result_body} + body = None + for member in members.values(): + body = session.adapter.combine(body, member) + if body is None: + raise ValueError("hole scope cut produced no active body") + result_body = body session.register_body( node.feature_id, result_body, replay_node=node, body_members=members, ) diff --git a/backend/engine/cdsl_engine/executors/loft_sweep.py b/backend/engine/cdsl_engine/executors/loft_sweep.py index 4cbb1ba5..00cc3717 100644 --- a/backend/engine/cdsl_engine/executors/loft_sweep.py +++ b/backend/engine/cdsl_engine/executors/loft_sweep.py @@ -5,8 +5,8 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any from ..registry import atomic_executor -from ..topology import FeaturePlanNode, FeatureResult -from .common import _sweep_path +from ..topology import FeaturePlanNode, FeatureResult, TopologyRecord +from .common import _apply_primary_tool, _sweep_path if TYPE_CHECKING: # pragma: no cover - import for type checkers only from ..session import ExecutionSession @@ -66,13 +66,18 @@ def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketc profile = sketch or session.sketches.get(str(node.sketch_id)) if profile is None: raise ValueError("sweep has no resolved profile sketch") - faces = session.adapter.faces_for_sketch(profile) + faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(profile) if len(faces) != 1: raise ValueError("sweep requires exactly one closed profile region") solid, topology_delta = session.adapter.sweep_with_topology_delta( faces[0], _sweep_path(node, session), is_frenet=bool(node.params.get("is_frenet", False)), ) + if node.atomic_id == "sweep_cut": + # The PipeShell is a transient cutting tool. Its own builder history + # cannot become selector provenance after the BRepAlgoAPI_Cut; only + # the cut's exact target-side delta may survive registration. + return _apply_primary_tool(node, session, solid, cutting=True) 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 @@ -80,10 +85,37 @@ def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketc # 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) + topology_anchors: list[TopologyRecord] = [] + if topology_delta is not None: + for index, spec in enumerate(source_anchor_specs): + kind = spec.get("kind") + if kind not in {"edge", "vertex"} or spec.get("value") is None: + continue + source_entity = spec.get("source_entity") + source_entities = tuple(spec.get("source_entities") or ()) + if not isinstance(source_entity, tuple) and not source_entities: + continue + topology_anchors.append(TopologyRecord( + record_id=f"anchor:{node.feature_id}:{kind}:{index}", + kind=kind, + feature_id=node.feature_id, + geometry={}, + value=spec["value"], + source_entity=source_entity if isinstance(source_entity, tuple) else None, + source_entities=source_entities, + )) + session.register_body( + node.feature_id, body, replay_node=node, topology_delta=topology_delta, + topology_anchors=topology_anchors, + ) return session.result(node) @atomic_executor("sweep_add") def _sweep_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: return _execute_sweep_add(node, session, sketch) + + +@atomic_executor("sweep_cut") +def _sweep_cut_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + return _execute_sweep_add(node, session, sketch) diff --git a/backend/engine/cdsl_engine/executors/surfaces.py b/backend/engine/cdsl_engine/executors/surfaces.py index 5ef44378..a7ae2e80 100644 --- a/backend/engine/cdsl_engine/executors/surfaces.py +++ b/backend/engine/cdsl_engine/executors/surfaces.py @@ -73,3 +73,21 @@ def _execute_extrude_surface(node: FeaturePlanNode, session: "ExecutionSession") def _extrude_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_extrude_surface(node, session) + + +def _execute_loft_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + profile_ids = node.params.get("profile_sketch_ids") or [] + profiles: list[dict[str, Any]] = [] + for sketch_id in profile_ids: + profile = session.sketches.get(str(sketch_id)) + if profile is None: + raise ValueError(f"surface loft profile sketch {sketch_id!r} is not resolved") + profiles.append(profile) + surface_id = session.register_surface(node.feature_id, session.adapter.loft_surface(profiles)) + return session.result(node, include_body=False, surface_id=surface_id) + + +@atomic_executor("loft_surface") +def _loft_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_loft_surface(node, session) diff --git a/backend/engine/cdsl_engine/extents.py b/backend/engine/cdsl_engine/extents.py index 1df0a516..3eb54da5 100644 --- a/backend/engine/cdsl_engine/extents.py +++ b/backend/engine/cdsl_engine/extents.py @@ -8,6 +8,7 @@ selector resolution, never on executors. from __future__ import annotations +import math from typing import TYPE_CHECKING, Any from .runtime_base import ExtentVector, FeatureExecutionError @@ -44,12 +45,29 @@ def _targeted_extent_vector( end_condition: dict[str, Any] | None = None, offset_mm: float | None = None, ) -> ExtentVector: - if session.body is None: + reference = _extent_reference(node, end_condition) if condition != "through_next" else None + source_vertex_point: Vector3 | None = None + if condition == "up_to_vertex" and isinstance(reference, dict) and reference.get("kind") == "source_vertex": + raw_point = reference.get("point_mm") + if not ( + isinstance(raw_point, list) + and len(raw_point) == 3 + and all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in raw_point) + ): + raise FeatureExecutionError( + "invalid_source_vertex_extent", + "The source-vertex extent datum must contain one finite 3D point", + extent=condition, + ) + source_vertex_point = (float(raw_point[0]), float(raw_point[1]), float(raw_point[2])) + elif session.body is None: raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition) if condition == "through_next": target = session.body + elif source_vertex_point is not None: + target = None else: - reference = _extent_reference(node, end_condition) + assert isinstance(reference, dict) resolution = session.resolve(reference) if resolution.status != "resolved" or resolution.record is None: raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "extent target was not resolved") @@ -62,7 +80,7 @@ def _targeted_extent_vector( ) target = resolution.record.value if condition == "up_to_vertex": - target_point = session.adapter.vertex_coordinates(target) + target_point = source_vertex_point or session.adapter.vertex_coordinates(target) projections = [ vector_dot(vector_subtract(target_point, point), direction) for face in faces @@ -89,6 +107,19 @@ def _targeted_extent_vector( distance = session.adapter.uniform_intersection_distance(target, faces, direction) except ValueError as error: message = str(error) + if condition == "up_to_surface" and message == "extent target is not reached by every profile ray": + # Do not reinterpret a partially hit finite face: that path + # has explicit trimmed-solid semantics below. Only a wholly + # unreachable planar face may terminate on its supporting + # plane, and the adapter proves one positive, uniform + # profile-to-plane distance before returning it. + if not session.adapter.target_has_forward_intersection(target, faces, direction): + try: + distance = session.adapter.uniform_planar_supporting_surface_distance(target, faces, direction) + except ValueError: + pass + else: + return ExtentVector(vector_scale(direction, distance)) code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached" if condition in {"up_to_surface", "through_next"}: # #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未 diff --git a/backend/engine/cdsl_engine/pattern_transform.py b/backend/engine/cdsl_engine/pattern_transform.py index bb9c832b..1ffd8ed8 100644 --- a/backend/engine/cdsl_engine/pattern_transform.py +++ b/backend/engine/cdsl_engine/pattern_transform.py @@ -409,6 +409,28 @@ def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle for key in ("x_dir", "y_dir", "normal"): if path_plane.get(key): path_plane[key] = list(_rotated_vector(tuple(float(v) for v in path_plane[key]), axis, angle_rad)) + elif isinstance(path, dict) and isinstance(path.get("segments"), list): + # A source-only spatial sweep path has no common workplane. Its + # captured points and directions are absolute, so replayed circular + # pattern instances must rotate each geometric field independently. + for segment in path["segments"]: + if not isinstance(segment, dict): + continue + for key in ("start_mm", "end_mm", "center_mm"): + value = segment.get(key) + if isinstance(value, list) and len(value) == 3: + segment[key] = _rotated_point(value, axis, angle_rad) + points = segment.get("points_mm") + if isinstance(points, list): + segment["points_mm"] = [ + _rotated_point(point, axis, angle_rad) + for point in points + if isinstance(point, list) and len(point) == 3 + ] + for key in ("normal", "start_tangent_mm", "end_tangent_mm"): + value = segment.get(key) + if isinstance(value, list) and len(value) == 3: + segment[key] = list(_rotated_vector(tuple(float(v) for v in value), axis, angle_rad)) host = params.get("host_face") host_frame = host.get("frame") if isinstance(host, dict) else None positions_are_local = isinstance(host_frame, dict) and all( diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index 407769c4..4fbdbfb9 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -4,17 +4,19 @@ "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", "planar_imprint"], + "runtime_supported_profiles": ["circle", "polygon", "analytic_contours", "multi_source_regions", "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},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "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},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_or_primary_add_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, "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"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, "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"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, - "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":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.1","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":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, "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"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "loft_surface": {"atomic_id":"loft_surface","contract_version":"1.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":2,"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":["surface_shell"],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, "loft_add_with_cap_face": {"atomic_id":"loft_add_with_cap_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":1,"maxItems":1,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["loft_cap_face","loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, "sweep_add": {"atomic_id":"sweep_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, - "extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, - "extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}}, + "sweep_cut": {"atomic_id":"sweep_cut","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"symmetric_direct_prism_two_sided_up_to_surface_cap_pair","requires_immediate_owner":true},"params.reverse_end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"symmetric_direct_prism_two_sided_up_to_surface_cap_pair","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_or_primary_add_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}}, "extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}}, "extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, "revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, @@ -33,6 +35,8 @@ "thread_cut": {"atomic_id":"thread_cut","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"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","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, "reference_plane": {"atomic_id":"reference_plane","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"plane":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"x_dir":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"normal":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","x_dir","normal"],"additionalProperties":false}},"required":["plane"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_plane_nonzero_normal"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, "reference_axis": {"atomic_id":"reference_axis","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"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}},"required":["axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_axis_nonzero_direction"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "reference_point": {"atomic_id":"reference_point","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"point_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["point_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":["reference_point_finite"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "assign_variable": {"atomic_id":"assign_variable","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":256},"value":{"type":"number"},"value_kind":{"enum":["any","length"]}},"required":["name","value","value_kind"],"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":["assign_variable_finite"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, "hole_wizard": { "runtime_capability": {"body_mutating": true, "requires_active_body": true, "replayable": true, "requires_selector": false, "open_profile_ok": false}, "atomic_id": "hole_wizard", @@ -67,11 +71,11 @@ "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"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, "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"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, "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"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, - "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":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.2","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"},"targetless_body_set":{"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":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, "transform_bodies": { "runtime_capability": {"body_mutating": false, "requires_active_body": false, "replayable": false, "requires_selector": false, "open_profile_ok": false}, "atomic_id": "transform_bodies", - "contract_version": "3.3", + "contract_version": "3.4", "fragment_shape": {"sketch": "forbidden", "params": "required_object", "selector_tokens": "forbidden"}, "author_params_schema": { "type": "object", @@ -79,6 +83,7 @@ "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}, + "source_member_aliases": {"type": "array", "items": {"type": "object", "properties": {"source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "active_member_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}}, "required": ["source_feature_id", "active_member_feature_id"], "additionalProperties": false}, "minItems": 1, "maxItems": 16, "uniqueItems": true}, "transform": { "type": "object", "properties": { @@ -129,6 +134,9 @@ "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. 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." }, + "multi_source_regions": { + "summary": "A direct qUnion of two or more closed qSketchRegion sources on exactly one shared workplane. Every child profile is solved as an independent region set before union, so cross-source nested contours are never reclassified as holes. This is profile geometry only and carries no single-source topology anchor." + }, "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/registry.py b/backend/engine/cdsl_engine/registry.py index 129ccfdb..0a829cbb 100644 --- a/backend/engine/cdsl_engine/registry.py +++ b/backend/engine/cdsl_engine/registry.py @@ -29,10 +29,10 @@ class AtomicExecutor(Protocol): #: at import time instead of surfacing as an unknown-atomic blocker later. 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", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add", + "extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "loft_surface", "sweep_add", "sweep_cut", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", - "reference_plane", "reference_axis", + "reference_plane", "reference_axis", "reference_point", "assign_variable", "hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror", "pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies", "thread_add", "thread_cut", diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index 53bcbbb2..3e0a07b8 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -169,6 +169,8 @@ class IncrementalCdslExecution: raise ValueError(f"Feature {node.feature_id} is not runtime eligible: {detail}") return None try: + if node.sketch_id is not None: + self.session.resolve_sketch_attachment(str(node.sketch_id), feature_id=node.feature_id) return execute_node(node, self.session) except Exception as error: diagnostic = _execution_diagnostic(error, node, self.session) diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index e29712a3..ebcc1812 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -43,6 +43,7 @@ from .topology import ( SelectorResolution, TopologyDelta, TopologyDeltaRelation, + TopologyBlendRelation, TopologySectionRelation, TopologyLineage, TopologyRecord, @@ -65,6 +66,7 @@ __all__ = [ "ThreadSpec", "TopologyDelta", "TopologyDeltaRelation", + "TopologyBlendRelation", "TopologySectionRelation", "TopologyLineage", "TopologyRecord", diff --git a/backend/engine/cdsl_engine/selector_capabilities.py b/backend/engine/cdsl_engine/selector_capabilities.py index f841e3ba..d760f60e 100644 --- a/backend/engine/cdsl_engine/selector_capabilities.py +++ b/backend/engine/cdsl_engine/selector_capabilities.py @@ -41,6 +41,22 @@ _CAPABILITIES = { "CADFS FeatureScript 1511 exported query history", "direct prism source edge to qualified start/end cap edge kernel lineage", ), + ("CAP_EDGE", "2491"): SelectorQueryCapability( + "CAP_EDGE", + "2491", + "onshape/std/geometry.fs", + "2491.0", + "CADFS FeatureScript 2491 symmetric extrusion query history", + "immediate direct two-sided prism source edge to qualified start/end cap edge kernel lineage", + ), + ("CAP_VERTEX", "1511"): SelectorQueryCapability( + "CAP_VERTEX", + "1511", + "onshape/std/geometry.fs", + "1511.0", + "CADFS FeatureScript 1511 exported query history", + "direct new-body prism source-vertex to qualified start/end cap vertex kernel lineage", + ), ("OFFSET_FACE", "1511"): SelectorQueryCapability( "OFFSET_FACE", "1511", @@ -49,6 +65,14 @@ _CAPABILITIES = { "CADFS FeatureScript 1511 exported query history", "shell offset-face operation role with true dependency qualification", ), + ("OFFSET_EDGE", "1511"): SelectorQueryCapability( + "OFFSET_EDGE", + "1511", + "onshape/std/geometry.fs", + "1511.0", + "CADFS FeatureScript 1511 exported query history", + "one-sided direct-prism retained cap edge through an immediate shell continuation", + ), # These entries authorize the deliberately narrow direct-prism lineage # path. The capability matrix keeps the broader query families explicitly # partial; all other generator and lifecycle combinations remain rejected. @@ -68,6 +92,14 @@ _CAPABILITIES = { "CADFS FeatureScript 1511 exported query history", "kernel-lineage resolver contract only", ), + ("SWEPT_EDGE", "2491"): SelectorQueryCapability( + "SWEPT_EDGE", + "2491", + "onshape/std/geometry.fs", + "2491.0", + "CADFS FeatureScript 2491 exported full-revolve query history", + "full independent solid revolve source vertex to exact MakeRevol Generated(vertex) kernel lineage", + ), ("SWEPT_BODY", "1511"): SelectorQueryCapability( "SWEPT_BODY", "1511", @@ -84,6 +116,30 @@ _CAPABILITIES = { "CADFS FeatureScript 1511 exported query history; BRepAlgoAPI boolean Generated(face) and SectionEdges() exact handles", "two source-qualified boolean input faces to one final section edge kernel lineage", ), + ("BLEND_EDGE", "1511"): SelectorQueryCapability( + "BLEND_EDGE", + "1511", + "onshape/std/geometry.fs", + "1511.0", + "CADFS FeatureScript 1511 exported direct dress-up query history", + "one direct-prism CAP_EDGE and CAP_FACE source pair to one exact final fillet/chamfer patch boundary", + ), + ("BLEND_FACE", "1511"): SelectorQueryCapability( + "BLEND_FACE", + "1511", + "onshape/std/geometry.fs", + "1511.0", + "CADFS FeatureScript 1511 exported direct dress-up sketch history", + "one immediate direct-prism CAP_EDGE to its exact active native fillet/chamfer generated patch face", + ), + ("COPY", "1511"): SelectorQueryCapability( + "COPY", + "1511", + "onshape/std/geometry.fs", + "1511.0", + "CADFS FeatureScript 1511 primary boolean COPY query history", + "immediate primary-cut COPY(CAP_EDGE) projection over an exact transient-prism and cut lineage", + ), } @@ -112,6 +168,367 @@ def known_selector_query_versions(query_family: str) -> tuple[str, ...]: return tuple(sorted(version for family, version in _CAPABILITIES if family == query_family)) +_PROVEN_OPERAND_SET_CONTRACTS = { + "proven_operand_union": ("union", "qUnion", 2, None), + "proven_operand_intersection": ("intersection", "qIntersection", 2, None), + "proven_operand_subtraction": ("subtraction", "qSubtraction", 2, 2), +} + + +def proven_operand_set_contract_error(selector: dict[str, Any]) -> str | None: + """Validate a narrowly executable FeatureScript query-set contract. + + The parent is a set expression, not another geometric/topology lookup. + Each ordered child therefore has to be a fully expressed provenance + selector whose typed expression is exactly the matching source-AST leaf. + The direct bridge deliberately requires each child to resolve to a + non-empty proven active set. It does not mistake an absent lineage for a + mathematically empty FeatureScript operand. + """ + intent = selector.get("selector_intent") + has_set_fields = ( + selector.get("query_operands") is not None + or isinstance(intent, dict) and intent.get("query_set_contract") is not None + or isinstance(intent, dict) and intent.get("query_family") == "QUERY_SET" + ) + if not has_set_fields: + return None + if not isinstance(intent, dict): + return "query-set selector requires selector_intent" + if intent.get("query_family") != "QUERY_SET": + return "query-set selector intent must use query_family QUERY_SET" + contract = intent.get("query_set_contract") + contract_spec = _PROVEN_OPERAND_SET_CONTRACTS.get(contract) + if contract_spec is None: + return "query-set selector has an unsupported set contract" + operator, source_name, minimum_operands, maximum_operands = contract_spec + kind = selector.get("kind") + if kind not in {"face", "edge"} or intent.get("set_kind") != kind: + return "query-set selector kind must be an explicit face or edge set kind" + if ( + selector.get("source") != "runtime_snapshot" + or intent.get("evidence") != "kernel_history" + or intent.get("body_scope") != "active_member" + or intent.get("empty_policy") != "reject" + or intent.get("multiple_policy") != "all" + ): + return "query-set selector does not declare the active-member set policy" + if any(selector.get(key) is not None for key in ( + "stable_id", "snapshot_id", "geometry", "binding_feature_id", + "owner_feature_id", "output_role", "output_role_source", + "matched_selectors", "intersection_of", + )): + return "query-set selector cannot mix stable, output-role, or geometric evidence" + policy = intent.get("derivation_policy") + if ( + not isinstance(policy, dict) + or policy.get("multiplicity") != "source_qualified" + or not isinstance(policy.get("allowed"), list) + or not policy["allowed"] + ): + return "query-set selector has an invalid set derivation policy" + expression = intent.get("query_expr") + root = expression.get("root") if isinstance(expression, dict) else None + operands = selector.get("query_operands") + if ( + not isinstance(root, dict) + or root.get("node") != "set" + or root.get("operator") != operator + or not isinstance(root.get("operands"), list) + or not isinstance(operands, list) + or len(root["operands"]) < minimum_operands + or len(root["operands"]) != len(operands) + or maximum_operands is not None and len(root["operands"]) != maximum_operands + ): + return f"{source_name} selector operands do not match its source expression" + source_query = intent.get("source_query") + source_signature = ( + source_query.get("featurescript_version"), + source_query.get("standard_library"), + source_query.get("standard_library_version"), + ) if isinstance(source_query, dict) else None + def validate_child( + operand: Any, + expression_leaf: Any, + *, + index_path: str, + ) -> str | None: + """Validate one recursive child against its exact source AST node.""" + if not isinstance(operand, dict) or operand.get("kind") != kind: + return f"{source_name} operand {index_path} does not have the parent set kind" + if ( + operand.get("source") != "runtime_snapshot" + or any(operand.get(key) is not None for key in ( + "stable_id", "snapshot_id", "geometry", "binding_feature_id", + "matched_selectors", "intersection_of", + )) + ): + return f"{source_name} operand {index_path} has unsupported non-provenance evidence" + operand_intent = operand.get("selector_intent") + if not isinstance(operand_intent, dict): + return f"{source_name} operand {index_path} is missing selector intent" + operand_expression = operand_intent.get("query_expr") + if not isinstance(operand_expression, dict) or operand_expression.get("root") != expression_leaf: + return f"{source_name} operand {index_path} does not match its source expression leaf" + operand_source = operand_intent.get("source_query") + operand_signature = ( + operand_source.get("featurescript_version"), + operand_source.get("standard_library"), + operand_source.get("standard_library_version"), + ) if isinstance(operand_source, dict) else None + if operand_signature != source_signature: + return f"{source_name} operand {index_path} has incompatible FeatureScript source metadata" + + nested_operands = operand.get("query_operands") + if nested_operands is not None or operand_intent.get("query_family") == "QUERY_SET": + nested_error = proven_operand_set_contract_error(operand) + if nested_error is not None: + return f"{source_name} operand {index_path} has invalid nested query set: {nested_error}" + return None + + if operand_intent.get("query_family") in {None, "GEOMETRIC"}: + return f"{source_name} operand {index_path} is not a direct provenance selector" + operand_policy = operand_intent.get("derivation_policy") + if not isinstance(operand_policy, dict) or operand_policy.get("multiplicity") == "none": + return f"{source_name} operand {index_path} is not executable provenance" + return None + + for index, (operand, expression_leaf) in enumerate(zip(operands, root["operands"])): + error = validate_child(operand, expression_leaf, index_path=str(index)) + if error is not None: + return error + return None + + +def blend_face_selector_contract_error(selector: dict[str, Any]) -> str | None: + """Validate the narrow runtime-attached direct-prism BLEND_FACE form.""" + intent = selector.get("selector_intent") + if not isinstance(intent, dict) or intent.get("query_family") != "BLEND_FACE": + return None + # Preserve generic source queries as deferred diagnostics. Only the + # explicitly declared direct-prism contract is executable. + if intent.get("blend_face_source") is None: + return None + source = intent.get("blend_face_source") + policy = intent.get("derivation_policy") + if ( + selector.get("kind") != "face" + or selector.get("source") != "runtime_snapshot" + or intent.get("kind") != "face" + or intent.get("evidence") != "kernel_history" + or not isinstance(policy, dict) + or policy.get("allowed") != ["boundary"] + or policy.get("multiplicity") != "one" + or not isinstance(source, dict) + or source.get("query_family") != "CAP_EDGE" + or not isinstance(source.get("owner_feature_id"), str) + or not isinstance(source.get("source_entity"), dict) + or source.get("lineage_role") not in {"extrude.start", "extrude.end"} + or any(selector.get(key) is not None for key in ( + "stable_id", "snapshot_id", "geometry", "binding_feature_id", + "output_role", "query_input", "query_operands", "matched_selectors", "intersection_of", + )) + ): + return "BLEND_FACE selector does not declare a supported direct-prism patch contract" + source_entity = source["source_entity"] + if not all(isinstance(source_entity.get(key), str) and source_entity[key] for key in ("sketch_id", "entity_id")): + return "BLEND_FACE CAP_EDGE source is incomplete" + return None + + +def copy_selector_contract_error(selector: dict[str, Any]) -> str | None: + """Validate the narrow source-qualified primary-cut COPY projection.""" + intent = selector.get("selector_intent") + if not isinstance(intent, dict) or intent.get("query_family") != "COPY": + return None + # A general COPY query remains deliberately deferred. Validate only the + # explicit contract form so it retains its existing unsupported diagnostic + # instead of being misreported as a malformed executable bridge. + if intent.get("copy_contract") is None and selector.get("query_input") is None: + return None + copy_contract = intent.get("copy_contract") + face_contracts = { + "primary_cut_cap_face_workplane": "CAP_FACE", + "primary_cut_swept_face_workplane": "SWEPT_FACE", + } + expected_kind = "face" if copy_contract in face_contracts else "edge" + expected_input_family = face_contracts.get(copy_contract, "CAP_EDGE") + if ( + selector.get("kind") != expected_kind + or selector.get("source") != "runtime_snapshot" + or intent.get("kind") != expected_kind + or intent.get("evidence") != "kernel_history" + or copy_contract not in {"primary_cut_cap_edge", *face_contracts} + or any(selector.get(key) is not None for key in ( + "stable_id", "snapshot_id", "geometry", "binding_feature_id", + "output_role", "query_operands", "matched_selectors", "intersection_of", + )) + ): + return "COPY selector does not declare a supported primary-cut contract" + policy = intent.get("derivation_policy") + if ( + not isinstance(policy, dict) + or policy.get("allowed") != ["boundary", "continuation"] + or policy.get("multiplicity") != "one" + ): + return "COPY selector has an invalid primary-cut derivation policy" + query_input = selector.get("query_input") + input_intent = query_input.get("selector_intent") if isinstance(query_input, dict) else None + if ( + not isinstance(query_input, dict) + or query_input.get("kind") != expected_kind + or query_input.get("owner_feature_id") != selector.get("owner_feature_id") + or query_input.get("source") != "runtime_snapshot" + or not isinstance(input_intent, dict) + or input_intent.get("query_family") != expected_input_family + or input_intent.get("evidence") != "kernel_history" + or ( + copy_contract != "primary_cut_swept_face_workplane" + and input_intent.get("lineage_role") not in {"extrude.start", "extrude.end"} + ) + ): + return "COPY selector requires one same-owner primary-cut query input" + if expected_kind == "edge" and not isinstance(input_intent.get("source_entity"), dict): + return "COPY CAP_EDGE selector requires one source-profile edge" + if copy_contract == "primary_cut_cap_face_workplane" and (not isinstance(input_intent.get("source_entities"), list) or not input_intent["source_entities"]): + return "COPY CAP_FACE selector requires its complete source-profile edge set" + if copy_contract == "primary_cut_swept_face_workplane" and not isinstance(input_intent.get("source_entity"), dict): + return "COPY SWEPT_FACE selector requires one source-profile edge" + input_policy = input_intent.get("derivation_policy") + if ( + not isinstance(input_policy, dict) + or input_policy.get("allowed") != ["boundary", "continuation"] + or input_policy.get("multiplicity") != "one" + ): + return "COPY selector input has an invalid primary-cut derivation policy" + source_query = intent.get("source_query") + input_source = input_intent.get("source_query") + signature = ( + source_query.get("featurescript_version"), + source_query.get("standard_library"), + source_query.get("standard_library_version"), + ) if isinstance(source_query, dict) else None + input_signature = ( + input_source.get("featurescript_version"), + input_source.get("standard_library"), + input_source.get("standard_library_version"), + ) if isinstance(input_source, dict) else None + if signature != input_signature: + return "COPY selector input has incompatible FeatureScript source metadata" + expression = intent.get("query_expr") + input_expression = input_intent.get("query_expr") + root = expression.get("root") if isinstance(expression, dict) else None + input_root = input_expression.get("root") if isinstance(input_expression, dict) else None + # CADFS sketches commonly wrap one workplane query in qUnion([Q0]). That + # is a set-preserving identity wrapper, not a different COPY relation. + copy_root = root + if ( + isinstance(copy_root, dict) + and copy_root.get("node") == "set" + and copy_root.get("operator") == "union" + and isinstance(copy_root.get("operands"), list) + and len(copy_root["operands"]) == 1 + ): + copy_root = copy_root["operands"][0] + if ( + not isinstance(copy_root, dict) + or copy_root.get("node") != "topology_query" + or copy_root.get("topology_type") != {"node": "literal", "value": "COPY"} + or not isinstance(copy_root.get("arguments"), list) + or len(copy_root["arguments"]) != 1 + or not isinstance(copy_root["arguments"][0], dict) + or copy_root["arguments"][0].get("node") != "map" + or not isinstance(input_root, dict) + ): + return "COPY selector has no typed COPY source expression" + derived = [ + entry.get("value") + for entry in copy_root["arguments"][0].get("entries") or () + if isinstance(entry, dict) and entry.get("key") == "derivedFrom" + ] + if len(derived) != 1 or derived[0] != input_root: + return "COPY selector input does not match the COPY derivedFrom expression" + return None + + +def owner_body_selector_contract_error(selector: dict[str, Any]) -> str | None: + """Validate the narrow exact-input ``qOwnerBody`` contract. + + ``qOwnerBody`` is an ownership projection, not a request to search the + current aggregate for a body. The only executable form therefore keeps + one nested, already-proven topology selector and asks the runtime to + project its exact active ``body_id`` to the matching body record. + """ + intent = selector.get("selector_intent") + if not isinstance(intent, dict) or intent.get("query_family") != "OWNER_BODY": + return None + if ( + selector.get("kind") != "body" + or selector.get("source") != "runtime_snapshot" + or intent.get("kind") != "body" + or intent.get("evidence") != "kernel_history" + or intent.get("owner_body_contract") != "exact_input_owner" + or intent.get("body_scope") != "active_member" + or intent.get("empty_policy") != "reject" + or intent.get("multiple_policy") != "one" + or selector.get("output_role") is not None + or any(selector.get(key) is not None for key in ( + "stable_id", "snapshot_id", "geometry", "binding_feature_id", + "matched_selectors", "intersection_of", "query_operands", + )) + ): + return "qOwnerBody selector does not declare the exact active-member owner contract" + policy = intent.get("derivation_policy") + if ( + not isinstance(policy, dict) + or policy.get("multiplicity") != "one" + or policy.get("allowed") != ["boundary"] + ): + return "qOwnerBody selector has an invalid owner derivation policy" + expression = intent.get("query_expr") + root = expression.get("root") if isinstance(expression, dict) else None + if ( + not isinstance(root, dict) + or root.get("node") != "filter" + or root.get("filter") != "owner_body" + or not isinstance(root.get("input"), dict) + or not isinstance(selector.get("query_input"), dict) + ): + return "qOwnerBody selector does not retain one typed query input" + query_input = selector["query_input"] + if query_input.get("kind") == "body": + return "qOwnerBody input must name a topology member, not a body" + input_intent = query_input.get("selector_intent") + if not isinstance(input_intent, dict): + return "qOwnerBody input is missing selector intent" + input_expression = input_intent.get("query_expr") + if not isinstance(input_expression, dict) or input_expression.get("root") != root.get("input"): + return "qOwnerBody input does not match its source expression" + source_query = intent.get("source_query") + input_source = input_intent.get("source_query") + source_signature = ( + source_query.get("featurescript_version"), + source_query.get("standard_library"), + source_query.get("standard_library_version"), + ) if isinstance(source_query, dict) else None + input_signature = ( + input_source.get("featurescript_version"), + input_source.get("standard_library"), + input_source.get("standard_library_version"), + ) if isinstance(input_source, dict) else None + if source_signature != input_signature: + return "qOwnerBody input has incompatible FeatureScript source metadata" + if ( + query_input.get("query_operands") is not None + or input_intent.get("query_family") in {None, "GEOMETRIC", "OWNER_BODY", "QUERY_SET"} + ): + return "qOwnerBody input is not a proven topology selector" + input_policy = input_intent.get("derivation_policy") + if not isinstance(input_policy, dict) or input_policy.get("multiplicity") == "none": + return "qOwnerBody input is not executable provenance" + return None + + def known_selector_query_standard_library_versions(query_family: str) -> tuple[tuple[str, str], ...]: """Expose exact direct imports that back a registered query contract.""" return tuple(sorted({ @@ -121,6 +538,103 @@ def known_selector_query_standard_library_versions(query_family: str) -> tuple[t })) +def _direct_profile_source_entity_ids(sketch: dict[str, Any]) -> set[str]: + """Return source labels retained by one unchanged direct profile.""" + profile = sketch.get("profile") or {} + source_ids: set[str] = set() + direct_circle = profile.get("source_entity_id") if profile.get("type") == "circle" else None + if isinstance(direct_circle, str) and direct_circle: + source_ids.add(direct_circle) + for contour in profile.get("contours") or (): + if not isinstance(contour, dict): + continue + for segment in contour.get("segments") or (): + source_entity_id = segment.get("source_entity_id") if isinstance(segment, dict) else None + if isinstance(source_entity_id, str) and source_entity_id: + source_ids.add(source_entity_id) + return source_ids + + +def _has_one_exact_retained_source_edge( + selected: dict[str, Any], + source: dict[str, Any], + source_entity_id: str, +) -> bool: + """Prove one selected prism edge is unchanged from its source sketch.""" + selected_profile = selected.get("profile") or {} + source_profile = source.get("profile") or {} + if selected.get("workplane") != source.get("workplane"): + return False + + def segments(profile: dict[str, Any]) -> list[dict[str, Any]]: + if profile.get("type") != "analytic_contours": + return [] + return [ + segment + for contour in profile.get("contours") or () + if isinstance(contour, dict) + for segment in contour.get("segments") or () + if isinstance(segment, dict) and segment.get("source_entity_id") == source_entity_id + ] + + selected_segments = segments(selected_profile) + source_segments = segments(source_profile) + return len(selected_segments) == len(source_segments) == 1 and selected_segments[0] == source_segments[0] + + +def is_immediate_retained_source_prism_swept_face_extent( + selector: dict[str, Any], + producer: dict[str, Any] | None, + sketches: dict[str, dict[str, Any]], +) -> bool: + """Validate the immediate IMPRINT-prism wall extent contract.""" + if not isinstance(selector, dict) or not isinstance(producer, dict): + return False + intent = selector.get("selector_intent") + source_entity = intent.get("source_entity") if isinstance(intent, dict) else None + policy = intent.get("derivation_policy") if isinstance(intent, dict) else None + source_query = intent.get("source_query") if isinstance(intent, dict) else None + if ( + selector.get("kind") != "face" + or selector.get("owner_feature_id") != producer.get("id") + or selector.get("source") != "runtime_snapshot" + or selector.get("output_role") is not None + or any(selector.get(key) is not None for key in ( + "stable_id", "snapshot_id", "geometry", "binding_feature_id", "output_role_source", + )) + or not isinstance(intent, dict) + or intent.get("query_family") != "SWEPT_FACE" + or intent.get("evidence") != "kernel_history" + or intent.get("consumer_contract") != "immediate_retained_source_prism_swept_face_up_to_surface" + or not isinstance(policy, dict) + or policy != {"allowed": ["boundary"], "multiplicity": "one"} + or not isinstance(source_query, dict) + or source_query.get("featurescript_version") != "1511" + or source_query.get("standard_library") != "onshape/std/geometry.fs" + or source_query.get("standard_library_version") != "1511.0" + or not isinstance(source_entity, dict) + ): + return False + sketch_id = source_entity.get("sketch_id") + entity_id = source_entity.get("entity_id") + params = producer.get("params") or {} + selected = sketches.get(str(producer.get("sketch_id") or "")) or {} + source = next( + (sketch for sketch in sketches.values() if sketch.get("source_sketch_id") == sketch_id), + {}, + ) + return ( + producer.get("atomic_id") == "extrude_add_blind" + and params.get("result_mode") == "new_body" + and (params.get("end_condition") or {}).get("type") == "blind" + and params.get("draft") is None + and isinstance(sketch_id, str) + and isinstance(entity_id, str) + and selected.get("source_sketch_id") == sketch_id + and _has_one_exact_retained_source_edge(selected, source, entity_id) + ) + + def is_direct_blind_extrude_cap_output_role( selector: dict[str, Any], producer: dict[str, Any] | None, @@ -171,3 +685,793 @@ def is_direct_blind_extrude_cap_output_role( and len(contours) == 1 and bool((contours[0] or {}).get("closed")) ) + + +def is_direct_prism_shell_offset_edge_tdd( + selector: dict[str, Any], + shell: dict[str, Any] | None, + producer: dict[str, Any] | None, + sketches: dict[str, dict[str, Any]], +) -> bool: + """Validate the retained-cap ``OFFSET_EDGE`` TDD tuple. + + This is intentionally not a shell-wall role bridge. In this source form + the outer shell query's TDD names one retained direct-prism CAP_EDGE. OCC + proves it through the cap-edge boundary followed by the shell's exact + one-to-one continuation. The deleted opposite cap can generate an inner + shell wall, but that is a different result and cannot satisfy this query. + """ + if not isinstance(selector, dict) or not isinstance(shell, dict) or not isinstance(producer, dict): + return False + intent = selector.get("selector_intent") + if ( + selector.get("kind") != "edge" + or selector.get("owner_feature_id") != producer.get("id") + or selector.get("source") != "runtime_snapshot" + or any(selector.get(key) is not None for key in ( + "stable_id", "snapshot_id", "geometry", "binding_feature_id", "output_role", "output_role_source", + )) + or not isinstance(intent, dict) + or intent.get("query_family") != "OFFSET_EDGE" + or intent.get("evidence") != "kernel_history" + or intent.get("consumer_contract") != "direct_prism_shell_offset_edge_tdd" + ): + return False + policy = intent.get("derivation_policy") + source_entity = intent.get("source_entity") + disambiguation = intent.get("disambiguation") + role = intent.get("lineage_role") + if ( + not isinstance(policy, dict) + or policy.get("multiplicity") != "one" + or set(policy.get("allowed") or ()) != {"boundary", "continuation"} + or not isinstance(source_entity, dict) + or not isinstance(source_entity.get("sketch_id"), str) + or not isinstance(source_entity.get("entity_id"), str) + or role not in {"extrude.start", "extrude.end"} + or not isinstance(disambiguation, dict) + or disambiguation.get("type") != "offset_edge_tdd_cap_continuation" + or disambiguation.get("shell_feature_id") != shell.get("id") + or disambiguation.get("outer_owner_feature_id") != shell.get("id") + or disambiguation.get("tdd_cap_owner_feature_id") != producer.get("id") + or disambiguation.get("tdd_cap_role") != role + or disambiguation.get("source_entity") != source_entity + ): + return False + params = producer.get("params") or {} + sketch = sketches.get(str(producer.get("sketch_id") or "")) or {} + if ( + producer.get("atomic_id") != "extrude_add_blind" + or params.get("result_mode") != "new_body" + or (params.get("end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None + or sketch.get("source_sketch_id") != source_entity["sketch_id"] + or source_entity["entity_id"] not in _direct_profile_source_entity_ids(sketch) + ): + return False + shell_params = shell.get("params") or {} + shell_selectors = shell.get("selectors") or [] + if ( + shell.get("atomic_id") != "shell" + or shell.get("depends_on") != [producer.get("id")] + or shell_params.get("inward") is not True + or len(shell_selectors) != 1 + or not isinstance(shell_selectors[0], dict) + ): + return False + removed = shell_selectors[0] + expected_removed = "extrude.end" if role == "extrude.start" else "extrude.start" + removed_intent = removed.get("selector_intent") + return ( + removed.get("kind") == "face" + and removed.get("owner_feature_id") == producer.get("id") + and removed.get("output_role") == expected_removed + and removed.get("source") == "runtime_snapshot" + and isinstance(removed_intent, dict) + and removed_intent.get("query_family") == "CAP_FACE" + and removed_intent.get("output_role") == expected_removed + ) + + +def is_direct_prism_shell_offset_edge_vertex( + selector: dict[str, Any], + shell: dict[str, Any] | None, + producer: dict[str, Any] | None, + sketches: dict[str, dict[str, Any]], +) -> bool: + """Validate the OSD-only source-vertex shell ``OFFSET_EDGE`` tuple.""" + if not isinstance(selector, dict) or not isinstance(shell, dict) or not isinstance(producer, dict): + return False + intent = selector.get("selector_intent") + if ( + selector.get("kind") != "edge" + or selector.get("owner_feature_id") != producer.get("id") + or selector.get("source") != "runtime_snapshot" + or any(selector.get(key) is not None for key in ( + "stable_id", "snapshot_id", "geometry", "binding_feature_id", "output_role", "output_role_source", + )) + or not isinstance(intent, dict) + or intent.get("query_family") != "OFFSET_EDGE" + or intent.get("evidence") != "kernel_history" + or intent.get("consumer_contract") != "direct_prism_shell_offset_edge_vertex" + or intent.get("source_entity") is not None + or intent.get("lineage_role") is not None + ): + return False + policy = intent.get("derivation_policy") + sources = intent.get("source_entities") + disambiguation = intent.get("disambiguation") + if ( + not isinstance(policy, dict) + or policy.get("multiplicity") != "one" + or set(policy.get("allowed") or ()) != {"boundary", "continuation"} + or not isinstance(sources, list) or len(sources) != 2 + or not all( + isinstance(source, dict) + and isinstance(source.get("sketch_id"), str) and source["sketch_id"] + and isinstance(source.get("entity_id"), str) and source["entity_id"] + for source in sources + ) + or len({(source["sketch_id"], source["entity_id"]) for source in sources}) != 2 + or not isinstance(disambiguation, dict) + or disambiguation.get("type") != "offset_edge_vertex_continuation" + or disambiguation.get("shell_feature_id") != shell.get("id") + or disambiguation.get("outer_owner_feature_id") != shell.get("id") + or disambiguation.get("prism_owner_feature_id") != producer.get("id") + or disambiguation.get("source_entities") != sources + ): + return False + source_sketches = {source["sketch_id"] for source in sources} + entity_ids = {source["entity_id"] for source in sources} + params = producer.get("params") or {} + sketch = sketches.get(str(producer.get("sketch_id") or "")) or {} + if ( + len(source_sketches) != 1 + or sketch.get("source_sketch_id") != next(iter(source_sketches)) + or not entity_ids <= _direct_profile_source_entity_ids(sketch) + or producer.get("atomic_id") != "extrude_add_blind" + or params.get("result_mode") != "new_body" + or (params.get("end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None + ): + return False + shell_params = shell.get("params") or {} + shell_selectors = shell.get("selectors") or [] + if ( + shell.get("atomic_id") != "shell" + or shell.get("depends_on") != [producer.get("id")] + or shell_params.get("inward") is not True + or len(shell_selectors) != 1 + or not isinstance(shell_selectors[0], dict) + ): + return False + removed = shell_selectors[0] + removed_intent = removed.get("selector_intent") + return ( + removed.get("kind") == "face" + and removed.get("owner_feature_id") == producer.get("id") + and removed.get("output_role") in {"extrude.start", "extrude.end"} + and removed.get("output_role") == disambiguation.get("removed_cap_role") + and removed.get("source") == "runtime_snapshot" + and isinstance(removed_intent, dict) + and removed_intent.get("query_family") == "CAP_FACE" + and removed_intent.get("output_role") == removed.get("output_role") + ) + + +def is_primary_add_shell_cap_output_role( + selector: dict[str, Any], + producer: dict[str, Any] | None, + _sketches: dict[str, dict[str, Any]], +) -> bool: + """Validate the primary-ADD CAP role available only to an immediate shell. + + Unlike a ``new_body`` prism, this producer's tool is transient. The + executor therefore has to prove its cap's one-to-one union successor in + the final active member; this predicate only admits the source/CDSL + contract needed to request that proof. + """ + if not isinstance(selector, dict) or not isinstance(producer, dict): + return False + intent = selector.get("selector_intent") + if ( + selector.get("kind") != "face" + or selector.get("output_role") not in {"extrude.start", "extrude.end"} + or selector.get("source") != "runtime_snapshot" + or selector.get("output_role_source") is not None + or any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")) + or not isinstance(intent, dict) + or intent.get("query_family") != "CAP_FACE" + or intent.get("evidence") != "operation_role" + or intent.get("output_role") != selector.get("output_role") + ): + return False + policy = intent.get("derivation_policy") + if ( + not isinstance(policy, dict) + or policy.get("multiplicity") != "one" + or set(policy.get("allowed") or ()) != {"boundary", "continuation"} + ): + return False + params = producer.get("params") or {} + return ( + producer.get("atomic_id") == "extrude_add_blind" + and params.get("result_mode") != "new_body" + and (params.get("end_condition") or {}).get("type") == "blind" + and params.get("draft") is None + ) + + +def is_primary_add_up_to_surface_cap_output_role( + selector: dict[str, Any], + producer: dict[str, Any] | None, + sketches: dict[str, dict[str, Any]], +) -> bool: + """Validate the transient primary-ADD CAP role for an immediate extent. + + This has the same producer constraints as the shell bridge, but is kept + separately named because its consumer must calculate a true face target, + not remove the face itself. The active successor remains subject to the + exact union continuation proof in ``TopologyRegistry.resolve``. + """ + intent = selector.get("selector_intent") if isinstance(selector, dict) else None + return ( + isinstance(intent, dict) + and intent.get("consumer_contract") == "primary_add_up_to_surface_union_continuation" + and is_primary_add_shell_cap_output_role(selector, producer, sketches) + ) + + +def is_primary_add_dressup_cap_output_role( + selector: dict[str, Any], + producer: dict[str, Any] | None, + sketches: dict[str, dict[str, Any]], +) -> bool: + """Validate the transient primary-ADD CAP role for an immediate dress-up. + + The consuming fillet/chamfer expands the resolved physical face to its + actual body-boundary edges. It does not infer those edges from source + geometry: the output role must first survive the exact union continuation. + """ + intent = selector.get("selector_intent") if isinstance(selector, dict) else None + return ( + isinstance(intent, dict) + and intent.get("consumer_contract") == "primary_add_dressup_union_continuation" + and is_primary_add_shell_cap_output_role(selector, producer, sketches) + ) + + +def is_symmetric_direct_prism_two_sided_up_to_surface_cap_pair( + forward: dict[str, Any], + reverse: dict[str, Any], + producer: dict[str, Any] | None, + sketches: dict[str, dict[str, Any]], +) -> bool: + """Validate the only paired CAP contract for two-sided up-to-surface. + + Both directions are required because a symmetric prism has two far caps + and no source-plane cap. Each query must name the complete, unchanged + direct source profile; this keeps a partial OSD query from becoming a + geometric substitute for a CAP role. + """ + if not isinstance(forward, dict) or not isinstance(reverse, dict) or not isinstance(producer, dict): + return False + selectors = (forward, reverse) + contract = "symmetric_direct_prism_two_sided_up_to_surface_cap_pair" + for selector in selectors: + intent = selector.get("selector_intent") + if ( + selector.get("kind") != "face" + or selector.get("output_role") not in {"extrude.start", "extrude.end"} + or selector.get("source") != "runtime_snapshot" + or selector.get("output_role_source") is not None + or any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")) + or not isinstance(intent, dict) + or intent.get("query_family") != "CAP_FACE" + or intent.get("evidence") != "operation_role" + or intent.get("output_role") != selector.get("output_role") + or intent.get("consumer_contract") != contract + ): + return False + policy = intent.get("derivation_policy") + source = intent.get("source_query") + ids = (intent.get("disambiguation") or {}).get("source_profile_entity_ids") + if ( + not isinstance(policy, dict) + or policy.get("multiplicity") != "one" + or set(policy.get("allowed") or ()) != {"boundary"} + or not isinstance(source, dict) + or source.get("featurescript_version") != "1511" + or source.get("standard_library") != "onshape/std/geometry.fs" + or source.get("standard_library_version") != "1511.0" + or not isinstance(ids, list) + or not ids + or any(not isinstance(item, str) or not item for item in ids) + or len(ids) != len(set(ids)) + ): + return False + if ( + forward.get("owner_feature_id") != reverse.get("owner_feature_id") + or {forward.get("output_role"), reverse.get("output_role")} != {"extrude.start", "extrude.end"} + or (forward.get("selector_intent") or {}).get("disambiguation", {}).get("source_profile_entity_ids") + != (reverse.get("selector_intent") or {}).get("disambiguation", {}).get("source_profile_entity_ids") + ): + return False + params = producer.get("params") or {} + if ( + producer.get("atomic_id") != "extrude_add_two_sided" + or params.get("result_mode") != "new_body" + or (params.get("end_condition") or {}).get("type") != "blind" + or (params.get("reverse_end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None + ): + return False + sketch = sketches.get(str(producer.get("sketch_id") or "")) or {} + profile = sketch.get("profile") or {} + source_sketch_id = sketch.get("source_sketch_id") + ids = set((forward.get("selector_intent") or {}).get("disambiguation", {}).get("source_profile_entity_ids") or ()) + direct_ids = {str(profile.get("source_entity_id"))} if profile.get("type") == "circle" and profile.get("source_entity_id") else set() + for contour in profile.get("contours") or (): + for segment in (contour or {}).get("segments") or (): + entity_id = segment.get("source_entity_id") if isinstance(segment, dict) else None + if isinstance(entity_id, str) and entity_id: + direct_ids.add(entity_id) + return isinstance(source_sketch_id, str) and bool(direct_ids) and ids == direct_ids + + +def is_initial_two_sided_circle_shell_cap_output_role( + selector: dict[str, Any], + producer: dict[str, Any] | None, + sketches: dict[str, dict[str, Any]], +) -> bool: + """Validate the direct symmetric-circle cap contract for shell removal. + + The two far prism caps are distinct exact builder results. This is not a + general two-sided CAP_FACE capability: the source query must name the + sole original circle and the consumer may use the role only immediately + as a shell removal face. + """ + if not isinstance(selector, dict) or not isinstance(producer, dict): + return False + intent = selector.get("selector_intent") + if ( + selector.get("kind") != "face" + or selector.get("output_role") not in {"extrude.start", "extrude.end"} + or selector.get("source") != "runtime_snapshot" + or selector.get("output_role_source") is not None + or any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")) + or not isinstance(intent, dict) + or intent.get("query_family") != "CAP_FACE" + or intent.get("evidence") != "operation_role" + or intent.get("output_role") != selector.get("output_role") + ): + return False + policy = intent.get("derivation_policy") + if not isinstance(policy, dict) or policy.get("multiplicity") != "one" or set(policy.get("allowed") or ()) != {"boundary"}: + return False + params = producer.get("params") or {} + if ( + producer.get("atomic_id") != "extrude_add_two_sided" + or params.get("result_mode") != "new_body" + or (params.get("end_condition") or {}).get("type") != "blind" + or (params.get("reverse_end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None + ): + return False + sketch = sketches.get(str(producer.get("sketch_id") or "")) or {} + profile = sketch.get("profile") or {} + source_sketch_id = sketch.get("source_sketch_id") + source_entity_id = profile.get("source_entity_id") + source_entity = intent.get("source_entity") + return ( + profile.get("type") == "circle" + and isinstance(source_sketch_id, str) + and isinstance(source_entity_id, str) + and source_entity == { + "sketch_id": source_sketch_id, + "entity_id": source_entity_id, + } + ) + + +def is_initial_direct_loft_cap_output_role( + selector: dict[str, Any], + producer: dict[str, Any] | None, + sketches: dict[str, dict[str, Any]], +) -> bool: + """Validate the exact endpoint-role contract for an initial direct loft.""" + if not isinstance(selector, dict) or not isinstance(producer, dict): + return False + intent = selector.get("selector_intent") + params = producer.get("params") or {} + profile_sources = params.get("cap_output_profile_sources") + if ( + selector.get("kind") != "face" + or selector.get("output_role") not in {"loft.start", "loft.end"} + or selector.get("source") != "runtime_snapshot" + or selector.get("output_role_source") is not None + or any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")) + or not isinstance(intent, dict) + or intent.get("query_family") != "CAP_FACE" + or intent.get("evidence") != "operation_role" + or intent.get("output_role") != selector.get("output_role") + or producer.get("atomic_id") != "loft_add" + or params.get("initial_output_roles") is not True + or not isinstance(profile_sources, list) + or len(profile_sources) != 2 + or len(set(profile_sources)) != 2 + ): + return False + profile_sketch_ids = params.get("profile_sketch_ids") + if ( + not isinstance(profile_sketch_ids, list) + or len(profile_sketch_ids) != 2 + or any(not isinstance(sketch_id, str) for sketch_id in profile_sketch_ids) + or [ + (sketches.get(sketch_id) or {}).get("source_sketch_id") + for sketch_id in profile_sketch_ids + ] != profile_sources + ): + return False + policy = intent.get("derivation_policy") + disambiguation = intent.get("disambiguation") + if ( + not isinstance(policy, dict) + or policy.get("multiplicity") != "one" + or set(policy.get("allowed") or ()) != {"boundary"} + or not isinstance(disambiguation, dict) + or disambiguation.get("type") != "loft_profile_source" + ): + return False + source = disambiguation.get("source_sketch_id") + if source not in profile_sources: + return False + return selector["output_role"] == ("loft.start" if profile_sources.index(source) == 0 else "loft.end") + + +def is_initial_direct_sweep_cap_output_role( + selector: dict[str, Any], + producer: dict[str, Any] | None, + sketches: dict[str, dict[str, Any]], +) -> bool: + """Validate one direct PipeShell cap selected by its exact source pair. + + A sweep cap has two source qualifications: the profile edge and the path + endpoint. ``FirstShape``/``LastShape`` prove the final face, while the + CDSL contract keeps both FeatureScript anchors so ``isStart`` never + degenerates into an arbitrary cap-role choice. + """ + if not isinstance(selector, dict) or not isinstance(producer, dict): + return False + intent = selector.get("selector_intent") + params = producer.get("params") or {} + contract = params.get("cap_output_contract") + if ( + selector.get("kind") != "face" + or selector.get("output_role") not in {"sweep.start", "sweep.end"} + or selector.get("source") != "runtime_snapshot" + or selector.get("output_role_source") is not None + or any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")) + or not isinstance(intent, dict) + or intent.get("query_family") != "CAP_FACE" + or intent.get("evidence") != "operation_role" + or intent.get("output_role") != selector.get("output_role") + or producer.get("atomic_id") != "sweep_add" + or params.get("result_mode") != "new_body" + or params.get("initial_output_roles") is not True + or not isinstance(contract, dict) + ): + return False + required = ("profile_source", "profile_entity", "path_source", "path_entity", "path_reversed") + if ( + any(not isinstance(contract.get(name), str) or not contract[name] for name in required[:-1]) + or not isinstance(contract.get("path_reversed"), bool) + ): + return False + sketch = sketches.get(str(producer.get("sketch_id") or "")) or {} + if sketch.get("source_sketch_id") != contract["profile_source"]: + return False + profile = sketch.get("profile") or {} + contours = profile.get("contours") + if not ( + profile.get("type") == "circle" + or profile.get("type") == "analytic_contours" + and isinstance(contours, list) + and len(contours) == 1 + and bool((contours[0] or {}).get("closed")) + ): + return False + policy = intent.get("derivation_policy") + disambiguation = intent.get("disambiguation") + if ( + not isinstance(policy, dict) + or policy.get("multiplicity") != "one" + or set(policy.get("allowed") or ()) != {"boundary"} + or not isinstance(disambiguation, dict) + or disambiguation.get("type") != "sweep_profile_path_endpoint" + or any(disambiguation.get(name) != contract[name] for name in required) + or disambiguation.get("path_endpoint") not in {"start", "end"} + ): + return False + endpoint = disambiguation["path_endpoint"] + role_endpoint = endpoint if not contract["path_reversed"] else ("end" if endpoint == "start" else "start") + return selector["output_role"] == f"sweep.{role_endpoint}" + + +def is_initial_direct_sweep_cap_edge( + selector: dict[str, Any], + producer: dict[str, Any] | None, + sketches: dict[str, dict[str, Any]], +) -> bool: + """Validate the singleton profile-edge form of a direct PipeShell cap. + + This does not claim PipeShell provides generic per-edge history. It is + limited to a direct circular profile, whose one source edge and one cap + boundary edge are both independently cardinality-checked by the adapter. + """ + if not isinstance(selector, dict) or not isinstance(producer, dict): + return False + intent = selector.get("selector_intent") + params = producer.get("params") or {} + contract = params.get("cap_output_contract") + if ( + selector.get("kind") != "edge" + or selector.get("source") != "runtime_snapshot" + or any(selector.get(key) is not None for key in ( + "stable_id", "snapshot_id", "geometry", "binding_feature_id", "output_role", "output_role_source", + )) + or not isinstance(intent, dict) + or intent.get("query_family") != "CAP_EDGE" + or intent.get("evidence") != "kernel_history" + or producer.get("atomic_id") != "sweep_add" + or params.get("result_mode") != "new_body" + or params.get("initial_output_roles") is not True + or not isinstance(contract, dict) + ): + return False + required = ("profile_source", "profile_entity", "path_source", "path_entity", "path_reversed") + if ( + any(not isinstance(contract.get(name), str) or not contract[name] for name in required[:-1]) + or not isinstance(contract.get("path_reversed"), bool) + ): + return False + sketch = sketches.get(str(producer.get("sketch_id") or "")) or {} + profile = sketch.get("profile") or {} + if ( + sketch.get("source_sketch_id") != contract["profile_source"] + or profile.get("type") != "circle" + or profile.get("source_entity_id") != contract["profile_entity"] + ): + return False + policy = intent.get("derivation_policy") + source_entity = intent.get("source_entity") + disambiguation = intent.get("disambiguation") + if ( + not isinstance(policy, dict) + or policy.get("multiplicity") != "one" + or set(policy.get("allowed") or ()) != {"boundary"} + or source_entity != {"sketch_id": contract["profile_source"], "entity_id": contract["profile_entity"]} + or not isinstance(disambiguation, dict) + or disambiguation.get("type") != "sweep_profile_path_endpoint" + or any(disambiguation.get(name) != contract[name] for name in required) + or disambiguation.get("path_endpoint") not in {"start", "end"} + ): + return False + endpoint = disambiguation["path_endpoint"] + role_endpoint = endpoint if not contract["path_reversed"] else ("end" if endpoint == "start" else "start") + return intent.get("lineage_role") == f"sweep.{role_endpoint}" + + +def is_initial_direct_sweep_swept_face( + selector: dict[str, Any], + producer: dict[str, Any] | None, + sketches: dict[str, dict[str, Any]], +) -> bool: + """Validate the direct analytic-profile PipeShell `Generated(edge)` form.""" + if not isinstance(selector, dict) or not isinstance(producer, dict): + return False + intent = selector.get("selector_intent") + params = producer.get("params") or {} + contract = params.get("swept_face_contract") + if ( + selector.get("kind") != "face" + or selector.get("source") != "runtime_snapshot" + or any(selector.get(key) is not None for key in ( + "stable_id", "snapshot_id", "geometry", "binding_feature_id", "output_role", "output_role_source", + )) + or not isinstance(intent, dict) + or intent.get("query_family") != "SWEPT_FACE" + or intent.get("evidence") != "kernel_history" + or producer.get("atomic_id") != "sweep_add" + or params.get("result_mode") != "new_body" + or params.get("initial_output_roles") is not True + or not isinstance(contract, dict) + ): + return False + required = ("profile_source", "profile_entities", "path_source", "path_entity", "path_reversed") + if ( + any(not isinstance(contract.get(name), str) or not contract[name] for name in ("profile_source", "path_source", "path_entity")) + or not isinstance(contract.get("profile_entities"), list) + or not contract["profile_entities"] + or any(not isinstance(entity, str) or not entity for entity in contract["profile_entities"]) + or len(set(contract["profile_entities"])) != len(contract["profile_entities"]) + or not isinstance(contract.get("path_reversed"), bool) + ): + return False + sketch = sketches.get(str(producer.get("sketch_id") or "")) or {} + profile_entities = _direct_sweep_profile_entities(sketch) + if sketch.get("source_sketch_id") != contract["profile_source"] or profile_entities != contract["profile_entities"]: + return False + policy = intent.get("derivation_policy") + disambiguation = intent.get("disambiguation") + return ( + isinstance(policy, dict) + and policy.get("multiplicity") == "one" + and set(policy.get("allowed") or ()) == {"boundary"} + and isinstance(intent.get("source_entity"), dict) + and intent["source_entity"].get("sketch_id") == contract["profile_source"] + and intent["source_entity"].get("entity_id") in profile_entities + and isinstance(disambiguation, dict) + and disambiguation.get("type") == "sweep_profile_path" + and all(disambiguation.get(name) == contract[name] for name in required) + ) + + +def is_initial_direct_sweep_swept_edge( + selector: dict[str, Any], + producer: dict[str, Any] | None, + sketches: dict[str, dict[str, Any]], +) -> bool: + """Validate the direct profile-vertex PipeShell `Generated(vertex)` form.""" + if not isinstance(selector, dict) or not isinstance(producer, dict): + return False + intent = selector.get("selector_intent") + params = producer.get("params") or {} + contract = params.get("swept_edge_contract") + if ( + selector.get("kind") != "edge" + or selector.get("source") != "runtime_snapshot" + or any(selector.get(key) is not None for key in ( + "stable_id", "snapshot_id", "geometry", "binding_feature_id", "output_role", "output_role_source", + )) + or not isinstance(intent, dict) + or intent.get("query_family") != "SWEPT_EDGE" + or intent.get("evidence") != "kernel_history" + or producer.get("atomic_id") != "sweep_add" + or params.get("result_mode") != "new_body" + or params.get("initial_output_roles") is not True + or not isinstance(contract, dict) + ): + return False + required = ("profile_source", "profile_entities", "path_source", "path_entity", "path_reversed") + if ( + any(not isinstance(contract.get(name), str) or not contract[name] for name in ("profile_source", "path_source", "path_entity")) + or contract.get("profile_source") == contract.get("path_source") + or not isinstance(contract.get("profile_entities"), list) + or len(contract["profile_entities"]) < 2 + or any(not isinstance(entity, str) or not entity for entity in contract["profile_entities"]) + or len(set(contract["profile_entities"])) != len(contract["profile_entities"]) + or not isinstance(contract.get("path_reversed"), bool) + ): + return False + sketch = sketches.get(str(producer.get("sketch_id") or "")) or {} + profile_entities = _direct_sweep_profile_entities(sketch) + if sketch.get("source_sketch_id") != contract["profile_source"] or profile_entities != contract["profile_entities"]: + return False + source_entities = intent.get("source_entities") + if not isinstance(source_entities, list) or len(source_entities) != 2: + return False + vertex_entities = tuple(sorted( + source.get("entity_id") for source in source_entities + if isinstance(source, dict) and source.get("sketch_id") == contract["profile_source"] + and isinstance(source.get("entity_id"), str) + )) + policy = intent.get("derivation_policy") + disambiguation = intent.get("disambiguation") + return ( + len(vertex_entities) == 2 + and len(set(vertex_entities)) == 2 + and vertex_entities in _direct_sweep_profile_vertex_entity_pairs(sketch) + and isinstance(policy, dict) + and policy.get("multiplicity") == "one" + and set(policy.get("allowed") or ()) == {"boundary"} + and isinstance(disambiguation, dict) + and disambiguation.get("type") == "sweep_profile_vertex_path" + and all(disambiguation.get(name) == contract[name] for name in required) + and disambiguation.get("profile_vertex_entities") == list(vertex_entities) + ) + + +def _direct_sweep_profile_entities(sketch: dict[str, Any]) -> list[str] | None: + """Return a direct closed profile's complete, unique source edge set.""" + profile = sketch.get("profile") or {} + if profile.get("type") == "circle": + entity = profile.get("source_entity_id") + return [entity] if isinstance(entity, str) and entity else None + contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None + if not isinstance(contours, list) or len(contours) != 1: + return None + contour = contours[0] or {} + segments = contour.get("segments") if isinstance(contour, dict) else None + if not contour.get("closed") or not isinstance(segments, list) or not segments: + return None + entities = [segment.get("source_entity_id") for segment in segments if isinstance(segment, dict)] + if len(entities) != len(segments) or any(not isinstance(entity, str) or not entity for entity in entities): + return None + return entities if len(set(entities)) == len(entities) else None + + +def _direct_sweep_profile_vertex_entity_pairs(sketch: dict[str, Any]) -> set[tuple[str, str]]: + """Return only the exact adjacent direct source-edge pairs of one contour.""" + entities = _direct_sweep_profile_entities(sketch) + if entities is None or len(entities) < 2: + return set() + return { + tuple(sorted((entities[index], entities[(index + 1) % len(entities)]))) + for index in range(len(entities)) + } + + +def is_planar_imprint_extrude_cap_output_role( + selector: dict[str, Any], + producer: dict[str, Any] | None, + sketches: dict[str, dict[str, Any]], +) -> bool: + """Validate the narrow all-fragment CAP_FACE contract for IMPRINT prisms. + + A CAP query over an IMPRINT result is only executable when its source + disambiguation names the complete profile source set. The runtime still + verifies every generated cap fragment through fresh builder history. + """ + if not isinstance(selector, dict) or not isinstance(producer, dict): + return False + intent = selector.get("selector_intent") + if ( + selector.get("kind") != "face" + or selector.get("output_role") not in {"extrude.start", "extrude.end"} + or selector.get("source") != "runtime_snapshot" + or selector.get("output_role_source") is not None + or any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")) + or not isinstance(intent, dict) + or intent.get("query_family") != "CAP_FACE" + or intent.get("evidence") != "kernel_history" + or intent.get("output_role") != selector.get("output_role") + ): + return False + policy = intent.get("derivation_policy") + if ( + not isinstance(policy, dict) + or policy.get("multiplicity") != "all_fragments" + or set(policy.get("allowed") or ()) != {"boundary", "fragment"} + ): + return False + params = producer.get("params") or {} + if ( + producer.get("atomic_id") not in {"extrude_add_blind", "extrude_cut_blind"} + or params.get("result_mode") != "new_body" + or (params.get("end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None + ): + return False + sketch = sketches.get(str(producer.get("sketch_id") or "")) or {} + profile = sketch.get("profile") or {} + if profile.get("type") != "planar_imprint": + return False + expected_ids = sorted({ + str(entry.get("id")) + for entry in profile.get("source_entities") or () + if isinstance(entry, dict) and isinstance(entry.get("id"), str) and entry.get("id") + }) + disambiguation = intent.get("disambiguation") + actual_ids = sorted({ + str(value) + for value in (disambiguation or {}).get("source_entity_ids") or () + if isinstance(value, str) and value + }) + return ( + bool(expected_ids) + and isinstance(disambiguation, dict) + and disambiguation.get("type") == "complete_imprint_profile_source_set" + and actual_ids == expected_ids + and len(actual_ids) == len((disambiguation or {}).get("source_entity_ids") or ()) + ) diff --git a/backend/engine/cdsl_engine/semantic_validation.py b/backend/engine/cdsl_engine/semantic_validation.py index 8253b854..b06c62ca 100644 --- a/backend/engine/cdsl_engine/semantic_validation.py +++ b/backend/engine/cdsl_engine/semantic_validation.py @@ -8,6 +8,7 @@ without claiming that every feature can already be rebuilt locally. from __future__ import annotations import json +import math import re from functools import lru_cache from pathlib import Path @@ -16,7 +17,26 @@ from typing import Any from jsonschema import Draft202012Validator from .operation_contracts import materialized_feature_contracts -from .selector_capabilities import is_direct_blind_extrude_cap_output_role +from .selector_capabilities import ( + is_direct_blind_extrude_cap_output_role, + is_immediate_retained_source_prism_swept_face_extent, + is_direct_prism_shell_offset_edge_tdd, + is_direct_prism_shell_offset_edge_vertex, + is_initial_direct_loft_cap_output_role, + is_initial_direct_sweep_cap_edge, + is_initial_direct_sweep_cap_output_role, + is_initial_direct_sweep_swept_edge, + is_initial_direct_sweep_swept_face, + is_initial_two_sided_circle_shell_cap_output_role, + is_planar_imprint_extrude_cap_output_role, + is_primary_add_dressup_cap_output_role, + is_primary_add_shell_cap_output_role, + is_primary_add_up_to_surface_cap_output_role, + is_symmetric_direct_prism_two_sided_up_to_surface_cap_pair, + copy_selector_contract_error, + owner_body_selector_contract_error, + proven_operand_set_contract_error, +) _ID = re.compile(r"^[A-Za-z0-9_-]{1,80}$") @@ -33,6 +53,246 @@ def _mappings(value: Any): yield from _mappings(child) +def _selector_slot_descendants(selectors: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return selectors that inherit an operation's declared selector slot. + + A proven ``QUERY_SET`` is one selector in the feature slot; its operands + are not unrelated nested mappings. They must therefore receive the same + output-role contract validation as their parent. Only ``query_operands`` + inherit this position, so metadata such as ``output_role_source`` cannot + use the exception to become an executable selector. + """ + descendants: list[dict[str, Any]] = [] + seen: set[int] = set() + + def visit(selector: Any) -> None: + if not isinstance(selector, dict) or id(selector) in seen: + return + seen.add(id(selector)) + descendants.append(selector) + operands = selector.get("query_operands") + if isinstance(operands, list): + for operand in operands: + visit(operand) + + for selector in selectors: + visit(selector) + return descendants + + +def _direct_profile_source_entity_ids(sketch: dict[str, Any]) -> set[str]: + """Return source labels represented by an unchanged direct profile. + + A ``COPY(CAP_FACE)`` workplane can only inherit the cut tool's full + profile boundary. These labels are contract data, not a shape-matching + hint: a selected or split profile simply has a different source set. + """ + profile = sketch.get("profile") or {} + source_ids: set[str] = set() + direct_circle = profile.get("source_entity_id") if profile.get("type") == "circle" else None + if isinstance(direct_circle, str) and direct_circle: + source_ids.add(direct_circle) + for contour in profile.get("contours") or (): + if not isinstance(contour, dict): + continue + for segment in contour.get("segments") or (): + source_entity_id = segment.get("source_entity_id") if isinstance(segment, dict) else None + if isinstance(source_entity_id, str) and source_entity_id: + source_ids.add(source_entity_id) + return source_ids + + +def _validate_primary_cut_copy_cap_face_attachment( + sketch: dict[str, Any], + *, + preceding_features: dict[str, dict[str, Any]], + sketches_by_id: dict[str, dict[str, Any]], +) -> None: + """Cross-check the narrow COPY(CAP_FACE) source set against its producer. + + The generic selector contract proves typed query shape. This preflight + check additionally binds the claimed complete OSD set to the producer's + actual direct profile, so a hand-authored attachment cannot substitute a + partial or unrelated source set before runtime topology resolution. + """ + attachment = sketch.get("attachment") + intent = attachment.get("selector_intent") if isinstance(attachment, dict) else None + if not ( + isinstance(intent, dict) + and intent.get("query_family") == "COPY" + and intent.get("copy_contract") == "primary_cut_cap_face_workplane" + ): + return + owner = attachment.get("owner_feature_id") + producer = preceding_features.get(str(owner or "")) + params = (producer or {}).get("params") or {} + profile_sketch = sketches_by_id.get(str((producer or {}).get("sketch_id") or "")) + source_sketch_id = profile_sketch.get("source_sketch_id") if isinstance(profile_sketch, dict) else None + expected_ids = _direct_profile_source_entity_ids(profile_sketch) if isinstance(profile_sketch, dict) else set() + query_input = attachment.get("query_input") if isinstance(attachment, dict) else None + input_intent = query_input.get("selector_intent") if isinstance(query_input, dict) else None + source_entities = input_intent.get("source_entities") if isinstance(input_intent, dict) else None + actual_pairs = { + (item.get("sketch_id"), item.get("entity_id")) + for item in source_entities or () + if isinstance(item, dict) + } if isinstance(source_entities, list) else set() + expected_pairs = {(source_sketch_id, entity_id) for entity_id in expected_ids} + if ( + not isinstance(producer, dict) + or producer.get("atomic_id") != "extrude_cut_blind" + or params.get("result_mode") is not None + or (params.get("end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None + or not isinstance(source_sketch_id, str) + or not expected_ids + or actual_pairs != expected_pairs + or len(actual_pairs) != len(source_entities or ()) + ): + raise ValueError( + f"Sketch {sketch.get('id')} COPY(CAP_FACE) attachment requires the complete direct primary-cut source-profile edge set" + ) + + +def _validate_direct_prism_cap_face_attachment( + sketch: dict[str, Any], + *, + features: list[dict[str, Any]], + preceding_features: dict[str, dict[str, Any]], + sketches_by_id: dict[str, dict[str, Any]], +) -> None: + """Require a CAP-face attachment to consume its immediate prism result.""" + attachment = sketch.get("attachment") + intent = attachment.get("selector_intent") if isinstance(attachment, dict) else None + if not ( + isinstance(intent, dict) + and intent.get("query_family") == "CAP_FACE" + and intent.get("consumer_contract") == "direct_prism_cap_face_workplane" + ): + return + owner = attachment.get("owner_feature_id") if isinstance(attachment, dict) else None + producer = preceding_features.get(str(owner or "")) + sketch_map = {str(item.get("id") or ""): item for item in sketches_by_id.values()} + if not is_direct_blind_extrude_cap_output_role(attachment, producer, sketch_map): + raise ValueError( + f"Sketch {sketch.get('id')} CAP_FACE attachment requires one direct new-body blind prism cap" + ) + consumers = [ + index for index, feature in enumerate(features) + if feature.get("sketch_id") == sketch.get("id") + ] + # A later source feature may be deferred during lowering, leaving its + # sketch as diagnostics-only data in an otherwise valid executable prefix. + # No consumer means no runtime attachment resolution; multiple or delayed + # consumers would be a materialized contract violation. + if not consumers: + return + if len(consumers) != 1 or consumers[0] == 0 or features[consumers[0] - 1].get("id") != owner: + raise ValueError( + f"Sketch {sketch.get('id')} CAP_FACE attachment must be consumed immediately after its producer" + ) + + +def _validate_primary_cut_copy_swept_face_attachment( + sketch: dict[str, Any], + *, + preceding_features: dict[str, dict[str, Any]], + sketches_by_id: dict[str, dict[str, Any]], +) -> None: + """Bind the sole COPY(SWEPT_FACE) anchor to an unchanged cut-tool profile.""" + attachment = sketch.get("attachment") + intent = attachment.get("selector_intent") if isinstance(attachment, dict) else None + if not ( + isinstance(intent, dict) + and intent.get("query_family") == "COPY" + and intent.get("copy_contract") == "primary_cut_swept_face_workplane" + ): + return + owner = attachment.get("owner_feature_id") + producer = preceding_features.get(str(owner or "")) + params = (producer or {}).get("params") or {} + profile_sketch = sketches_by_id.get(str((producer or {}).get("sketch_id") or "")) + source_sketch_id = profile_sketch.get("source_sketch_id") if isinstance(profile_sketch, dict) else None + expected_ids = _direct_profile_source_entity_ids(profile_sketch) if isinstance(profile_sketch, dict) else set() + query_input = attachment.get("query_input") if isinstance(attachment, dict) else None + input_intent = query_input.get("selector_intent") if isinstance(query_input, dict) else None + source_entity = input_intent.get("source_entity") if isinstance(input_intent, dict) else None + if ( + not isinstance(producer, dict) + or producer.get("atomic_id") != "extrude_cut_blind" + or params.get("result_mode") is not None + or (params.get("end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None + or not isinstance(source_sketch_id, str) + or not expected_ids + or not isinstance(source_entity, dict) + or (source_entity.get("sketch_id"), source_entity.get("entity_id")) not in { + (source_sketch_id, entity_id) for entity_id in expected_ids + } + ): + raise ValueError( + f"Sketch {sketch.get('id')} COPY(SWEPT_FACE) attachment requires one direct primary-cut source-profile edge" + ) + + +def _validate_direct_prism_blend_face_attachment( + sketch: dict[str, Any], + *, + preceding_features: dict[str, dict[str, Any]], + sketches_by_id: dict[str, dict[str, Any]], +) -> None: + """Cross-check the producer and native dress-up behind a BLEND_FACE host.""" + attachment = sketch.get("attachment") + intent = attachment.get("selector_intent") if isinstance(attachment, dict) else None + if not ( + isinstance(intent, dict) + and intent.get("query_family") == "BLEND_FACE" + and isinstance(intent.get("blend_face_source"), dict) + ): + return + source = intent["blend_face_source"] + producer_id = source.get("owner_feature_id") + dressup_id = attachment.get("owner_feature_id") if isinstance(attachment, dict) else None + producer = preceding_features.get(str(producer_id or "")) + dressup = preceding_features.get(str(dressup_id or "")) + profile = sketches_by_id.get(str((producer or {}).get("sketch_id") or "")) + source_entity = source.get("source_entity") if isinstance(source, dict) else None + source_sketch_id = source_entity.get("sketch_id") if isinstance(source_entity, dict) else None + source_entity_id = source_entity.get("entity_id") if isinstance(source_entity, dict) else None + source_sketch = next( + ( + item for item in sketches_by_id.values() + if item.get("source_sketch_id") == source_sketch_id and item.get("id") == f"sketch_{source_sketch_id}" + ), + None, + ) + params = (producer or {}).get("params") or {} + ordered_ids = list(preceding_features) + if ( + not isinstance(producer, dict) + or not isinstance(dressup, dict) + or producer.get("atomic_id") != "extrude_add_blind" + or params.get("result_mode") != "new_body" + or (params.get("end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None + or dressup.get("atomic_id") not in {"fillet", "chamfer"} + or dressup.get("depends_on") != [producer_id] + or producer_id not in ordered_ids + or dressup_id not in ordered_ids + or ordered_ids.index(str(dressup_id)) != ordered_ids.index(str(producer_id)) + 1 + or not isinstance(profile, dict) + or not isinstance(source_sketch, dict) + or profile.get("source_sketch_id") != source_sketch_id + or profile.get("workplane") != source_sketch.get("workplane") + or profile.get("profile") != source_sketch.get("profile") + or not isinstance(source_entity_id, str) + or source_entity_id not in _direct_profile_source_entity_ids(profile) + ): + raise ValueError( + f"Sketch {sketch.get('id')} BLEND_FACE attachment requires one immediate direct new-body blind prism and native dress-up" + ) + + def _contract_selectors(feature: dict[str, Any], contract: dict[str, Any]) -> list[dict[str, Any]]: """Return the selectors at the operation contract's declared slot.""" slot = str(contract.get("selector_slot") or "") @@ -53,7 +313,7 @@ def _up_to_surface_output_role_reference(feature: dict[str, Any], contract: dict not isinstance(policy, dict) or policy.get("end_condition_type") != "up_to_surface" or policy.get("token_kind") != "face" - or policy.get("output_role_contract") != "direct_blind_extrude_cap" + or policy.get("output_role_contract") != "direct_or_primary_add_blind_extrude_cap" or policy.get("requires_immediate_owner") is not True ): return None @@ -64,6 +324,80 @@ def _up_to_surface_output_role_reference(feature: dict[str, Any], contract: dict return None +def _two_sided_up_to_surface_cap_pair_references(feature: dict[str, Any], contract: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]] | None: + """Return the inseparable forward/reverse CAP-role extent contract.""" + policies = contract.get("nested_selector_policies") or {} + required = "symmetric_direct_prism_two_sided_up_to_surface_cap_pair" + forward_policy = policies.get("params.end_condition.reference") + reverse_policy = policies.get("params.reverse_end_condition.reference") + if not ( + isinstance(forward_policy, dict) + and isinstance(reverse_policy, dict) + and forward_policy.get("output_role_contract") == required + and reverse_policy.get("output_role_contract") == required + and forward_policy.get("requires_immediate_owner") is True + and reverse_policy.get("requires_immediate_owner") is True + ): + return None + params = feature.get("params") or {} + forward = params.get("end_condition") or {} + reverse = params.get("reverse_end_condition") or {} + forward_reference = forward.get("reference") if isinstance(forward, dict) else None + reverse_reference = reverse.get("reference") if isinstance(reverse, dict) else None + if ( + forward.get("type") == "up_to_surface" + and reverse.get("type") == "up_to_surface" + and isinstance(forward_reference, dict) + and isinstance(reverse_reference, dict) + and forward_reference.get("output_role") is not None + and reverse_reference.get("output_role") is not None + ): + return forward_reference, reverse_reference + return None + + +def _validate_source_vertex_extent_references(feature: dict[str, Any], source_sketch_ids: set[str]) -> None: + """Validate source-sketch vertex data separately from runtime selectors.""" + params = feature.get("params") or {} + for parameter in ("end_condition", "reverse_end_condition"): + condition = params.get(parameter) + if not isinstance(condition, dict): + continue + reference = condition.get("reference") + if not isinstance(reference, dict) or reference.get("kind") != "source_vertex": + continue + point = reference.get("point_mm") + if condition.get("type") != "up_to_vertex": + raise ValueError(f"Feature {feature.get('id')} source_vertex datum is valid only for up_to_vertex") + if ( + reference.get("source_sketch_id") not in source_sketch_ids + or not isinstance(reference.get("source_entity_id"), str) + or not isinstance(point, list) + or len(point) != 3 + or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in point) + ): + raise ValueError(f"Feature {feature.get('id')} has an invalid source_vertex extent datum") + + +def _validate_assign_variable(feature: dict[str, Any], assigned_names: set[str]) -> str: + """Validate the intentionally scalar, declaration-only source-variable contract.""" + params = feature.get("params") or {} + name = params.get("name") + value = params.get("value") + if not isinstance(name, str) or not name: + raise ValueError(f"Feature {feature.get('id')} assign_variable requires one non-empty name") + if name in assigned_names: + raise ValueError(f"Feature {feature.get('id')} assign_variable redeclares source variable {name}") + if ( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not math.isfinite(float(value)) + or params.get("value_kind") not in {"any", "length"} + ): + raise ValueError(f"Feature {feature.get('id')} assign_variable requires one finite scalar value") + return name + + def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index: int) -> None: """Enforce the provenance boundary before the runtime can bind a selector.""" intent = selector.get("selector_intent") @@ -79,7 +413,7 @@ def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index: if intent.get("kind") not in {None, selector.get("kind")}: raise ValueError(f"Feature {feature_id} selector {index} intent kind differs from selector kind") family = intent.get("query_family") - derived = {"CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "SWEPT_BODY", "OFFSET_FACE", "INTERSECT", "COPY"} + derived = {"CAP_FACE", "CAP_EDGE", "CAP_VERTEX", "SWEPT_FACE", "SWEPT_EDGE", "SWEPT_BODY", "OFFSET_FACE", "OFFSET_EDGE", "INTERSECT", "BLEND_EDGE", "BLEND_FACE", "COPY"} if family in derived and not selector.get("owner_feature_id"): raise ValueError(f"Feature {feature_id} selector {index} derived intent requires owner_feature_id") policy = intent.get("derivation_policy") or {} @@ -101,6 +435,22 @@ def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index: # instead of rewriting it as a fake numeric version during validation. if version is not None and (not isinstance(version, str) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+)*", version)): raise ValueError(f"Feature {feature_id} selector {index} has an invalid FeatureScript query version") + query_expr = intent.get("query_expr") + if query_expr is not None and ( + not isinstance(query_expr, dict) + or query_expr.get("version") != intent.get("version") + or not isinstance(query_expr.get("root"), dict) + ): + raise ValueError(f"Feature {feature_id} selector {index} has an invalid versioned query expression") + query_set_error = proven_operand_set_contract_error(selector) + if query_set_error is not None: + raise ValueError(f"Feature {feature_id} selector {index} {query_set_error}") + owner_body_error = owner_body_selector_contract_error(selector) + if owner_body_error is not None: + raise ValueError(f"Feature {feature_id} selector {index} {owner_body_error}") + copy_error = copy_selector_contract_error(selector) + if copy_error is not None: + raise ValueError(f"Feature {feature_id} selector {index} {copy_error}") if intent.get("output_role") is not None and intent.get("output_role") != selector.get("output_role"): raise ValueError(f"Feature {feature_id} selector {index} intent output role differs from selector output role") source_entity = intent.get("source_entity") @@ -109,16 +459,33 @@ def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index: raise ValueError(f"Feature {feature_id} selector {index} intent cannot mix one source entity with a source-vertex set") lineage_role = intent.get("lineage_role") if lineage_role is not None: - if ( - family != "CAP_EDGE" - or selector.get("kind") != "edge" - or source_entity is None - or lineage_role not in {"extrude.start", "extrude.end"} - ): - raise ValueError(f"Feature {feature_id} selector {index} has an invalid CAP_EDGE lineage role") + valid_cap_edge = ( + family in {"CAP_EDGE", "OFFSET_EDGE"} + and selector.get("kind") == "edge" + and source_entity is not None + and lineage_role in ( + {"extrude.start", "extrude.end", "sweep.start", "sweep.end"} + if family == "CAP_EDGE" else {"extrude.start", "extrude.end"} + ) + ) + valid_cap_vertex = ( + family == "CAP_VERTEX" + and selector.get("kind") == "vertex" + and source_entities is not None + and lineage_role in {"extrude.start", "extrude.end"} + ) + valid_cap_face = ( + family == "CAP_FACE" + and selector.get("kind") == "face" + and source_entities is not None + and lineage_role in {"extrude.start", "extrude.end"} + ) + if not valid_cap_edge and not valid_cap_vertex and not valid_cap_face: + raise ValueError(f"Feature {feature_id} selector {index} has an invalid cap lineage role") if source_entities is not None: - if family != "SWEPT_EDGE" or not isinstance(source_entities, list) or len(source_entities) < 2: - raise ValueError(f"Feature {feature_id} selector {index} source-vertex anchor requires SWEPT_EDGE and two source entities") + minimum = 1 if family == "CAP_FACE" else 2 + if family not in {"SWEPT_EDGE", "CAP_VERTEX", "CAP_FACE", "OFFSET_EDGE"} or not isinstance(source_entities, list) or len(source_entities) < minimum: + raise ValueError(f"Feature {feature_id} selector {index} source entity set is invalid for {family}") pairs = [] for source in source_entities: if not isinstance(source, dict): @@ -130,6 +497,8 @@ def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index: if len(set(pairs)) != len(pairs): raise ValueError(f"Feature {feature_id} selector {index} source-vertex anchor repeats an entity") intersection_sources = intent.get("intersection_sources") + blend_sources = intent.get("blend_sources") + blend_face_source = intent.get("blend_face_source") deferred_source_query = ( policy.get("multiplicity") == "none" and intent.get("evidence") == "feature_script_query" @@ -180,6 +549,67 @@ def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index: raise ValueError(f"Feature {feature_id} selector {index} INTERSECT sources must have distinct owners") elif intersection_sources is not None: raise ValueError(f"Feature {feature_id} selector {index} only INTERSECT may declare section sources") + if family == "BLEND_EDGE": + if deferred_source_query: + # Preserve an unsupported BLEND_EDGE query for diagnostics and + # bounded replay failure. It cannot carry executable transition + # witnesses, just as a deferred INTERSECT cannot carry section + # witnesses above. + if blend_sources is not None: + raise ValueError(f"Feature {feature_id} selector {index} deferred BLEND_EDGE cannot declare transition sources") + else: + edge_source = blend_sources.get("edge") if isinstance(blend_sources, dict) else None + face_source = blend_sources.get("face") if isinstance(blend_sources, dict) else None + if ( + selector.get("kind") != "edge" + or selector.get("source") != "runtime_snapshot" + or intent.get("evidence") != "kernel_history" + or policy.get("allowed") != ["boundary"] + or policy.get("multiplicity") != "one" + or not isinstance(edge_source, dict) + or not isinstance(face_source, dict) + or edge_source.get("query_family") != "CAP_EDGE" + or face_source.get("query_family") not in {"CAP_FACE", "SWEPT_FACE"} + or edge_source.get("owner_feature_id") != face_source.get("owner_feature_id") + or not isinstance(edge_source.get("owner_feature_id"), str) + or not isinstance(edge_source.get("source_entity"), dict) + or edge_source.get("lineage_role") not in {"extrude.start", "extrude.end"} + or any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id", "output_role")) + ): + raise ValueError(f"Feature {feature_id} selector {index} has an invalid BLEND_EDGE transition contract") + if face_source.get("query_family") == "CAP_FACE": + if face_source.get("output_role") not in {"extrude.start", "extrude.end"} or face_source.get("source_entity") is not None: + raise ValueError(f"Feature {feature_id} selector {index} has an invalid BLEND_EDGE cap-face source") + elif not isinstance(face_source.get("source_entity"), dict) or face_source.get("output_role") is not None: + raise ValueError(f"Feature {feature_id} selector {index} has an invalid BLEND_EDGE swept-face source") + elif face_source["source_entity"] != edge_source["source_entity"]: + raise ValueError(f"Feature {feature_id} selector {index} BLEND_EDGE swept-face source must match its cap edge") + elif blend_sources is not None: + raise ValueError(f"Feature {feature_id} selector {index} only BLEND_EDGE may declare blend sources") + if family == "BLEND_FACE": + if deferred_source_query: + if blend_face_source is not None: + raise ValueError(f"Feature {feature_id} selector {index} deferred BLEND_FACE cannot declare a patch source") + else: + if ( + selector.get("kind") != "face" + or selector.get("source") != "runtime_snapshot" + or intent.get("evidence") != "kernel_history" + or policy.get("allowed") != ["boundary"] + or policy.get("multiplicity") != "one" + or not isinstance(blend_face_source, dict) + or blend_face_source.get("query_family") != "CAP_EDGE" + or not isinstance(blend_face_source.get("owner_feature_id"), str) + or not isinstance(blend_face_source.get("source_entity"), dict) + or blend_face_source.get("lineage_role") not in {"extrude.start", "extrude.end"} + or any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id", "output_role")) + ): + raise ValueError(f"Feature {feature_id} selector {index} has an invalid BLEND_FACE patch contract") + source = blend_face_source["source_entity"] + if not all(isinstance(source.get(key), str) and source[key] for key in ("sketch_id", "entity_id")): + raise ValueError(f"Feature {feature_id} selector {index} BLEND_FACE source edge is incomplete") + elif blend_face_source is not None: + raise ValueError(f"Feature {feature_id} selector {index} only BLEND_FACE may declare a patch source") forbidden = {"runtime_id", "record_id", "topology_record_id", "task_id", "revision_id"} stack = [intent] while stack: @@ -244,33 +674,61 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: sketches = (cdsl.get("geometry") or {}).get("sketches") or [] sketch_ids = {str(sketch.get("id") or "") for sketch in sketches} + source_sketch_ids = {str(sketch.get("source_sketch_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") + if profile.get("type") == "planar_imprint": + 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) + external_anchors = profile.get("external_anchors") or [] + external_ids = [str(anchor.get("id") or "") for anchor in external_anchors if isinstance(anchor, dict)] + if len(external_ids) != len(external_anchors) or len(set(external_ids)) != len(external_ids) or not all(external_ids): + raise ValueError(f"Sketch {sketch.get('id')} planar_imprint external anchor ids must be unique") + if external_ids and not isinstance(sketch.get("attachment"), dict): + raise ValueError(f"Sketch {sketch.get('id')} planar_imprint external anchors require a runtime face attachment") + if len(entities) == 1 and not external_ids: + raise ValueError(f"Sketch {sketch.get('id')} planar_imprint needs two source entities without an external anchor") + 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 "" + external_anchor = str(fragment.get("external_anchor_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 bool(anchor) == bool(external_anchor): + raise ValueError(f"Sketch {sketch.get('id')} planar_imprint selection {index} must name exactly one fragment anchor") + if anchor and (anchor not in known or anchor == source): + raise ValueError(f"Sketch {sketch.get('id')} planar_imprint selection {index} has an invalid fragment anchor") + if external_anchor and external_anchor not in external_ids: + raise ValueError(f"Sketch {sketch.get('id')} planar_imprint selection {index} has an unknown external anchor") + if len(entities) == 1 and not external_anchor: + raise ValueError( + f"Sketch {sketch.get('id')} planar_imprint single-source selection {index} requires an external anchor" + ) + elif profile.get("type") == "multi_source_regions": + sources, children = profile.get("source_sketch_ids") or [], profile.get("profiles") or [] + if len(sources) < 2 or len(sources) != len(children) or len(set(sources)) != len(sources): + raise ValueError(f"Sketch {sketch.get('id')} multi_source_regions must have one unique source id per profile") + if sketch.get("source_sketch_id") is not None or sketch.get("attachment") is not None: + raise ValueError(f"Sketch {sketch.get('id')} multi_source_regions cannot claim a single source or runtime attachment") + allowed = {"circle", "polygon", "analytic_contours"} + if any(not isinstance(child, dict) or child.get("type") not in allowed for child in children): + raise ValueError(f"Sketch {sketch.get('id')} multi_source_regions has an unsupported child profile") feature_ids: set[str] = set() preceding_features: dict[str, dict[str, Any]] = {} previous_feature_id: str | None = None + assigned_variable_names: set[str] = set() deferred: list[str] = [] unresolved: list[dict[str, Any]] = [] contracts = _operation_contracts() - for feature in cdsl.get("features") or []: + features = cdsl.get("features") or [] + for feature in features: fid = str(feature.get("id") or "") if not _ID.fullmatch(fid) or fid in feature_ids: raise ValueError("Feature ids must be unique valid CDSL identifiers") @@ -285,20 +743,28 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: if feature.get("execution_status") == "deferred": deferred.append(fid) contract = contracts.get(str(feature.get("atomic_id") or "")) or {} + assigned_variable_name = ( + _validate_assign_variable(feature, assigned_variable_names) + if feature.get("atomic_id") == "assign_variable" else None + ) + _validate_source_vertex_extent_references(feature, source_sketch_ids) feature_selectors = _contract_selectors(feature, contract) extent_selector = _up_to_surface_output_role_reference(feature, contract) + two_sided_extent_pair = _two_sided_up_to_surface_cap_pair_references(feature, contract) output_role_selectors = [ *feature_selectors, *([extent_selector] if extent_selector is not None else []), + *(list(two_sided_extent_pair) if two_sided_extent_pair is not None else []), ] - output_role_selector_ids = {id(selector) for selector in output_role_selectors} + selector_slot_selectors = _selector_slot_descendants(output_role_selectors) + output_role_selector_ids = {id(selector) for selector in selector_slot_selectors} selector_intent_ids = { id(selector.get("selector_intent")) - for selector in output_role_selectors + for selector in selector_slot_selectors if isinstance(selector, dict) and isinstance(selector.get("selector_intent"), dict) } validated_selector_ids: set[int] = set() - for index, selector in enumerate(output_role_selectors): + for index, selector in enumerate(selector_slot_selectors): _validate_selector_intent(selector, fid, index) validated_selector_ids.add(id(selector)) owner = selector.get("owner_feature_id") @@ -314,26 +780,104 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: raise ValueError(f"Feature {fid} selector {index} output role cannot mix stable or geometry evidence") role_source = selector.get("output_role_source") selector_intent = selector.get("selector_intent") + is_retained_shell_cap_offset_profile = ( + feature.get("atomic_id") == "extrude_from_face" + and isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "OFFSET_FACE" + and selector_intent.get("consumer_contract") + == "shell_retained_direct_prism_cap_offset_face_profile" + and selector.get("output_role") == "shell.offset_face" + and owner == previous_feature_id + ) is_shell_cap_face_output_role = ( feature.get("atomic_id") == "shell" and isinstance(selector_intent, dict) and selector_intent.get("query_family") == "CAP_FACE" - and selector.get("output_role") in {"extrude.start", "extrude.end"} + and selector.get("output_role") in { + "extrude.start", "extrude.end", "loft.start", "loft.end", "sweep.start", "sweep.end", + } ) - if selector is extent_selector: - if owner != previous_feature_id or not is_direct_blind_extrude_cap_output_role( - selector, preceding_features.get(str(owner)), {str(sketch.get("id") or ""): sketch for sketch in sketches}, + is_imprint_dressup_cap_output_role = ( + feature.get("atomic_id") in {"fillet", "chamfer"} + and isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "CAP_FACE" + and selector.get("output_role") in {"extrude.start", "extrude.end"} + and selector_intent.get("consumer_contract") != "primary_add_dressup_union_continuation" + ) + is_primary_add_dressup_cap_role = ( + feature.get("atomic_id") in {"fillet", "chamfer"} + and isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "CAP_FACE" + and selector.get("output_role") in {"extrude.start", "extrude.end"} + and is_primary_add_dressup_cap_output_role( + selector, preceding_features.get(str(owner)), + {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + ) + if two_sided_extent_pair is not None and any(selector is reference for reference in two_sided_extent_pair): + if owner != previous_feature_id or not is_symmetric_direct_prism_two_sided_up_to_surface_cap_pair( + two_sided_extent_pair[0], two_sided_extent_pair[1], + preceding_features.get(str(owner)), {str(sketch.get("id") or ""): sketch for sketch in sketches}, ): raise ValueError( - f"Feature {fid} up_to_surface output role requires the immediately preceding direct new_body blind extrusion cap" + f"Feature {fid} two-sided up_to_surface CAP roles require the immediately preceding direct symmetric new_body prism pair" + ) + elif selector is extent_selector: + retained_source_swept_face = is_immediate_retained_source_prism_swept_face_extent( + selector, + preceding_features.get(str(owner)), + {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + if owner != previous_feature_id or not ( + is_direct_blind_extrude_cap_output_role( + selector, preceding_features.get(str(owner)), {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + or is_primary_add_up_to_surface_cap_output_role( + selector, preceding_features.get(str(owner)), {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + or retained_source_swept_face + ): + raise ValueError( + f"Feature {fid} up_to_surface reference requires an immediately preceding direct new_body or primary ADD blind extrusion cap, or one exact retained source wall" ) elif is_shell_cap_face_output_role: - if owner != previous_feature_id or not is_direct_blind_extrude_cap_output_role( - selector, preceding_features.get(str(owner)), {str(sketch.get("id") or ""): sketch for sketch in sketches}, - ): + producer = preceding_features.get(str(owner)) + is_valid = ( + is_direct_blind_extrude_cap_output_role( + selector, producer, {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + or is_primary_add_shell_cap_output_role( + selector, producer, {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + or is_initial_direct_loft_cap_output_role( + selector, producer, {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + or is_initial_direct_sweep_cap_output_role( + selector, producer, {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + or is_initial_two_sided_circle_shell_cap_output_role( + selector, producer, {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + ) + if owner != previous_feature_id or not is_valid: raise ValueError( f"Feature {fid} CAP_FACE output role requires the immediately preceding direct new_body blind extrusion cap" ) + elif is_primary_add_dressup_cap_role: + if owner != previous_feature_id: + raise ValueError( + f"Feature {fid} primary ADD CAP_FACE output role requires the immediately preceding blind extrusion" + ) + elif is_imprint_dressup_cap_output_role: + producer = preceding_features.get(str(owner)) + sketch_map = {str(sketch.get("id") or ""): sketch for sketch in sketches} + if owner != previous_feature_id or not ( + is_planar_imprint_extrude_cap_output_role(selector, producer, sketch_map) + or is_direct_blind_extrude_cap_output_role(selector, producer, sketch_map) + ): + raise ValueError( + f"Feature {fid} CAP_FACE dress-up role requires an immediately preceding complete direct or planar-IMPRINT new_body blind prism" + ) elif not contract.get("selector_slot") or contract.get("selector_token_kind") != "face": raise ValueError(f"Feature {fid} selector {index} cannot consume a feature output role") if role_source is not None: @@ -343,9 +887,12 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: 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": + if ( + selector.get("output_role") != "shell.offset_face" + or not (feature.get("atomic_id") == "shell" or is_retained_shell_cap_offset_profile) + ): raise ValueError( - f"Feature {fid} selector {index} output role source is only supported for shell.offset_face" + f"Feature {fid} selector {index} output role source is only supported for a shell offset-face contract" ) if source_role not in {"extrude.start", "extrude.end"}: raise ValueError( @@ -360,8 +907,140 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: raise ValueError( f"Feature {fid} selector {index} shell.offset_face source requires a preceding direct new_body blind extrusion" ) + if is_retained_shell_cap_offset_profile: + shell_feature = preceding_features.get(str(owner)) or {} + shell_selectors = shell_feature.get("selectors") or [] + shell_params = shell_feature.get("params") or {} + feature_params = feature.get("params") or {} + removed = shell_selectors[0] if len(shell_selectors) == 1 else {} + removed_intent = removed.get("selector_intent") if isinstance(removed, dict) else {} + disambiguation = selector_intent.get("disambiguation") or {} + source_ids = disambiguation.get("source_profile_entity_ids") if isinstance(disambiguation, dict) else None + expected_retained = "extrude.end" if removed.get("output_role") == "extrude.start" else "extrude.start" + if ( + shell_feature.get("atomic_id") != "shell" + or shell_feature.get("depends_on") != [source_owner] + or not isinstance(removed, dict) + or removed.get("owner_feature_id") != source_owner + or removed.get("output_role") not in {"extrude.start", "extrude.end"} + or not isinstance(removed_intent, dict) + or removed_intent.get("query_family") != "CAP_FACE" + or source_role != expected_retained + or not isinstance(source_ids, list) + or not source_ids + or len(set(source_ids)) != len(source_ids) + or disambiguation.get("removed_cap_role") != removed.get("output_role") + or feature_params.get("operation") != "add" + or feature_params.get("result_mode") != "new_body" + or (feature_params.get("end_condition") or {}).get("type") != "blind" + or feature_params.get("two_sided") + or feature_params.get("draft") is not None + ): + raise ValueError( + f"Feature {fid} selector {index} retained shell cap profile requires one immediate opposite direct-prism cap removal" + ) elif selector.get("output_role_source") is not None: raise ValueError(f"Feature {fid} selector {index} output role source requires output_role") + if selector.get("query_operands") is not None and feature.get("atomic_id") not in {"fillet", "chamfer"}: + raise ValueError(f"Feature {fid} qUnion selectors are supported only for fillet or chamfer") + selector_intent = selector.get("selector_intent") if isinstance(selector, dict) else None + if ( + isinstance(selector_intent, dict) + and selector_intent.get("consumer_contract") == "immediate_retained_source_prism_swept_face_up_to_surface" + and selector is not extent_selector + ): + raise ValueError( + f"Feature {fid} retained source prism SWEPT_FACE is valid only as its up_to_surface reference" + ) + if ( + isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "OFFSET_EDGE" + and selector_intent.get("consumer_contract") == "direct_prism_shell_offset_edge_tdd" + and ( + feature.get("atomic_id") not in {"fillet", "chamfer"} + or not isinstance(selector_intent.get("disambiguation"), dict) + or selector_intent["disambiguation"].get("shell_feature_id") != previous_feature_id + or not is_direct_prism_shell_offset_edge_tdd( + selector, + preceding_features.get(str(previous_feature_id or "")), + preceding_features.get(str(selector.get("owner_feature_id") or "")), + {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + ) + ): + raise ValueError( + f"Feature {fid} OFFSET_EDGE TDD requires the immediately preceding direct-prism shell retained-cap continuation" + ) + if ( + isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "OFFSET_EDGE" + and selector_intent.get("consumer_contract") == "direct_prism_shell_offset_edge_vertex" + and ( + feature.get("atomic_id") not in {"fillet", "chamfer"} + or not isinstance(selector_intent.get("disambiguation"), dict) + or selector_intent["disambiguation"].get("shell_feature_id") != previous_feature_id + or not is_direct_prism_shell_offset_edge_vertex( + selector, + preceding_features.get(str(previous_feature_id or "")), + preceding_features.get(str(selector.get("owner_feature_id") or "")), + {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + ) + ): + raise ValueError( + f"Feature {fid} OFFSET_EDGE vertex requires the immediately preceding direct-prism shell continuation" + ) + if ( + isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "CAP_EDGE" + and selector_intent.get("lineage_role") in {"sweep.start", "sweep.end"} + and ( + feature.get("atomic_id") not in {"fillet", "chamfer"} + or selector.get("owner_feature_id") != previous_feature_id + or not is_initial_direct_sweep_cap_edge( + selector, + preceding_features.get(str(selector.get("owner_feature_id") or "")), + {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + ) + ): + raise ValueError( + f"Feature {fid} CAP_EDGE sweep role requires the immediately preceding direct new_body one-edge sweep" + ) + if ( + isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "SWEPT_FACE" + and (selector_intent.get("disambiguation") or {}).get("type") == "sweep_profile_path" + and ( + feature.get("atomic_id") not in {"fillet", "chamfer"} + or selector.get("owner_feature_id") != previous_feature_id + or not is_initial_direct_sweep_swept_face( + selector, + preceding_features.get(str(selector.get("owner_feature_id") or "")), + {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + ) + ): + raise ValueError( + f"Feature {fid} SWEPT_FACE sweep relation requires the immediately preceding direct new_body analytic-profile sweep" + ) + if ( + isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "SWEPT_EDGE" + and (selector_intent.get("disambiguation") or {}).get("type") == "sweep_profile_vertex_path" + and ( + feature.get("atomic_id") not in {"fillet", "chamfer"} + or selector.get("owner_feature_id") != previous_feature_id + or not is_initial_direct_sweep_swept_edge( + selector, + preceding_features.get(str(selector.get("owner_feature_id") or "")), + {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + ) + ): + raise ValueError( + f"Feature {fid} SWEPT_EDGE sweep relation requires the immediately preceding direct new_body analytic-profile sweep" + ) for selector in _mappings(feature): if ( id(selector) not in validated_selector_ids @@ -375,6 +1054,65 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: raise ValueError( f"Feature {fid} nested selector has a forward or missing owner_feature_id" ) + selector_intent = selector.get("selector_intent") if isinstance(selector, dict) else None + if ( + isinstance(selector_intent, dict) + and selector_intent.get("consumer_contract") == "immediate_retained_source_prism_swept_face_up_to_surface" + ): + end_condition = (feature.get("params") or {}).get("end_condition") or {} + extent_reference = end_condition.get("reference") if isinstance(end_condition, dict) else None + if ( + feature.get("atomic_id") not in {"extrude_add_blind", "extrude_cut_blind"} + or end_condition.get("type") != "up_to_surface" + or selector is not extent_reference + or selector.get("owner_feature_id") != previous_feature_id + or not is_immediate_retained_source_prism_swept_face_extent( + selector, + preceding_features.get(str(previous_feature_id or "")), + {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + ): + raise ValueError( + f"Feature {fid} retained source prism SWEPT_FACE requires the immediately preceding direct new-body prism up_to_surface extent" + ) + if ( + isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "OFFSET_EDGE" + and selector_intent.get("consumer_contract") == "direct_prism_shell_offset_edge_tdd" + and ( + feature.get("atomic_id") not in {"fillet", "chamfer"} + or not isinstance(selector_intent.get("disambiguation"), dict) + or selector_intent["disambiguation"].get("shell_feature_id") != previous_feature_id + or not is_direct_prism_shell_offset_edge_tdd( + selector, + preceding_features.get(str(previous_feature_id or "")), + preceding_features.get(str(selector.get("owner_feature_id") or "")), + {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + ) + ): + raise ValueError( + f"Feature {fid} OFFSET_EDGE TDD requires the immediately preceding direct-prism shell retained-cap continuation" + ) + if ( + isinstance(selector_intent, dict) + and selector_intent.get("query_family") == "OFFSET_EDGE" + and selector_intent.get("consumer_contract") == "direct_prism_shell_offset_edge_vertex" + and ( + feature.get("atomic_id") not in {"fillet", "chamfer"} + or not isinstance(selector_intent.get("disambiguation"), dict) + or selector_intent["disambiguation"].get("shell_feature_id") != previous_feature_id + or not is_direct_prism_shell_offset_edge_vertex( + selector, + preceding_features.get(str(previous_feature_id or "")), + preceding_features.get(str(selector.get("owner_feature_id") or "")), + {str(sketch.get("id") or ""): sketch for sketch in sketches}, + ) + ) + ): + raise ValueError( + f"Feature {fid} OFFSET_EDGE vertex requires the immediately preceding direct-prism shell continuation" + ) # ``output_role_source`` is provenance metadata nested inside a # feature selector, not a selector on its own. if ( @@ -398,6 +1136,39 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: raise ValueError(f"Feature {fid} hole scope_feature_id requires a preceding body feature") if feature.get("atomic_id") == "transform_bodies": params = feature.get("params") or {} + source_member_aliases = params.get("source_member_aliases") or [] + if source_member_aliases: + direct_sources = params.get("source_feature_ids") or [] + if ( + not bool(params.get("make_copy")) + or not isinstance(direct_sources, list) + or len(direct_sources) != 1 + ): + raise ValueError( + f"Feature {fid} source member aliases require a single-source make_copy transform" + ) + active_member = str(direct_sources[0]) + if active_member not in preceding_features: + raise ValueError( + f"Feature {fid} source member aliases require a preceding active member" + ) + seen_alias_sources: set[str] = set() + for index, alias in enumerate(source_member_aliases): + if not isinstance(alias, dict): + raise ValueError(f"Feature {fid} source member alias {index} is invalid") + source_id = str(alias.get("source_feature_id") or "") + alias_member = str(alias.get("active_member_feature_id") or "") + if ( + not source_id + or source_id == active_member + or alias_member != active_member + or source_id not in preceding_features + or source_id in seen_alias_sources + ): + raise ValueError( + f"Feature {fid} source member alias {index} does not bind one preceding semantic source to its selected member" + ) + seen_alias_sources.add(source_id) references = params.get("pattern_instance_refs") or [] for index, reference in enumerate(references): if not isinstance(reference, dict): @@ -466,6 +1237,27 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: ) if feature.get("atomic_id") == "boolean_bodies": params = feature.get("params") or {} + if params.get("targetless_body_set"): + targets = params.get("target_feature_ids") or [] + target_patterns = params.get("target_pattern_instance_refs") or [] + target_transforms = params.get("target_transform_copy_refs") or [] + tools = params.get("tool_feature_ids") or [] + tool_patterns = params.get("tool_pattern_instance_refs") or [] + tool_transforms = params.get("tool_transform_copy_refs") or [] + if ( + params.get("operation") not in {"union", "intersect"} + or not isinstance(targets, list) + or not isinstance(target_patterns, list) + or not isinstance(target_transforms, list) + or len(targets) + len(target_patterns) + len(target_transforms) != 1 + or not isinstance(tools, list) + or not isinstance(tool_patterns, list) + or not isinstance(tool_transforms, list) + or not (len(tools) + len(tool_patterns) + len(tool_transforms)) + ): + raise ValueError( + f"Feature {fid} targetless_body_set requires one qualified body and UNION/INTERSECTION tools" + ) 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): @@ -495,11 +1287,91 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: 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") + for parameter in ("target_transform_copy_refs", "tool_transform_copy_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") + 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} {parameter} entry {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} {parameter} entry {index} names a source outside its transform" + ) + for parameter in ("target_feature_ids", "tool_feature_ids"): + for source_id in params.get(parameter) 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 references to select a source of multi-source COPY {source_id}" + ) if feature.get("unresolved"): unresolved.append({"feature_id": fid, "reasons": list(feature["unresolved"])}) feature_ids.add(fid) preceding_features[fid] = feature - previous_feature_id = fid + if assigned_variable_name is not None: + assigned_variable_names.add(assigned_variable_name) + else: + previous_feature_id = fid + + sketches_by_id = {str(sketch.get("id") or ""): sketch for sketch in sketches} + for sketch in sketches: + attachment = sketch.get("attachment") if isinstance(sketch, dict) else None + intent = attachment.get("selector_intent") if isinstance(attachment, dict) else None + profile = sketch.get("profile") if isinstance(sketch, dict) else None + if isinstance(profile, dict) and profile.get("type") == "planar_imprint": + for index, anchor in enumerate(profile.get("external_anchors") or []): + selector = anchor.get("selector") if isinstance(anchor, dict) else None + if not isinstance(selector, dict) or selector.get("kind") != "edge": + raise ValueError(f"Sketch {sketch.get('id')} planar_imprint external anchor {index} requires an edge selector") + _validate_selector_intent(selector, f"sketch {sketch.get('id')} external anchor", index) + if not isinstance(intent, dict): + continue + _validate_selector_intent(attachment, f"sketch {sketch.get('id')}", 0) + _validate_direct_prism_cap_face_attachment( + sketch, + features=features, + preceding_features=preceding_features, + sketches_by_id=sketches_by_id, + ) + if intent.get("query_family") == "COPY" and intent.get("copy_contract") == "primary_cut_cap_face_workplane": + _validate_primary_cut_copy_cap_face_attachment( + sketch, + preceding_features=preceding_features, + sketches_by_id=sketches_by_id, + ) + if intent.get("query_family") == "COPY" and intent.get("copy_contract") == "primary_cut_swept_face_workplane": + _validate_primary_cut_copy_swept_face_attachment( + sketch, + preceding_features=preceding_features, + sketches_by_id=sketches_by_id, + ) + if intent.get("query_family") == "BLEND_FACE": + _validate_direct_prism_blend_face_attachment( + sketch, + preceding_features=preceding_features, + sketches_by_id=sketches_by_id, + ) return { "schema_version": version, diff --git a/backend/engine/cdsl_engine/session.py b/backend/engine/cdsl_engine/session.py index a4a514a7..db770639 100644 --- a/backend/engine/cdsl_engine/session.py +++ b/backend/engine/cdsl_engine/session.py @@ -14,6 +14,7 @@ from typing import Any, Protocol from .build123d_adapter import Build123dGeometryAdapter from .runtime_base import FeatureExecutionError +from .sketch_solver import resolve_profile from .specs import AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec, Vector3 from .topology import ( FeaturePlanNode, @@ -38,18 +39,25 @@ class GeometryAdapter(Protocol): def body_solids(self, body: Any) -> list[Any]: ... def body_geometry(self, body: Any) -> dict[str, Any]: ... def surface_geometry(self, surface: Any) -> dict[str, Any]: ... - def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ... - def faces_for_sketch_with_source_anchors(self, sketch: dict[str, Any]) -> tuple[list[Any], list[dict[str, Any]]]: ... + def faces_for_sketch(self, sketch: dict[str, Any], *, support_face: Any | None = None, + external_anchor_edges: dict[str, Any] | None = None) -> list[Any]: ... + def faces_for_sketch_with_source_anchors(self, sketch: dict[str, Any], *, support_face: Any | None = None, + external_anchor_edges: dict[str, Any] | None = None) -> tuple[list[Any], list[dict[str, 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_surface(self, sketches: list[dict[str, Any]]) -> Any: ... def loft_with_cap_face(self, cap_face: Any, sketches: list[dict[str, Any]]) -> Any: ... def sweep(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> Any: ... def sweep_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 sweep_arc_path(self, start: Vector3, end: Vector3, center: Vector3, normal: Vector3, *, radius_mm: float, clockwise: bool) -> Any: ... + def sweep_circle_path(self, center: Vector3, x_dir: Vector3, normal: Vector3, *, radius_mm: float) -> Any: ... def face_normal(self, face: Any) -> Vector3: ... + def planar_face_workplane(self, face: Any) -> PlaneSpec: ... def extrude(self, face: Any, direction: Vector3) -> Any: ... def extrude_with_topology_delta(self, face: Any, direction: Vector3) -> tuple[Any, TopologyDelta]: ... + def extrude_faces_with_composed_topology_delta(self, faces: list[Any], direction: Vector3) -> tuple[Any, TopologyDelta] | None: ... 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: ... @@ -57,6 +65,7 @@ class GeometryAdapter(Protocol): def extrude_surface(self, wires: list[Any], direction: Vector3) -> Any: ... def combine_surfaces(self, *surfaces: Any) -> Any: ... def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ... + def revolve_with_topology_delta(self, face: Any, angle_deg: float, axis: AxisSpec) -> tuple[Any, TopologyDelta]: ... 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]: ... @@ -82,6 +91,8 @@ class GeometryAdapter(Protocol): def profile_touches_target(self, target: Any, faces: list[Any]) -> bool: ... def next_body_face_after(self, body: Any, faces: list[Any], direction: Vector3, *, excluded_face: Any) -> Any: ... def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ... + def target_has_forward_intersection(self, target: Any, faces: list[Any], direction: Vector3) -> bool: ... + def uniform_planar_supporting_surface_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]: ... @@ -104,10 +115,85 @@ class ExecutionSession: results: dict[str, FeatureResult] = field(default_factory=dict) replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict) body_members: dict[str, Any] = field(default_factory=dict) + body_member_snapshot_ids: dict[str, str] = field(default_factory=dict) surface_members: dict[str, Any] = field(default_factory=dict) + sketch_attachment_faces: dict[str, Any] = field(default_factory=dict) + sketch_imprint_external_edges: dict[str, dict[str, Any]] = field(default_factory=dict) selector_resolutions: list[dict[str, Any]] = field(default_factory=list) active_feature_id: str = "" + def resolve_sketch_attachment(self, sketch_id: str, *, feature_id: str | None = None) -> None: + """Resolve one runtime-attached sketch immediately before consumption.""" + sketch = self.sketches.get(sketch_id) + if sketch is None or not isinstance(sketch.get("attachment"), dict): + return + previous_feature_id = self.active_feature_id + if feature_id is not None: + self.active_feature_id = feature_id + try: + resolution = self.resolve(sketch["attachment"]) + if resolution.status != "resolved" or resolution.record is None or resolution.record.kind != "face": + detail = resolution.diagnostic.message if resolution.diagnostic else "attached face was not resolved" + raise FeatureExecutionError("sketch_attachment_unresolved", detail) + try: + plane = self.adapter.planar_face_workplane(resolution.record.value) + except ValueError as error: + raise FeatureExecutionError("sketch_attachment_nonplanar", str(error)) from error + external_edges: dict[str, Any] = {} + profile = sketch.get("profile") or {} + for entry in profile.get("external_anchors") or (): + anchor_id = entry.get("id") if isinstance(entry, dict) else None + selector = entry.get("selector") if isinstance(entry, dict) else None + if not isinstance(anchor_id, str) or not anchor_id or not isinstance(selector, dict): + raise FeatureExecutionError("imprint_external_anchor_invalid", "external anchor contract is invalid") + edge_resolution = self.resolve(selector) + if ( + edge_resolution.status != "resolved" + or edge_resolution.record is None + or edge_resolution.record.kind != "edge" + ): + detail = edge_resolution.diagnostic.message if edge_resolution.diagnostic else "external anchor edge was not resolved" + raise FeatureExecutionError("imprint_external_anchor_unresolved", detail) + if not any(edge.wrapped.IsSame(edge_resolution.record.value.wrapped) for edge in resolution.record.value.edges()): + raise FeatureExecutionError( + "imprint_external_anchor_not_support_boundary", + "external anchor edge is not an exact boundary of the attached support face", + ) + external_edges[anchor_id] = edge_resolution.record.value + materialized = deepcopy(sketch) + materialized["workplane"] = plane.as_dict() + # IMPRINT face-side disambiguation is expressed in the source + # sketch's oriented plane. A native attached face may have the + # opposite normal, which reverses the physical side of the same + # local curve after its coordinates are mapped to that face. + # This is a session-local frame conversion, not selector or + # geometry fallback data. + source_profile = materialized.get("profile") or {} + if source_profile.get("type") == "planar_imprint": + try: + source_plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) + except ValueError as error: + raise FeatureExecutionError("sketch_attachment_frame_invalid", str(error)) from error + orientation = sum( + source_plane.normal[index] * plane.normal[index] + for index in range(3) + ) + if orientation < 0.0: + for selection in source_profile.get("selections") or (): + if isinstance(selection, dict) and selection.get("face_side") in {-1, 1}: + selection["face_side"] = -selection["face_side"] + # Preflight intentionally leaves attached sketches local. Resolve now + # so every world-space contour is rebuilt from the proven face frame. + materialized = resolve_profile(materialized) + self.sketches[sketch_id] = materialized + # Keep exact supports only in the live session. They are never CDSL + # geometry, serialized record IDs, or selector fallback evidence. + self.sketch_attachment_faces[sketch_id] = resolution.record.value + if external_edges: + self.sketch_imprint_external_edges[sketch_id] = external_edges + finally: + self.active_feature_id = previous_feature_id + def register_body( self, feature_id: str, @@ -123,6 +209,8 @@ class ExecutionSession: # 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id": # 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标 # 实体;单体保持 body:{feature}(与历史行为完全一致)。 + previous_members = dict(self.body_members) + previous_snapshot_ids = dict(self.body_member_snapshot_ids) self.body = body self.body_id = f"body:{feature_id}" self.body_members = dict(body_members) if body_members is not None else {feature_id: body} @@ -135,11 +223,27 @@ class ExecutionSession: self.topology.register(anchor) predecessors = [*(topology_predecessors or ()), *anchors] solids = self.adapter.body_solids(body) + member_snapshot_ids: dict[str, str] = {} + for member_key, member in self.body_members.items(): + matches = [ + index for index, solid in enumerate(solids) + if TopologyRegistry._same_topology_value(member, solid) + ] + if len(matches) == 1: + member_snapshot_ids[member_key] = ( + self.body_id if len(solids) == 1 else f"{self.body_id}:{matches[0]}" + ) + member_preservations = [ + (previous_snapshot_ids[key], member_snapshot_ids[key]) + for key, member in previous_members.items() + if key in previous_snapshot_ids and key in member_snapshot_ids + and TopologyRegistry._same_topology_value(member, self.body_members[key]) + ] if len(solids) <= 1: 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=predecessors, + additional_predecessors=predecessors, member_preservations=member_preservations, ) else: # 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让 @@ -152,7 +256,7 @@ class ExecutionSession: ] self.topology.replace_body_topologies( feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta, - additional_predecessors=predecessors, + additional_predecessors=predecessors, member_preservations=member_preservations, ) self.topology.register(TopologyRecord( record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id, @@ -160,6 +264,7 @@ class ExecutionSession: )) if replay_node is not None: self.replay_definitions[feature_id] = replay_node + self.body_member_snapshot_ids = member_snapshot_ids def register_transient_prism_tool( self, @@ -198,6 +303,7 @@ class ExecutionSession: self.body = None self.body_id = None self.body_members = {} + self.body_member_snapshot_ids = {} def _record_selector_resolution(self, resolution: SelectorResolution) -> SelectorResolution: evidence = resolution.as_dict() diff --git a/backend/engine/cdsl_engine/sketch_solver.py b/backend/engine/cdsl_engine/sketch_solver.py index f749618d..22a6f0dd 100644 --- a/backend/engine/cdsl_engine/sketch_solver.py +++ b/backend/engine/cdsl_engine/sketch_solver.py @@ -489,6 +489,49 @@ def _contains(point: tuple[float, float], loop: list[tuple[float, float]]) -> bo return inside +def _point_on_loop_boundary(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool: + """Return whether a sampled contour point lies on one sampled boundary. + + Region nesting is topology, not a ray-casting tie-break. A contour that + shares a vertex or an edge with another contour cannot be a hole of it. + The ordinary parity test intentionally leaves that boundary case + unspecified, so keep it out of containment classification explicitly. + """ + if len(loop) < 2: + return False + px, py = point + previous = loop[-1] + for current in loop: + dx, dy = current[0] - previous[0], current[1] - previous[1] + length_squared = dx * dx + dy * dy + if length_squared <= _TOLERANCE_MM * _TOLERANCE_MM: + if math.dist(point, previous) <= _TOLERANCE_MM: + return True + else: + projection = ((px - previous[0]) * dx + (py - previous[1]) * dy) / length_squared + if -_TOLERANCE_MM <= projection <= 1.0 + _TOLERANCE_MM: + nearest = (previous[0] + projection * dx, previous[1] + projection * dy) + if math.dist(point, nearest) <= _TOLERANCE_MM: + return True + previous = current + return False + + +def _strictly_contains_loop(outer: list[tuple[float, float]], inner: list[tuple[float, float]]) -> bool: + """Require every sampled inner boundary point to be strictly inside outer. + + This conservative predicate rejects touching and intersecting contours + rather than manufacturing an invalid face with a self-identical or shared + hole. Curved contours are already sampled by ``_sample_loop`` before + this stage, so the decision uses the same region representation as the + existing parity classifier. + """ + return bool(inner) and all( + not _point_on_loop_boundary(point, outer) and _contains(point, outer) + for point in inner + ) + + def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: loops: list[_Ctx] = [] entities: list[_Ctx] = [] @@ -516,6 +559,16 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[ )) raw_edges.extend(_segment_edges(segment)) if raw_edges: + # A pure surface extrusion consumes its source wire directly. + # Do not invent the closing edge used by the solid-cut open + # profile contract: it would turn one requested ruled face into + # a different shell. This marker is emitted only by the CADFS + # ToolBodyType.SURFACE lowering contract. + if bool(contour.get("surface_wire")): + if not contour_open or contour.get("role") != "open": + raise ValueError(f"analytic_contours: surface wire {index} must be an open contour") + meta.setdefault("_surface_wires", []).append(_join(raw_edges, allow_open=True)) + continue edges = _join(raw_edges, allow_open=contour_open) contour_opened = False if contour_open: @@ -537,11 +590,17 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[ if not loops: return entities, [] for loop in loops: - loop["role"] = "inner" if sum(_contains(loop["points"][0], other["points"]) for other in loops if other is not loop) % 2 else "outer" + loop["role"] = "inner" if sum( + _strictly_contains_loop(other["points"], loop["points"]) + for other in loops if other is not loop + ) % 2 else "outer" outers = [loop for loop in loops if loop["role"] == "outer"] regions = [{"outer": outer["edges"], "holes": [], "open": bool(outer.get("open"))} for outer in outers] for inner in (loop for loop in loops if loop["role"] == "inner"): - containing = [outer for outer in outers if _contains(inner["points"][0], outer["points"])] + containing = [ + outer for outer in outers + if _strictly_contains_loop(outer["points"], inner["points"]) + ] if not containing: raise ValueError("analytic_contours: inner contour has no containing outer contour") if any(outer.get("open") for outer in containing): @@ -552,6 +611,58 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[ return entities, [] +def _multi_source_child_as_analytic(profile: _Ctx) -> _Ctx: + """Use the established region classifier once per source profile.""" + kind = profile.get("type") + if kind == "analytic_contours": + return deepcopy(profile) + if kind == "circle": + return { + "type": "analytic_contours", + "contours": [{"role": "outer", "closed": True, "segments": [{ + "type": "circle", "center": deepcopy(profile.get("center") or [0.0, 0.0]), + "radius_mm": profile.get("radius_mm"), + }]}], + } + if kind == "polygon": + vertices = profile.get("vertices") or [] + if len(vertices) < 3: + raise ValueError("multi_source_regions: polygon needs at least three vertices") + return { + "type": "analytic_contours", + "contours": [{"role": "outer", "closed": True, "segments": [ + {"type": "line", "start": deepcopy(vertices[index]), "end": deepcopy(vertices[(index + 1) % len(vertices)])} + for index in range(len(vertices)) + ]}], + } + raise ValueError(f"multi_source_regions: unsupported child profile type {kind!r}") + + +def _gen_multi_source_regions(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """Resolve each qSketchRegion source independently before taking its union. + + Flattening all contours would reclassify a nested region from a separate + sketch as a hole. FeatureScript qUnion selects each source region, so its + operands must retain independent outer/hole classification here. + """ + sources, profiles = profile.get("source_sketch_ids") or [], profile.get("profiles") or [] + if len(sources) < 2 or len(sources) != len(profiles) or len(set(sources)) != len(sources): + raise ValueError("multi_source_regions: source sketch ids must be unique and match profiles") + entities: list[_Ctx] = [] + regions: list[_Ctx] = [] + for child in profiles: + if not isinstance(child, dict): + raise ValueError("multi_source_regions: child profile must be an object") + child_meta: _Ctx = {"_regions": [], "_has_open_contour": False, "_surface_wires": []} + child_entities, _ = _gen_analytic_contours(_multi_source_child_as_analytic(child), child_meta) + if child_meta["_has_open_contour"] or child_meta["_surface_wires"] or not child_meta["_regions"]: + raise ValueError("multi_source_regions: every child must resolve to closed regions") + entities.extend(child_entities) + regions.extend(child_meta["_regions"]) + meta["_regions"] = regions + return entities, [] + + def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: """Prepare source curves for an exact OCC planar-arrangement split. @@ -571,8 +682,23 @@ def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ct 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") + external_anchors = profile.get("external_anchors") or [] + external_ids = { + str(entry.get("id") or "") + for entry in external_anchors + if isinstance(entry, dict) + } + single_external_fragment = ( + len(source_entities) == 1 + and bool(external_ids) + and all( + isinstance(selection, dict) + and str((selection.get("fragment") or {}).get("external_anchor_id") or "") in external_ids + for selection in profile.get("selections") or () + ) + ) + if len(source_entities) < 2 and not single_external_fragment: + raise ValueError("planar_imprint: at least two source entities or one external anchor are required") meta["_imprint_entities"] = source_entities meta["_imprint_selections"] = deepcopy(profile.get("selections") or []) return [], [] @@ -582,6 +708,7 @@ CORE_SHAPE_GENERATORS: dict[str, Any] = { "circle": _gen_circle, "polygon": _gen_polygon, "analytic_contours": _gen_analytic_contours, + "multi_source_regions": _gen_multi_source_regions, "planar_imprint": _gen_planar_imprint, } SHAPE_GENERATORS = CORE_SHAPE_GENERATORS @@ -589,6 +716,7 @@ SHAPE_CAPABILITIES: dict[str, _Ctx] = { "circle": {"detectable": True, "arity": "circle", "description": "single circular contour"}, "polygon": {"detectable": True, "arity": "polygon", "description": "closed straight-edge contour"}, "analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, circle, ellipse and B-spline contours"}, + "multi_source_regions": {"detectable": True, "arity": "multi_source", "description": "independent direct sketch regions on one shared frame"}, } @@ -609,7 +737,7 @@ def resolve_profile(sketch: _Ctx) -> _Ctx: 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, - "_imprint_entities": [], "_imprint_selections": [], + "_imprint_entities": [], "_imprint_selections": [], "_surface_wires": [], } entities, contour = generator(profile, meta) output = deepcopy(sketch) @@ -627,6 +755,11 @@ def resolve_profile(sketch: _Ctx) -> _Ctx: ] if meta["_has_open_contour"]: output["_open_contour"] = True + if meta["_surface_wires"]: + output["surface_wires_mm"] = [ + _transform_contours(wire, workplane) if workplane else wire + for wire in meta["_surface_wires"] + ] if meta["_imprint_entities"]: output["imprint_entities_mm"] = [ { diff --git a/backend/engine/cdsl_engine/topology.py b/backend/engine/cdsl_engine/topology.py index 39a2ec1b..eaaaf30b 100644 --- a/backend/engine/cdsl_engine/topology.py +++ b/backend/engine/cdsl_engine/topology.py @@ -14,6 +14,10 @@ from typing import Any, Iterable from .selector_capabilities import ( known_selector_query_standard_library_versions, known_selector_query_versions, + blend_face_selector_contract_error, + copy_selector_contract_error, + owner_body_selector_contract_error, + proven_operand_set_contract_error, selector_query_capability, ) from .specs import AxisSpec, PlaneSpec, Vector3, _length, _vector3, normalize_selector_geometry @@ -88,6 +92,38 @@ def validate_selector_provenance_intent(selector: dict[str, Any]) -> RuntimeDiag "FeatureScript query standard-library import or version is unavailable", detail={"query_family": intent.get("query_family")}, ) + query_set_error = proven_operand_set_contract_error(selector) + if query_set_error is not None: + return RuntimeDiagnostic( + "selector_query_set_invalid", + "The FeatureScript query-set selector contract is invalid", + detail={"reason": query_set_error}, + ) + owner_body_error = owner_body_selector_contract_error(selector) + if owner_body_error is not None: + return RuntimeDiagnostic( + "selector_owner_body_invalid", + "The FeatureScript qOwnerBody selector contract is invalid", + detail={"reason": owner_body_error}, + ) + copy_error = copy_selector_contract_error(selector) + if copy_error is not None: + return RuntimeDiagnostic( + "selector_copy_invalid", + "The FeatureScript COPY selector contract is invalid", + detail={"reason": copy_error}, + ) + blend_face_error = blend_face_selector_contract_error(selector) + if blend_face_error is not None: + return RuntimeDiagnostic( + "selector_blend_face_invalid", + "The FeatureScript BLEND_FACE selector contract is invalid", + detail={"reason": blend_face_error}, + ) + if intent.get("query_family") == "QUERY_SET": + return None + if intent.get("query_family") == "OWNER_BODY": + return None capability = selector_query_capability(intent) if capability is None: family = str(intent.get("query_family") or "") @@ -343,6 +379,33 @@ class TopologySectionRelation: raise ValueError(f"unsupported section relation status {self.status!r}") +@dataclass(frozen=True) +class TopologyBlendRelation: + """Exact dress-up transition from an input edge/face pair to result edges. + + A fillet or chamfer builder creates a patch face from the selected input + edge. A ``BLEND_EDGE`` query additionally names the source face that the + patch blends into, so the patch face alone is insufficient evidence. This + relation retains only an exact shared boundary between that generated patch + and a final ``Modified(source_face)`` result; it never ranks result edges + by their geometry. + """ + + source_edge_value: Any + source_face_value: Any + patch_face_value: Any | None = None + blend_into_result_value: Any | None = None + result_values: tuple[Any, ...] = () + coverage: str = "complete" + status: str = "proven" + + def __post_init__(self) -> None: + if self.coverage not in {"complete", "partial", "none"}: + raise ValueError(f"unsupported blend relation coverage {self.coverage!r}") + if self.status not in {"proven", "unknown", "rejected"}: + raise ValueError(f"unsupported blend relation status {self.status!r}") + + @dataclass(frozen=True) class TopologyDelta: """Kernel-backed topology history for one adapter operation. @@ -361,6 +424,9 @@ class TopologyDelta: # both source faces and the exact final edge. Unqualified values remain # diagnostic facts in ``section_values``. section_relations: tuple[TopologySectionRelation, ...] = () + # Dress-up transition evidence is kept separate from ordinary one-source + # lineage. A future BLEND_EDGE resolver must consume both source handles. + blend_relations: tuple[TopologyBlendRelation, ...] = () history_status: str = "proven" history_reason: str | None = None @@ -704,6 +770,7 @@ class TopologyRegistry: result_kind=item.get("result_kind"), ) for item in delta_evidence + if not item.get("blend_transition") ] self._lineage.extend(operation_lineage) self._topology_deltas.append({ @@ -723,16 +790,18 @@ class TopologyRegistry: self, feature_id: str, body_id: str, records: Iterable[TopologyRecord], *, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None, additional_predecessors: Iterable[TopologyRecord] = (), + member_preservations: Iterable[tuple[str, str]] = (), ) -> None: self.replace_body_topologies( feature_id, [(body_id, records)], active_body_id=active_body_id, topology_delta=topology_delta, - additional_predecessors=additional_predecessors, + additional_predecessors=additional_predecessors, member_preservations=member_preservations, ) def replace_body_topologies( self, feature_id: str, bodies: Iterable[tuple[str, Iterable[TopologyRecord]]], *, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None, additional_predecessors: Iterable[TopologyRecord] = (), + member_preservations: Iterable[tuple[str, str]] = (), ) -> None: """Record a fresh B-rep snapshot after a feature mutates the body. @@ -842,6 +911,47 @@ class TopologyRegistry: for successor_id in successor_ids: if successor_id not in known: known.append(successor_id) + # A compound assembly may retain an independent member verbatim while + # another member is created or changed. Member identity is a body + # lifecycle fact, not a geometric continuation: record it only when + # the session supplied an exact old/new member pair and every exported + # subshape has one reciprocal IsSame partner. + for source_body_id, result_body_id in member_preservations: + # ``kind == body`` records describe aggregate snapshots. They are + # not B-rep members and a Compound member has no corresponding + # aggregate record, so including them would reject an otherwise + # exact face/edge/vertex preservation relation. + source_records = [ + record for record in active_previous + if record.body_id == source_body_id and record.kind != "body" + ] + result_records = [ + record for record in registered + if record.body_id == result_body_id and record.kind != "body" + ] + pairs: list[tuple[TopologyRecord, TopologyRecord]] = [] + for source in source_records: + matches = [ + result for result in result_records + if result.kind == source.kind and self._same_topology_value(source.value, result.value) + ] + if len(matches) != 1: + pairs = [] + break + pairs.append((source, matches[0])) + if ( + not pairs + or len(source_records) != len(result_records) + or len({result.record_id for _source, result in pairs}) != len(result_records) + ): + continue + for source, result in pairs: + self._lineage.append(TopologyLineage( + source_record_ids=(source.record_id,), result_record_ids=(result.record_id,), + derivation="continuation", evidence="kernel_history", coverage="complete", + status="proven", operation="body_member_preserve", feature_id=feature_id, + source_kind=source.kind, result_kind=result.kind, + )) if topology_delta is not None: self._append_delta_evidence( feature_id, topology_delta, previous, current_records, delta_evidence, @@ -1061,6 +1171,60 @@ class TopologyRegistry: item["status"] = "recorded_without_owner_transfer" if item["coverage"] != "complete" or relation.status != "proven": item["lineage_status"] = "unknown" + for relation in topology_delta.blend_relations: + edge_sources = [ + record for record in previous + if record.kind == "edge" and cls._same_topology_value(record.value, relation.source_edge_value) + ] + face_sources = [ + record for record in previous + if record.kind == "face" and cls._same_topology_value(record.value, relation.source_face_value) + ] + patch_faces = [ + record for record in current + if record.kind == "face" + and relation.patch_face_value is not None + and cls._same_topology_value(record.value, relation.patch_face_value) + ] + blend_into_results = [ + record for record in current + if record.kind == "face" + and relation.blend_into_result_value is not None + and cls._same_topology_value(record.value, relation.blend_into_result_value) + ] + outputs = [ + record for record in current + if record.kind == "edge" + and any(cls._same_topology_value(record.value, value) for value in relation.result_values) + ] + complete = ( + len(edge_sources) == 1 + and len(face_sources) == 1 + and len(patch_faces) == 1 + and len(blend_into_results) == 1 + and len(relation.result_values) == 1 + and len(outputs) == 1 + and topology_delta.history_status == "proven" + and relation.coverage == "complete" + and relation.status == "proven" + ) + evidence.append({ + "event": "generated", + "kind": "edge", + "source_kind": "edge", + "result_kind": "edge", + "source_record_ids": [record.record_id for record in edge_sources], + "blend_into_source_record_ids": [record.record_id for record in face_sources], + "blend_into_result_record_ids": [record.record_id for record in blend_into_results], + "patch_face_record_ids": [record.record_id for record in patch_faces], + "result_record_ids": [record.record_id for record in outputs], + "proof": "kernel_history", + "derivation": "boundary", + "coverage": "complete" if complete else "partial", + "lineage_status": "proven" if complete else "unknown", + "blend_transition": True, + "status": "exact_blend_boundary" if complete else "incomplete_blend_boundary", + }) return ( predecessors, successors, @@ -1569,37 +1733,47 @@ class TopologyRegistry: "A CAP_FACE INTERSECT source requires one direct prism cap role", detail={"owner_feature_id": owner, "output_role": role}, ) - candidates = [ - record for record in self._records - if record.kind == "face" - and (owner in record.owners or record.feature_id == owner) - and role in record.output_roles - ] - if len(candidates) != 1: - return None, [], RuntimeDiagnostic( - "selector_source_unavailable" if not candidates else "selector_ambiguous", - "The CAP_FACE input snapshot is unavailable or non-unique", - detail={"owner_feature_id": owner, "output_role": role, "candidate_count": len(candidates)}, - ) - # Output roles are cached on records only after an exact builder - # relation binds to one final snapshot. Confirm that evidence is - # still present instead of treating the cache as source proof. + # Output roles propagate over exact continuations so ordinary + # role resolution can reach an active descendant. An INTERSECT + # source instead identifies the historical boolean input. Read + # only the owner's exact builder-role fact; cached roles on later + # descendants are not additional source snapshots. role_facts = [ relation for delta in self._topology_deltas if delta.get("feature_id") == owner + and delta.get("operation") == "extrude" for relation in delta.get("relations") or () if relation.get("output_role") == role - and candidates[0].record_id in relation.get("result_record_ids", ()) and relation.get("coverage") == "complete" and relation.get("lineage_status") == "proven" + and relation.get("result_kind") == "face" ] if len(role_facts) != 1: return None, [], RuntimeDiagnostic( - "selector_kernel_history_missing", + "selector_kernel_history_missing" if not role_facts else "selector_relation_non_unique", "The CAP_FACE input has no unique complete builder-role fact", detail={"owner_feature_id": owner, "output_role": role, "fact_count": len(role_facts)}, ) + result_ids = role_facts[0].get("result_record_ids") + if not isinstance(result_ids, list) or len(result_ids) != 1: + return None, [], RuntimeDiagnostic( + "selector_relation_non_unique", + "The CAP_FACE builder role does not identify one face snapshot", + detail={"owner_feature_id": owner, "output_role": role, "result_count": len(result_ids or ())}, + ) + candidates = [ + record for record in self._records + if record.record_id == result_ids[0] + and record.kind == "face" + and record.feature_id == owner + ] + if len(candidates) != 1: + return None, [], RuntimeDiagnostic( + "selector_kernel_history_missing" if not candidates else "selector_ambiguous", + "The CAP_FACE builder-role snapshot is unavailable or non-unique", + detail={"owner_feature_id": owner, "output_role": role, "candidate_count": len(candidates)}, + ) return candidates[0], [], None if family == "SWEPT_FACE": source_entity = source.get("source_entity") @@ -1783,6 +1957,794 @@ class TopologyRegistry: }, ) + def _resolve_blend_edge_intent( + self, + selector: dict[str, Any], + intent: dict[str, Any], + *, + active_body_id: str | None, + ) -> SelectorResolution: + """Resolve one complete exact dress-up patch-boundary transition.""" + sources = intent.get("blend_sources") + owner = selector.get("owner_feature_id") + + def unresolved(code: str, message: str, detail: dict[str, Any]) -> SelectorResolution: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic(code, message, detail=detail), + ) + + if not isinstance(sources, dict) or not isinstance(owner, str): + return unresolved("selector_source_unavailable", "BLEND_EDGE has no explicit dress-up source contract", {}) + edge_source = sources.get("edge") + face_source = sources.get("face") + if not isinstance(edge_source, dict) or not isinstance(face_source, dict): + return unresolved("selector_source_unavailable", "BLEND_EDGE source pair is incomplete", {}) + source_owner = edge_source.get("owner_feature_id") + source_entity = edge_source.get("source_entity") + edge_role = edge_source.get("lineage_role") + face_family = face_source.get("query_family") + face_role = face_source.get("output_role") + if ( + edge_source.get("query_family") != "CAP_EDGE" + or face_family not in {"CAP_FACE", "SWEPT_FACE"} + or source_owner != face_source.get("owner_feature_id") + or not isinstance(source_owner, str) + or not isinstance(source_entity, dict) + or edge_role not in {"extrude.start", "extrude.end"} + ): + return unresolved("selector_source_unavailable", "BLEND_EDGE source pair violates the direct-prism contract", {}) + if face_family == "SWEPT_FACE" and face_source.get("source_entity") != source_entity: + return unresolved( + "selector_source_unavailable", + "BLEND_EDGE swept-face source must match its cap-edge anchor", + {}, + ) + sketch_id, entity_id = source_entity.get("sketch_id"), source_entity.get("entity_id") + if not isinstance(sketch_id, str) or not isinstance(entity_id, str): + return unresolved("selector_source_unavailable", "BLEND_EDGE edge source lacks a sketch entity", {}) + anchors = [ + record for record in self._records + if record.kind == "edge" and record.source_entity == (sketch_id, entity_id) + ] + if len(anchors) != 1: + return unresolved( + "selector_source_unavailable" if not anchors else "selector_ambiguous", + "BLEND_EDGE source edge anchor is unavailable or ambiguous", + {"anchor_count": len(anchors), "source_entity": source_entity}, + ) + cap_edges = [ + edge for edge in self._lineage + if edge.feature_id == source_owner + and edge.operation == "extrude" + and edge.source_kind == "edge" and edge.result_kind == "edge" + and edge.source_record_ids == (anchors[0].record_id,) + and edge.output_role == edge_role + and edge.coverage == "complete" and edge.status == "proven" + and len(edge.result_record_ids) == 1 + ] + cap_edge_ids = {record_id for edge in cap_edges for record_id in edge.result_record_ids} + if face_family == "CAP_FACE": + source_faces = [ + record for record in self._records + if record.kind == "face" and record.feature_id == source_owner and face_role in record.output_roles + ] if face_role in {"extrude.start", "extrude.end"} else [] + else: + face_entity = face_source.get("source_entity") + face_sketch_id = face_entity.get("sketch_id") if isinstance(face_entity, dict) else None + face_entity_id = face_entity.get("entity_id") if isinstance(face_entity, dict) else None + face_anchors = [ + record for record in self._records + if record.kind == "edge" and record.source_entity == (face_sketch_id, face_entity_id) + ] + face_relations = [ + relation for relation in self._lineage + if relation.feature_id == source_owner + and relation.operation == "extrude" + and relation.source_kind == "edge" and relation.result_kind == "face" + and len(face_anchors) == 1 + and relation.source_record_ids == (face_anchors[0].record_id,) + and relation.coverage == "complete" and relation.status == "proven" + and len(relation.result_record_ids) == 1 + ] + source_face_ids = {record_id for relation in face_relations for record_id in relation.result_record_ids} + source_faces = [ + record for record in self._records + if record.record_id in source_face_ids and record.kind == "face" + ] + if len(cap_edge_ids) != 1 or len(source_faces) != 1: + return unresolved( + "selector_source_unavailable" if not cap_edge_ids or not source_faces else "selector_ambiguous", + "BLEND_EDGE direct source edge or cap face is unavailable or ambiguous", + {"cap_edge_count": len(cap_edge_ids), "cap_face_count": len(source_faces)}, + ) + source_edge_id = next(iter(cap_edge_ids)) + source_face_id = source_faces[0].record_id + matching = [ + relation + for delta in self._topology_deltas + if delta.get("feature_id") == owner + for relation in delta.get("relations") or () + if relation.get("blend_transition") is True + and relation.get("status") == "exact_blend_boundary" + and relation.get("coverage") == "complete" + and relation.get("lineage_status") == "proven" + and relation.get("source_record_ids") == [source_edge_id] + and relation.get("blend_into_source_record_ids") == [source_face_id] + and len(relation.get("blend_into_result_record_ids") or ()) == 1 + and len(relation.get("patch_face_record_ids") or ()) == 1 + and len(relation.get("result_record_ids") or ()) == 1 + ] + result_ids = {relation["result_record_ids"][0] for relation in matching} + active_results = [ + record for record in self._records + if record.record_id in result_ids and record.kind == "edge" + and self._record_is_active(record, active_body_id) + ] + if len(matching) != 1 or len(active_results) != 1: + return unresolved( + "selector_relation_non_unique" if matching or result_ids else "selector_kernel_history_missing", + "BLEND_EDGE has no unique complete exact dress-up boundary in the active body", + {"relation_count": len(matching), "active_result_count": len(active_results)}, + ) + record = active_results[0] + return SelectorResolution( + selector=selector, + status="resolved", + record=record, + candidates=({"score": 1.0, **record.public_dict()},), + resolution_mode="blend_boundary", + evidence={ + "source_query": intent.get("source_query"), + "source_records": [source_edge_id, source_face_id], + "result_records": [record.record_id], + "blend_relation": matching[0], + "policy": intent.get("derivation_policy"), + }, + ) + + def _resolve_blend_face_intent( + self, + selector: dict[str, Any], + intent: dict[str, Any], + *, + active_body_id: str | None, + ) -> SelectorResolution: + """Resolve one exact active dress-up patch face from its input edge.""" + owner = selector.get("owner_feature_id") + source = intent.get("blend_face_source") + + def unresolved(code: str, message: str, detail: dict[str, Any]) -> SelectorResolution: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic(code, message, detail=detail), + ) + + if not isinstance(owner, str) or not isinstance(source, dict): + return unresolved("selector_source_unavailable", "BLEND_FACE has no explicit direct-prism source contract", {}) + source_owner = source.get("owner_feature_id") + source_entity = source.get("source_entity") + role = source.get("lineage_role") + if ( + source.get("query_family") != "CAP_EDGE" + or not isinstance(source_owner, str) + or not isinstance(source_entity, dict) + or role not in {"extrude.start", "extrude.end"} + ): + return unresolved("selector_source_unavailable", "BLEND_FACE source violates the direct-prism contract", {}) + sketch_id, entity_id = source_entity.get("sketch_id"), source_entity.get("entity_id") + if not isinstance(sketch_id, str) or not isinstance(entity_id, str): + return unresolved("selector_source_unavailable", "BLEND_FACE source lacks one sketch edge", {}) + anchors = [ + record for record in self._records + if record.kind == "edge" and record.source_entity == (sketch_id, entity_id) + ] + if len(anchors) != 1: + return unresolved( + "selector_source_unavailable" if not anchors else "selector_ambiguous", + "BLEND_FACE source edge anchor is unavailable or ambiguous", + {"anchor_count": len(anchors), "source_entity": source_entity}, + ) + cap_relations = [ + relation for relation in self._lineage + if relation.feature_id == source_owner + and relation.operation == "extrude" + and relation.source_kind == "edge" and relation.result_kind == "edge" + and relation.source_record_ids == (anchors[0].record_id,) + and relation.output_role == role + and relation.coverage == "complete" and relation.status == "proven" + and len(relation.result_record_ids) == 1 + ] + cap_ids = {record_id for relation in cap_relations for record_id in relation.result_record_ids} + if len(cap_ids) != 1: + return unresolved( + "selector_source_unavailable" if not cap_ids else "selector_ambiguous", + "BLEND_FACE role-qualified direct source edge is unavailable or ambiguous", + {"cap_edge_count": len(cap_ids)}, + ) + cap_id = next(iter(cap_ids)) + patch_relations = [ + relation for relation in self._lineage + if relation.feature_id == owner + and relation.operation in {"fillet", "chamfer"} + and relation.derivation == "boundary" + and relation.source_kind == "edge" and relation.result_kind == "face" + and relation.source_record_ids == (cap_id,) + and relation.coverage == "complete" and relation.status == "proven" + and len(relation.result_record_ids) == 1 + ] + patch_ids = {record_id for relation in patch_relations for record_id in relation.result_record_ids} + active_patches = [ + record for record in self._records + if record.record_id in patch_ids and record.kind == "face" + and self._record_is_active(record, active_body_id) + ] + if len(patch_relations) != 1 or len(active_patches) != 1: + return unresolved( + "selector_relation_non_unique" if patch_relations or patch_ids else "selector_kernel_history_missing", + "BLEND_FACE has no unique complete generated patch face in the active body", + {"relation_count": len(patch_relations), "active_result_count": len(active_patches)}, + ) + record = active_patches[0] + return SelectorResolution( + selector=selector, + status="resolved", + record=record, + candidates=({"score": 1.0, **record.public_dict()},), + resolution_mode="blend_patch_face", + evidence={ + "source_query": intent.get("source_query"), + "source_records": [anchors[0].record_id, cap_id], + "result_records": [record.record_id], + "relations": [relation.as_dict() for relation in [cap_relations[0], patch_relations[0]]], + "body_member": record.body_id, + "policy": intent.get("derivation_policy"), + }, + ) + + def _resolve_proven_operand_set( + self, + selector: dict[str, Any], + *, + active_body_id: str | None, + ) -> SelectorResolution: + """Evaluate one direct query set from exact active child record IDs.""" + operands = selector.get("query_operands") + kind = selector.get("kind") + intent = selector["selector_intent"] + contract = intent.get("query_set_contract") + operator = { + "proven_operand_union": "union", + "proven_operand_intersection": "intersection", + "proven_operand_subtraction": "subtraction", + }.get(contract) + source_name = { + "union": "qUnion", + "intersection": "qIntersection", + "subtraction": "qSubtraction", + }.get(operator, "query set") + if not isinstance(operands, list): # Defensive: the public gate checked this. + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + "selector_query_set_invalid", + "The FeatureScript query-set selector has no executable operands", + ), + ) + + records: list[TopologyRecord] = [] + seen_record_ids: set[str] = set() + operand_record_sets: list[tuple[TopologyRecord, ...]] = [] + operand_evidence: list[dict[str, Any]] = [] + for index, operand in enumerate(operands): + resolution = self.resolve(operand, active_body_id=active_body_id) + resolved_records = resolution.records or ((resolution.record,) if resolution.record is not None else ()) + if resolution.status != "resolved" or not resolved_records: + cause = resolution.diagnostic + return SelectorResolution( + selector=selector, + status="not_found", + candidates=tuple({"score": 1.0, **record.public_dict()} for record in records), + diagnostic=RuntimeDiagnostic( + cause.code if cause is not None else "selector_query_operand_unresolved", + f"{source_name} operand {index} did not resolve to a proven active result", + detail={ + "operand_index": index, + **({"cause": cause.as_dict()} if cause is not None else {}), + }, + ), + ) + for record in resolved_records: + if record.kind != kind: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in records), + diagnostic=RuntimeDiagnostic( + "selector_query_set_kind_mismatch", + f"A {source_name} operand resolved to a topology kind outside the declared set", + detail={"operand_index": index, "expected_kind": kind, "record_kind": record.kind}, + ), + ) + if not self._record_is_active(record, active_body_id): + return SelectorResolution( + selector=selector, + status="not_found", + candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in records), + diagnostic=RuntimeDiagnostic( + "selector_body_member_inactive", + f"A {source_name} operand does not resolve inside the active body member", + detail={"operand_index": index, "record_id": record.record_id, "active_body_id": active_body_id}, + ), + ) + if record.record_id not in seen_record_ids: + seen_record_ids.add(record.record_id) + records.append(record) + operand_record_sets.append(tuple(resolved_records)) + operand_evidence.append({ + "operand_index": index, + "resolution_mode": resolution.resolution_mode, + "result_records": [record.record_id for record in resolved_records], + "relations": list(resolution.evidence.get("relations") or ()), + }) + if operator == "intersection": + shared_ids = { + record.record_id + for record in operand_record_sets[0] + } + for operand_records in operand_record_sets[1:]: + shared_ids.intersection_update(record.record_id for record in operand_records) + records = [] + seen_record_ids.clear() + for record in operand_record_sets[0]: + if record.record_id in shared_ids and record.record_id not in seen_record_ids: + seen_record_ids.add(record.record_id) + records.append(record) + elif operator == "subtraction": + excluded_ids = {record.record_id for record in operand_record_sets[1]} + records = [] + seen_record_ids.clear() + for record in operand_record_sets[0]: + if record.record_id not in excluded_ids and record.record_id not in seen_record_ids: + seen_record_ids.add(record.record_id) + records.append(record) + if not records: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + "selector_query_empty", + f"The {source_name} selector resolved no active topology records", + detail={"operator": operator}, + ), + ) + public_records = tuple({"score": 1.0, **record.public_dict()} for record in records) + return SelectorResolution( + selector=selector, + status="resolved", + records=tuple(records), + candidates=public_records, + resolution_mode=f"query_set_{operator}", + evidence={ + "source_query": intent.get("source_query"), + "query_expr": intent.get("query_expr"), + "set_contract": intent.get("query_set_contract"), + "set_operator": operator, + "operand_resolutions": operand_evidence, + "source_records": [], + "result_records": [record.record_id for record in records], + "body_members": [record.body_id for record in records], + "policy": intent.get("derivation_policy"), + }, + ) + + def _resolve_owner_body_selector( + self, + selector: dict[str, Any], + *, + active_body_id: str | None, + ) -> SelectorResolution: + """Project one exact active topology result to its owning body record.""" + query_input = selector.get("query_input") + if not isinstance(query_input, dict): # Defensive: the public gate checked this. + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + "selector_owner_body_invalid", + "The qOwnerBody selector has no executable topology input", + ), + ) + input_resolution = self.resolve(query_input, active_body_id=active_body_id) + input_records = input_resolution.records or ( + (input_resolution.record,) if input_resolution.record is not None else () + ) + if input_resolution.status != "resolved" or not input_records: + cause = input_resolution.diagnostic + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + "selector_owner_body_input_unresolved", + "The qOwnerBody input did not resolve to proven active topology", + detail={ + **({"cause": cause.as_dict()} if cause is not None else {}), + }, + ), + ) + if len(input_records) != 1: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=tuple({"score": 1.0, **record.public_dict()} for record in input_records), + diagnostic=RuntimeDiagnostic( + "selector_owner_body_ambiguous", + "The qOwnerBody input resolved to more than one topology member", + detail={"input_record_ids": [record.record_id for record in input_records]}, + ), + ) + input_record = input_records[0] + if ( + input_record.body_id is None + or active_body_id is None + or input_record.body_id != active_body_id + or not self._record_is_active(input_record, active_body_id) + ): + return SelectorResolution( + selector=selector, + status="not_found", + candidates=({"score": 1.0, **input_record.public_dict()},), + diagnostic=RuntimeDiagnostic( + "selector_owner_body_inactive", + "The qOwnerBody input is not an exact member of the active body", + detail={ + "input_record_id": input_record.record_id, + "input_body_id": input_record.body_id, + "active_body_id": active_body_id, + }, + ), + ) + body_records = [ + record for record in self._records + if record.kind == "body" + and not record.transient + and record.body_id == input_record.body_id + ] + public_candidates = tuple({"score": 1.0, **record.public_dict()} for record in body_records) + if len(body_records) != 1: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=public_candidates, + diagnostic=RuntimeDiagnostic( + "selector_owner_body_not_found" if not body_records else "selector_owner_body_ambiguous", + "The qOwnerBody input has no unique exact active body record", + detail={ + "input_record_id": input_record.record_id, + "body_id": input_record.body_id, + "candidate_count": len(body_records), + }, + ), + ) + body_record = body_records[0] + return SelectorResolution( + selector=selector, + status="resolved", + record=body_record, + candidates=public_candidates, + resolution_mode="owner_body", + evidence={ + "source_query": selector["selector_intent"].get("source_query"), + "query_expr": selector["selector_intent"].get("query_expr"), + "input_resolution_mode": input_resolution.resolution_mode, + "input_record_ids": [input_record.record_id], + "result_records": [body_record.record_id], + "body_member": input_record.body_id, + "relations": list(input_resolution.evidence.get("relations") or ()), + "policy": selector["selector_intent"].get("derivation_policy"), + }, + ) + + def _resolve_primary_cut_copy_cap_edge_selector( + self, + selector: dict[str, Any], + *, + active_body_id: str | None, + ) -> SelectorResolution: + """Project one exact transient CAP_EDGE through its owning primary cut. + + COPY itself does not permit a topology lookup. The sole executable + bridge is the two-link history emitted for a primary blind cut: a + direct-prism cap boundary into the transient tool, immediately + followed by that same feature's exact subtract continuation. + """ + owner = selector.get("owner_feature_id") + query_input = selector.get("query_input") + if not isinstance(owner, str) or not isinstance(query_input, dict): + return SelectorResolution( + selector=selector, + status="not_found", + diagnostic=RuntimeDiagnostic( + "selector_copy_invalid", + "The COPY selector has no executable primary-cut CAP_EDGE input", + ), + ) + input_resolution = self.resolve(query_input, active_body_id=active_body_id) + input_records = input_resolution.records or ( + (input_resolution.record,) if input_resolution.record is not None else () + ) + if input_resolution.status != "resolved" or len(input_records) != 1: + cause = input_resolution.diagnostic + return SelectorResolution( + selector=selector, + status="not_found", + candidates=input_resolution.candidates, + diagnostic=RuntimeDiagnostic( + "selector_copy_input_unresolved", + "The COPY CAP_EDGE input did not resolve to one proven active edge", + detail={ + **({"cause": cause.as_dict()} if cause is not None else {}), + "input_record_ids": [record.record_id for record in input_records], + }, + ), + ) + record = input_records[0] + relations = list(input_resolution.evidence.get("relations") or ()) + if ( + record.kind != "edge" + or record.transient + or not self._record_is_active(record, active_body_id) + or len(relations) != 2 + ): + return SelectorResolution( + selector=selector, + status="not_found", + candidates=({"score": 1.0, **record.public_dict()},), + diagnostic=RuntimeDiagnostic( + "selector_copy_kernel_history_missing", + "COPY requires one active edge reached by exactly two primary-cut lineage relations", + detail={ + "record_id": record.record_id, + "relation_count": len(relations), + "active_body_id": active_body_id, + }, + ), + ) + boundary, continuation = relations + valid_boundary = ( + boundary.get("feature_id") == owner + and boundary.get("operation") == "extrude" + and boundary.get("derivation") == "boundary" + and boundary.get("evidence") == "kernel_history" + and boundary.get("coverage") == "complete" + and boundary.get("status") == "proven" + and boundary.get("source_kind") == "edge" + and boundary.get("result_kind") == "edge" + and str(boundary.get("output_role") or "").startswith("extrude.") + and len(boundary.get("result_record_ids") or ()) == 1 + ) + valid_continuation = ( + continuation.get("feature_id") == owner + and continuation.get("operation") == "subtract" + and continuation.get("derivation") == "continuation" + and continuation.get("evidence") == "kernel_history" + and continuation.get("coverage") == "complete" + and continuation.get("status") == "proven" + and continuation.get("source_kind") == "edge" + and continuation.get("result_kind") == "edge" + and continuation.get("source_record_ids") == boundary.get("result_record_ids") + and continuation.get("result_record_ids") == [record.record_id] + ) + if not valid_boundary or not valid_continuation: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=({"score": 1.0, **record.public_dict()},), + diagnostic=RuntimeDiagnostic( + "selector_copy_kernel_history_missing", + "COPY has no exact transient-prism boundary and same-owner cut continuation", + detail={ + "owner_feature_id": owner, + "relations": relations, + }, + ), + ) + return SelectorResolution( + selector=selector, + status="resolved", + record=record, + candidates=({"score": 1.0, **record.public_dict()},), + resolution_mode="copy_lineage", + evidence={ + "source_query": selector["selector_intent"].get("source_query"), + "query_expr": selector["selector_intent"].get("query_expr"), + "input_query_expr": query_input["selector_intent"].get("query_expr"), + "input_resolution_mode": input_resolution.resolution_mode, + "source_records": list(boundary.get("source_record_ids") or ()), + "transient_records": list(boundary.get("result_record_ids") or ()), + "result_records": [record.record_id], + "relations": relations, + "body_member": record.body_id, + "policy": selector["selector_intent"].get("derivation_policy"), + }, + ) + + def _resolve_primary_cut_copy_cap_face_selector( + self, + selector: dict[str, Any], + *, + active_body_id: str | None, + ) -> SelectorResolution: + """Resolve one immediate COPY(CAP_FACE) attachment by exact history.""" + owner = selector.get("owner_feature_id") + query_input = selector.get("query_input") + input_intent = query_input.get("selector_intent") if isinstance(query_input, dict) else None + role = input_intent.get("lineage_role") if isinstance(input_intent, dict) else None + if not isinstance(owner, str) or role not in {"extrude.start", "extrude.end"}: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic("selector_copy_invalid", "COPY(CAP_FACE) attachment has no primary-cut cap role"), + ) + boundary = [ + relation for relation in self._lineage + if relation.feature_id == owner and relation.operation == "extrude" + and relation.output_role == role and relation.derivation == "boundary" + and relation.evidence == "kernel_history" and relation.coverage == "complete" + and relation.status == "proven" and relation.source_kind == "face" + and relation.result_kind == "face" and len(relation.result_record_ids) == 1 + ] + if len(boundary) != 1: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic( + "selector_copy_kernel_history_missing", + "COPY(CAP_FACE) has no unique complete transient-prism cap relation", + detail={"owner_feature_id": owner, "output_role": role, "relation_count": len(boundary)}, + ), + ) + transient_id = boundary[0].result_record_ids[0] + continuation = [ + relation for relation in self._lineage + if relation.feature_id == owner and relation.operation == "subtract" + and relation.derivation == "continuation" and relation.evidence == "kernel_history" + and relation.coverage == "complete" and relation.status == "proven" + and relation.source_kind == "face" and relation.result_kind == "face" + and relation.source_record_ids == (transient_id,) and len(relation.result_record_ids) == 1 + ] + if len(continuation) != 1: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic( + "selector_copy_kernel_history_missing", + "COPY(CAP_FACE) has no unique same-owner subtract continuation", + detail={"owner_feature_id": owner, "transient_record_id": transient_id, "relation_count": len(continuation)}, + ), + ) + result_id = continuation[0].result_record_ids[0] + records = [ + record for record in self._records + if record.record_id == result_id and record.kind == "face" + and not record.transient and self._record_is_active(record, active_body_id) + ] + if len(records) != 1: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic( + "selector_copy_inactive", "COPY(CAP_FACE) successor is not one active face", + detail={"result_record_id": result_id, "candidate_count": len(records), "active_body_id": active_body_id}, + ), + ) + record = records[0] + return SelectorResolution( + selector=selector, status="resolved", record=record, + candidates=({"score": 1.0, **record.public_dict()},), resolution_mode="copy_lineage", + evidence={ + "source_query": selector["selector_intent"].get("source_query"), + "query_expr": selector["selector_intent"].get("query_expr"), + "transient_records": [transient_id], "result_records": [record.record_id], + "relations": [boundary[0].as_dict(), continuation[0].as_dict()], + "body_member": record.body_id, "policy": selector["selector_intent"].get("derivation_policy"), + }, + ) + + def _resolve_primary_cut_copy_swept_face_selector( + self, + selector: dict[str, Any], + *, + active_body_id: str | None, + ) -> SelectorResolution: + """Resolve COPY(SWEPT_FACE) via one exact prism-face cut continuation.""" + owner = selector.get("owner_feature_id") + query_input = selector.get("query_input") + input_intent = query_input.get("selector_intent") if isinstance(query_input, dict) else None + source_entity = input_intent.get("source_entity") if isinstance(input_intent, dict) else None + if not isinstance(owner, str) or not isinstance(source_entity, dict): + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic("selector_copy_invalid", "COPY(SWEPT_FACE) attachment has no source-profile edge"), + ) + source_pair = (source_entity.get("sketch_id"), source_entity.get("entity_id")) + anchors = [ + record for record in self._records + if record.feature_id == owner and record.kind == "edge" and record.transient + and record.source_entity == source_pair + ] + if len(anchors) != 1: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic( + "selector_copy_kernel_history_missing", + "COPY(SWEPT_FACE) has no unique direct tool source-edge anchor", + detail={"owner_feature_id": owner, "source_entity": source_entity, "anchor_count": len(anchors)}, + ), + ) + boundary = [ + relation for relation in self._lineage + if relation.feature_id == owner and relation.operation == "extrude" + and relation.derivation == "boundary" and relation.evidence == "kernel_history" + and relation.coverage == "complete" and relation.status == "proven" + and relation.source_kind == "edge" and relation.result_kind == "face" + and relation.source_record_ids == (anchors[0].record_id,) and len(relation.result_record_ids) == 1 + ] + if len(boundary) != 1: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic( + "selector_copy_kernel_history_missing", + "COPY(SWEPT_FACE) has no unique complete transient-prism swept-face relation", + detail={"owner_feature_id": owner, "anchor_record_id": anchors[0].record_id, "relation_count": len(boundary)}, + ), + ) + transient_id = boundary[0].result_record_ids[0] + continuation = [ + relation for relation in self._lineage + if relation.feature_id == owner and relation.operation == "subtract" + and relation.derivation == "continuation" and relation.evidence == "kernel_history" + and relation.coverage == "complete" and relation.status == "proven" + and relation.source_kind == "face" and relation.result_kind == "face" + and relation.source_record_ids == (transient_id,) and len(relation.result_record_ids) == 1 + ] + if len(continuation) != 1: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic( + "selector_copy_kernel_history_missing", + "COPY(SWEPT_FACE) has no unique same-owner subtract continuation", + detail={"owner_feature_id": owner, "transient_record_id": transient_id, "relation_count": len(continuation)}, + ), + ) + result_id = continuation[0].result_record_ids[0] + records = [ + record for record in self._records + if record.record_id == result_id and record.kind == "face" + and not record.transient and self._record_is_active(record, active_body_id) + ] + if len(records) != 1: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic( + "selector_copy_inactive", "COPY(SWEPT_FACE) successor is not one active face", + detail={"result_record_id": result_id, "candidate_count": len(records), "active_body_id": active_body_id}, + ), + ) + record = records[0] + return SelectorResolution( + selector=selector, status="resolved", record=record, + candidates=({"score": 1.0, **record.public_dict()},), resolution_mode="copy_lineage", + evidence={ + "source_query": selector["selector_intent"].get("source_query"), + "query_expr": selector["selector_intent"].get("query_expr"), + "transient_records": [transient_id], "result_records": [record.record_id], + "relations": [boundary[0].as_dict(), continuation[0].as_dict()], + "body_member": record.body_id, "policy": selector["selector_intent"].get("derivation_policy"), + }, + ) + def resolve( self, selector: dict[str, Any], @@ -1815,12 +2777,35 @@ class TopologyRegistry: else: allowed, multiplicity = set(), "one" + if provenance_intent and intent.get("query_set_contract") in { + "proven_operand_union", + "proven_operand_intersection", + "proven_operand_subtraction", + }: + return self._resolve_proven_operand_set(selector, active_body_id=active_body_id) + + if provenance_intent and intent.get("query_family") == "OWNER_BODY": + return self._resolve_owner_body_selector(selector, active_body_id=active_body_id) + + if provenance_intent and intent.get("query_family") == "COPY": + if intent.get("copy_contract") == "primary_cut_cap_face_workplane": + return self._resolve_primary_cut_copy_cap_face_selector(selector, active_body_id=active_body_id) + if intent.get("copy_contract") == "primary_cut_swept_face_workplane": + return self._resolve_primary_cut_copy_swept_face_selector(selector, active_body_id=active_body_id) + return self._resolve_primary_cut_copy_cap_edge_selector(selector, active_body_id=active_body_id) + if provenance_intent and intent.get("query_family") == "INTERSECT": return self._resolve_intersection_intent( selector, intent, allowed=allowed, multiplicity=multiplicity, active_body_id=active_body_id, ) + if provenance_intent and intent.get("query_family") == "BLEND_EDGE": + return self._resolve_blend_edge_intent(selector, intent, active_body_id=active_body_id) + + if provenance_intent and intent.get("query_family") == "BLEND_FACE": + return self._resolve_blend_face_intent(selector, intent, active_body_id=active_body_id) + if provenance_intent and intent.get("query_family") == "SWEPT_BODY": mixed_evidence = any( selector.get(key) is not None @@ -2041,6 +3026,17 @@ class TopologyRegistry: else: continue break + if multiplicity == "all_fragments" and len(proven_candidates) != len(role_candidates): + return unresolved( + "selector_kernel_history_missing", + "One or more output-role fragments lack complete kernel evidence", + detail={ + "output_role": output_role, + "candidate_count": len(role_candidates), + "proven_candidate_count": len(proven_candidates), + }, + candidates=public_candidates, + ) if not proven_candidates: if cardinality_errors: error = cardinality_errors[0] @@ -2058,6 +3054,32 @@ class TopologyRegistry: ) role_candidates = [candidate for candidate, _relations in proven_candidates] public_candidates = tuple({"score": 1.0, **record.public_dict()} for record in role_candidates) + if multiplicity == "all_fragments": + relations = [ + relation + for _candidate, candidate_relations in proven_candidates + for relation in candidate_relations + ] + return SelectorResolution( + selector=selector, + status="resolved", + records=tuple(role_candidates), + candidates=public_candidates, + resolution_mode="operation_role", + evidence={ + "source_query": intent.get("source_query"), + "semantic_anchor": { + "type": "output_role", + "owner_feature_id": owner, + "output_role": output_role, + }, + "source_records": [], + "result_records": [record.record_id for record in role_candidates], + "relations": [edge.as_dict() for edge in relations], + "body_members": [record.body_id for record in role_candidates], + "policy": intent.get("derivation_policy"), + }, + ) if len(role_candidates) == 1: relations = proven_candidates[0][1] if provenance_intent else [] return SelectorResolution( @@ -2097,7 +3119,13 @@ class TopologyRegistry: detail={"output_role": output_role, "candidate_count": 0}, ), ) - if provenance_intent and intent.get("query_family") in {"CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE"}: + if provenance_intent and intent.get("query_family") in {"CAP_EDGE", "CAP_VERTEX", "SWEPT_FACE", "SWEPT_EDGE", "OFFSET_EDGE"}: + if not isinstance(owner, str) or not owner: + return unresolved( + "selector_source_unavailable", + "The provenance selector has no explicit producer owner for its source anchor", + detail={"query_family": intent.get("query_family")}, + ) source_entity = intent.get("source_entity") source_entities = intent.get("source_entities") source_kind: str | None = None @@ -2110,7 +3138,9 @@ class TopologyRegistry: semantic_anchor = {"type": "source_entity", "sketch_id": sketch_id, "entity_id": entity_id} anchor_records = [ record for record in self._records - if record.kind == source_kind and record.source_entity == (sketch_id, entity_id) + if record.kind == source_kind + and record.feature_id == owner + and record.source_entity == (sketch_id, entity_id) ] elif isinstance(source_entities, list): pairs = tuple(sorted( @@ -2131,7 +3161,9 @@ class TopologyRegistry: } anchor_records = [ record for record in self._records - if record.kind == source_kind and record.source_entities == pairs + if record.kind == source_kind + and record.feature_id == owner + and record.source_entities == pairs ] if source_kind is None: return unresolved( @@ -2140,20 +3172,36 @@ class TopologyRegistry: detail={"query_family": intent.get("query_family")}, ) lineage_role = intent.get("lineage_role") - if intent.get("query_family") == "CAP_EDGE": - if kind != "edge" or lineage_role not in {"extrude.start", "extrude.end"}: + if intent.get("query_family") == "CAP_EDGE" or ( + intent.get("query_family") == "OFFSET_EDGE" and source_kind == "edge" + ): + cap_roles = ( + {"extrude.start", "extrude.end", "sweep.start", "sweep.end"} + if intent.get("query_family") == "CAP_EDGE" + else {"extrude.start", "extrude.end"} + ) + if kind != "edge" or lineage_role not in cap_roles: return unresolved( "selector_source_unavailable", - "The CAP_EDGE provenance selector lacks a valid direct-prism cap role", - detail={"query_family": "CAP_EDGE", "lineage_role": lineage_role}, + "The cap-edge provenance selector lacks a valid direct cap role", + detail={"query_family": intent.get("query_family"), "lineage_role": lineage_role}, + ) + elif intent.get("query_family") in {"CAP_VERTEX", "OFFSET_EDGE"}: + if kind != "vertex" or source_kind != "vertex" or lineage_role not in { + "extrude.start", "extrude.end", + } if intent.get("query_family") == "CAP_VERTEX" else kind != "edge" or source_kind != "vertex" or lineage_role is not None: + return unresolved( + "selector_source_unavailable", + "The source-vertex provenance selector has an invalid direct-prism role contract", + detail={"query_family": intent.get("query_family"), "lineage_role": lineage_role}, ) else: lineage_role = None public_anchors = tuple({"score": 1.0, **record.public_dict()} for record in anchor_records) - if len(anchor_records) != 1: + if not anchor_records or (multiplicity != "all_fragments" and len(anchor_records) != 1): return unresolved( "selector_source_unavailable" if not anchor_records else "selector_ambiguous", - "The source-profile semantic anchor is unavailable or non-unique in this replay", + "The source-profile semantic anchor is unavailable or has unsupported cardinality in this replay", detail={ "query_family": intent.get("query_family"), "semantic_anchor": semantic_anchor, @@ -2161,22 +3209,40 @@ class TopologyRegistry: }, candidates=public_anchors, ) - successors, relations, error = self._intent_lineage_successors( - anchor_records[0].record_id, - allowed=allowed, - active_body_id=active_body_id, - initial_output_role=lineage_role, - target_kind=kind, - ) - successors = [record for record in successors if record.kind == kind] - public_successors = tuple({"score": 1.0, **record.public_dict()} for record in successors) - if error is not None: - return unresolved( - error.code, - error.message, - detail={**error.detail, "semantic_anchor": semantic_anchor}, - candidates=public_successors, + successors: list[TopologyRecord] = [] + relations: list[TopologyLineage] = [] + seen_successors: set[str] = set() + for anchor in anchor_records: + branch_successors, branch_relations, error = self._intent_lineage_successors( + anchor.record_id, + allowed=allowed, + active_body_id=active_body_id, + initial_output_role=lineage_role, + target_kind=kind, ) + branch_successors = [record for record in branch_successors if record.kind == kind] + if error is not None: + return unresolved( + error.code, + error.message, + detail={**error.detail, "semantic_anchor": semantic_anchor}, + candidates=tuple({"score": 1.0, **record.public_dict()} for record in branch_successors), + ) + # A set-valued source query must be proven for every selected + # physical source fragment. Returning only surviving branches + # would make a missing fragment look like a valid selector. + if multiplicity == "all_fragments" and not branch_successors: + return unresolved( + "selector_kernel_history_missing", + "A source-profile fragment has no complete active lineage result", + detail={"semantic_anchor": semantic_anchor, "source_record_id": anchor.record_id}, + ) + relations.extend(branch_relations) + for record in branch_successors: + if record.record_id not in seen_successors: + seen_successors.add(record.record_id) + successors.append(record) + public_successors = tuple({"score": 1.0, **record.public_dict()} for record in successors) if multiplicity == "all_fragments": return SelectorResolution( selector=selector, @@ -2187,7 +3253,7 @@ class TopologyRegistry: evidence={ "source_query": intent.get("source_query"), "semantic_anchor": semantic_anchor, - "source_records": [anchor_records[0].record_id], + "source_records": [record.record_id for record in anchor_records], "result_records": [record.record_id for record in successors], "relations": [edge.as_dict() for edge in relations], "body_members": [record.body_id for record in successors], diff --git a/backend/engine/cdsl_engine/topology_export.py b/backend/engine/cdsl_engine/topology_export.py index 8b6301d3..db7740bb 100644 --- a/backend/engine/cdsl_engine/topology_export.py +++ b/backend/engine/cdsl_engine/topology_export.py @@ -13,6 +13,11 @@ from __future__ import annotations import math from typing import Any +from OCP.BRep import BRep_Tool +from OCP.TopAbs import TopAbs_EDGE, TopAbs_VERTEX +from OCP.TopExp import TopExp_Explorer +from OCP.TopoDS import TopoDS + from .specs import canonical_plane_signature from .topology import TopologyRecord @@ -48,6 +53,40 @@ def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyR edges = list(body.edges()) vertices = list(body.vertices()) + # build123d may wrap a vertex extracted from a solid in a distinct OCC + # handle. Builder FirstShape/LastShape vertex history is only comparable + # with the final result's native explorer handles, so retain those exact + # handles for selector records. Faces and edges keep their established + # build123d export path. + result_vertices: list[Any] = [] + explorer = TopExp_Explorer(body.wrapped, TopAbs_VERTEX) + while explorer.More(): + candidate = explorer.Current() + if not any(candidate.IsSame(existing) for existing in result_vertices): + result_vertices.append(candidate) + explorer.Next() + result_edges: list[Any] = [] + explorer = TopExp_Explorer(body.wrapped, TopAbs_EDGE) + while explorer.More(): + candidate = explorer.Current() + if not any(candidate.IsSame(existing) for existing in result_edges): + result_edges.append(candidate) + explorer.Next() + + def exact_incident_edge_count(vertex: Any) -> int: + count = 0 + for edge in result_edges: + edge_vertices: list[Any] = [] + edge_explorer = TopExp_Explorer(edge, TopAbs_VERTEX) + while edge_explorer.More(): + candidate = edge_explorer.Current() + if not any(candidate.IsSame(existing) for existing in edge_vertices): + edge_vertices.append(candidate) + edge_explorer.Next() + if any(vertex.IsSame(candidate) for candidate in edge_vertices): + count += 1 + return count + def index_for(shape: Any, candidates: list[Any]) -> int | None: """Map a subshape returned by a face/edge back to body topology.""" # 用 is_same 把面/边的子形状映射回主体拓扑列表的下标。 @@ -302,10 +341,11 @@ def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyR geometry=geometry, )) # 5. 导出顶点记录:含坐标与关联边数。 - for index, vertex in enumerate(vertices): - point = [vertex.X, vertex.Y, vertex.Z] + for index, vertex in enumerate(result_vertices): + point_value = BRep_Tool.Pnt_s(TopoDS.Vertex_s(vertex)) + point = [point_value.X(), point_value.Y(), point_value.Z()] records.append(TopologyRecord( record_id=f"{body_id}:vertex:{index}", kind="vertex", feature_id=feature_id, body_id=body_id, value=vertex, - geometry={"center_mm": point, "incident_edge_count": len(vertex_edges[index])}, + geometry={"center_mm": point, "incident_edge_count": exact_incident_edge_count(vertex)}, )) return records diff --git a/backend/tests/test_engine_extent_trim_contract.py b/backend/tests/test_engine_extent_trim_contract.py index 7677598c..c0372025 100644 --- a/backend/tests/test_engine_extent_trim_contract.py +++ b/backend/tests/test_engine_extent_trim_contract.py @@ -57,7 +57,7 @@ from cdsl_engine.runtime import rebuild_cdsl # noqa: E402 try: - from build123d import Face, Plane, Vector # noqa: F401 + from build123d import Face, Plane, Solid, Vector # noqa: F401 _HAS_BUILD123D = True except ImportError: _HAS_BUILD123D = False @@ -137,6 +137,39 @@ class ExtentTrimContractTests(unittest.TestCase): self.assertAlmostEqual(trimmed.volume, 500.0, places=5) + @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") + def test_planar_supporting_surface_accepts_only_a_complete_forward_profile(self) -> None: + """A wholly unreachable planar face may supply its support plane. + + The finite target is deliberately outside the profile's x/y range, + so no ray intersects its trim. The underlying z=5 plane still gives + one exact +z termination distance. This is distinct from the + partial-hit trim contract below. + """ + profile = Face.make_rect(2, 2, Plane(origin=(8, 0, 0))) + target = Face.make_rect(2, 2, Plane(origin=(0, 0, 5))) + + self.assertTrue(all( + Build123dGeometryAdapter._forward_intersection_distance(target, point, Vector(0, 0, 1)) is None + for point in Build123dGeometryAdapter.profile_sample_points(profile) + )) + self.assertAlmostEqual( + Build123dGeometryAdapter.uniform_planar_supporting_surface_distance(target, [profile], (0, 0, 1)), + 5.0, + places=6, + ) + + @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") + def test_planar_supporting_surface_rejects_parallel_and_non_planar_targets(self) -> None: + profile = Face.make_rect(2, 2) + parallel = Face.make_rect(2, 2, Plane(origin=(0, 0, 5), z_dir=(1, 0, 0))) + cylindrical = next(face for face in Solid.make_cylinder(2, 4).faces() if face.geom_type != Face.make_rect(1, 1).geom_type) + + with self.assertRaisesRegex(ValueError, "parallel"): + Build123dGeometryAdapter.uniform_planar_supporting_surface_distance(parallel, [profile], (0, 0, 1)) + with self.assertRaisesRegex(ValueError, "planar"): + Build123dGeometryAdapter.uniform_planar_supporting_surface_distance(cylindrical, [profile], (0, 0, 1)) + @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") def test_up_to_surface_hanging_profile_is_trimmed_not_rejected(self) -> None: """集成裁剪契约:profile 悬空超出目标面时不再拒绝,悬空部分被切掉。 diff --git a/backend/tests/test_engine_multi_body_contract.py b/backend/tests/test_engine_multi_body_contract.py index 7fb8511a..810553ab 100644 --- a/backend/tests/test_engine_multi_body_contract.py +++ b/backend/tests/test_engine_multi_body_contract.py @@ -205,6 +205,15 @@ class MultiBodyContractTests(unittest.TestCase): if r["kind"] == "face" and r.get("body_id", "").startswith("body:cut_1:") } self.assertEqual(member_ids, {"body:cut_1:0", "body:cut_1:1"}) + cut_delta = next(item for item in rebuilt["topology_deltas"] if item["feature_id"] == "cut_1") + self.assertEqual(cut_delta["operation"], "subtract") + self.assertEqual(cut_delta["history_status"], "proven") + self.assertEqual(cut_delta["history_reason"], "per_member_exact_cut_history") + self.assertEqual( + {record_id.split(":face:", 1)[0] for relation in cut_delta["relations"] + for record_id in relation["source_record_ids"] if ":face:" in record_id}, + {"body:add_2:0", "body:add_2:1"}, + ) def test_face_selector_resolves_on_multi_body_via_prefix_matching(self) -> None: """多体 + selector resolve 契约:face selector 经前缀匹配命中正确实体。 diff --git a/backend/tests/test_engine_runtime_foundation.py b/backend/tests/test_engine_runtime_foundation.py index 841869ed..393a5f23 100644 --- a/backend/tests/test_engine_runtime_foundation.py +++ b/backend/tests/test_engine_runtime_foundation.py @@ -86,6 +86,26 @@ class EngineRuntimeFoundationTests(unittest.TestCase): }], } + def test_planar_face_workplane_projects_the_global_origin_to_the_support_plane(self) -> None: + from build123d import Edge, Face, Plane, Wire + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + plane = Plane((10, 20, 30), (1, 0, 0), (0, 0, 1)) + corners = [ + plane.origin + plane.x_dir * x + plane.y_dir * y + for x, y in ((-5, -10), (5, -10), (5, 10), (-5, 10)) + ] + face = Face(Wire([ + Edge.make_line(corners[index], corners[(index + 1) % len(corners)]) + for index in range(len(corners)) + ])) + workplane = Build123dGeometryAdapter.planar_face_workplane(face) + + self.assertAlmostEqual(workplane.origin_mm[0], 0.0, places=7) + self.assertAlmostEqual(workplane.origin_mm[1], 0.0, places=7) + self.assertAlmostEqual(workplane.origin_mm[2], 30.0, places=7) + self.assertEqual(workplane.normal, (0.0, 0.0, 1.0)) + def test_runtime_module_has_no_build123d_import(self) -> None: runtime_source = (ROOT / "backend" / "engine" / "cdsl_engine" / "runtime.py").read_text(encoding="utf-8") self.assertNotIn("from build123d", runtime_source) @@ -183,6 +203,36 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(len(sketch["contour_regions_mm"]), 2) self.assertTrue(all(not region["holes"] for region in sketch["contour_regions_mm"])) + def test_analytic_contours_do_not_turn_shared_boundaries_into_holes(self) -> None: + """A touching contour is an independent region, never a hole loop.""" + cdsl = { + "schema": "cad.cdsl.llm.v1", + "geometry": {"sketches": [{ + "id": "sketch", "workplane": _workplane(), + "profile": {"type": "analytic_contours", "contours": [ + {"role": "unknown", "closed": True, "segments": [ + {"type": "line", "start": [0, 0], "end": [2, 0]}, + {"type": "line", "start": [2, 0], "end": [1, 1]}, + {"type": "line", "start": [1, 1], "end": [0, 0]}, + ]}, + {"role": "unknown", "closed": True, "segments": [ + {"type": "line", "start": [0, 0], "end": [1, 1]}, + {"type": "line", "start": [1, 1], "end": [-1, 1]}, + {"type": "line", "start": [-1, 1], "end": [0, 0]}, + ]}, + ]}, + }]}, + "features": [], + } + sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0] + self.assertEqual(len(sketch["contour_regions_mm"]), 2) + self.assertTrue(all(not region["holes"] for region in sketch["contour_regions_mm"])) + + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + faces = Build123dGeometryAdapter().faces_for_sketch(sketch) + self.assertEqual(len(faces), 2) + self.assertTrue(all(len(face.faces()) == 1 for face in faces)) + def test_analytic_circle_contours_preserve_single_circular_wire_edges(self) -> None: from cdsl_engine.build123d_adapter import Build123dGeometryAdapter @@ -207,6 +257,44 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(len(faces), 2) self.assertTrue(all(len(face.outer_wire().edges()) == 1 for face in faces)) + def test_multi_source_regions_keep_nested_sources_as_independent_union_regions(self) -> None: + """A nested qSketchRegion source is not a hole in its sibling source.""" + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + from cdsl_engine.runtime import rebuild_cdsl + from cdsl_engine.semantic_validation import validate_semantic_cdsl + + profile = { + "type": "multi_source_regions", + "source_sketch_ids": ["outer_source", "inner_source"], + "profiles": [ + {"type": "circle", "center": [0, 0], "radius_mm": 5}, + {"type": "circle", "center": [0, 0], "radius_mm": 2}, + ], + } + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "multi-source-regions", "meta": {"unit": "mm"}, + "geometry": {"sketches": [{"id": "regions", "workplane": _workplane(), "profile": profile}]}, + "features": [{ + "id": "regions_add", "atomic_id": "extrude_add_blind", "depends_on": [], + "sketch_id": "regions", "params": {"distance_mm": 2}, "execution_status": "supported", + }], + } + validate_semantic_cdsl(cdsl) + sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0] + self.assertEqual(len(sketch["contour_regions_mm"]), 2) + self.assertTrue(all(not region["holes"] for region in sketch["contour_regions_mm"])) + self.assertEqual(len(Build123dGeometryAdapter().faces_for_sketch(sketch)), 2) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(cdsl, Path(directory) / "multi-source-regions.step") + self.assertEqual(rebuilt["solid_count"], 1) + self.assertAlmostEqual(rebuilt["volume_mm3"], math.pi * 5 ** 2 * 2, places=5) + + malformed = json.loads(json.dumps(cdsl)) + malformed["geometry"]["sketches"][0]["profile"]["source_sketch_ids"].append("third_source") + with self.assertRaisesRegex(ValueError, "one unique source id per profile"): + validate_semantic_cdsl(malformed) + 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 @@ -266,6 +354,42 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(len(faces), 2) self.assertAlmostEqual(sum(face.area for face in faces), 100.0, places=6) + def test_planar_imprint_multiregion_extrude_registers_final_fuse_history(self) -> None: + from cdsl_engine.runtime import prepare_cdsl_execution + + 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}], + } + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "planar-imprint-fuse-history", "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "imprint", "source_sketch_id": "F0", "workplane": _workplane(), "profile": profile, + }]}, + "features": [{ + "id": "f1", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "imprint", + "params": {"distance_mm": 2, "result_mode": "new_body"}, + }], + } + + execution = prepare_cdsl_execution(cdsl) + self.assertTrue(execution.analysis.runtime_eligible) + execution.execute_all() + self.assertTrue(execution.session.body.is_valid) + self.assertAlmostEqual(float(execution.session.body.volume), 200.0, places=6) + self.assertEqual( + [(item["history_status"], item.get("history_reason")) for item in execution.session.topology.topology_deltas()], + [("proven", "exact_prism_fuse_history")], + ) + 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 @@ -342,6 +466,200 @@ class EngineRuntimeFoundationTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "selected region is unbounded"): Build123dGeometryAdapter().faces_for_sketch(resolved) + def test_planar_imprint_uses_an_exact_runtime_support_face(self) -> None: + """An attached support is topology, not the artificial IMPRINT box.""" + from build123d import Face, Plane + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + sketch = { + "id": "attached-imprint", "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] + support = Face.make_rect(10, 10, Plane.XY) + faces = Build123dGeometryAdapter().faces_for_sketch(resolved, support_face=support) + self.assertEqual(len(faces), 1) + self.assertAlmostEqual(faces[0].area, 25.0, places=6) + + def test_planar_imprint_can_use_a_proven_external_boundary_edge_as_fragment_anchor(self) -> None: + """An attached CAP edge can be a splitter witness without becoming a source curve.""" + from build123d import Face, Plane + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + sketch = { + "id": "attached-external-anchor", "source_sketch_id": "F-attached", "workplane": _workplane(), + "profile": { + "type": "planar_imprint", + "source_entities": [ + {"id": "cut", "curve": {"type": "line", "start": [0, -5], "end": [0, 5]}}, + {"id": "top", "curve": {"type": "line", "start": [-5, 5], "end": [5, 5]}}, + ], + "selections": [{ + "source_entity_id": "cut", "face_side": 1, + "fragment": {"external_anchor_id": "cap-boundary", "side": -1}, + }], + }, + } + resolved = resolve_all_sketches({"geometry": {"sketches": [sketch]}})["geometry"]["sketches"][0] + support = Face.make_rect(10, 10, Plane.XY) + cap_boundary = next( + edge for edge in support.edges() + if abs(edge.bounding_box().min.Y + 5.0) < 1e-9 + and abs(edge.bounding_box().max.Y + 5.0) < 1e-9 + ) + faces, anchors = Build123dGeometryAdapter().faces_for_sketch_with_source_anchors( + resolved, + support_face=support, + external_anchor_edges={"cap-boundary": cap_boundary}, + ) + self.assertEqual(len(faces), 1) + self.assertAlmostEqual(faces[0].area, 50.0, places=6) + self.assertTrue(anchors) + self.assertTrue(all(item.get("source_entity", (None,))[0] == "F-attached" for item in anchors if item["kind"] == "edge")) + self.assertFalse(any(item.get("source_entity", (None, None))[1] == "cap-boundary" for item in anchors)) + + def test_planar_imprint_rejects_missing_or_ambiguous_external_fragment_anchor(self) -> None: + """The adapter never substitutes a same-sketch curve for an external witness.""" + from build123d import Face, Plane + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + support = Face.make_rect(10, 10, Plane.XY) + base = { + "id": "external-anchor-reject", "workplane": _workplane(), + "profile": { + "type": "planar_imprint", + "source_entities": [ + {"id": "cut", "curve": {"type": "line", "start": [0, -5], "end": [0, 5]}}, + {"id": "other", "curve": {"type": "line", "start": [-5, 0], "end": [0, 0]}}, + ], + "selections": [{ + "source_entity_id": "cut", "face_side": 1, + "fragment": {"external_anchor_id": "missing", "side": -1}, + }], + }, + } + resolved = resolve_all_sketches({"geometry": {"sketches": [base]}})["geometry"]["sketches"][0] + with self.assertRaisesRegex(ValueError, "fragment anchor is unavailable"): + Build123dGeometryAdapter().faces_for_sketch(resolved, support_face=support) + + ambiguous = json.loads(json.dumps(base)) + ambiguous["profile"]["selections"][0]["fragment"]["anchor_entity_id"] = "other" + resolved_ambiguous = resolve_all_sketches({"geometry": {"sketches": [ambiguous]}})["geometry"]["sketches"][0] + with self.assertRaisesRegex(ValueError, "exactly one anchor"): + Build123dGeometryAdapter().faces_for_sketch( + resolved_ambiguous, + support_face=support, + external_anchor_edges={"missing": support.edges()[0]}, + ) + + def test_runtime_attachment_face_stays_session_local(self) -> None: + """Attachment topology may support a splitter but cannot leak into CDSL.""" + from build123d import Face, Plane + from cdsl_engine.session import ExecutionSession + from cdsl_engine.topology import SelectorResolution + + support = Face.make_rect(10, 10, Plane.XY) + selector = {"kind": "face", "owner_feature_id": "base"} + session = ExecutionSession( + sketches={"attached": { + "id": "attached", "workplane": _workplane(), + "attachment": selector, + "profile": {"type": "circle", "center": [0, 0], "radius_mm": 1}, + }}, + nodes={}, + ) + record = TopologyRecord("face:base", "face", "base", "body:base", value=support) + session.resolve = lambda _selector: SelectorResolution( # type: ignore[method-assign] + selector=selector, status="resolved", record=record, + ) + session.resolve_sketch_attachment("attached", feature_id="consumer") + self.assertTrue(session.sketch_attachment_faces["attached"].wrapped.IsSame(support.wrapped)) + self.assertNotIn("support_face", session.sketches["attached"]) + self.assertNotIn("runtime_record", session.sketches["attached"]) + + def test_runtime_attachment_resolves_external_imprint_anchor_only_on_support_boundary(self) -> None: + """An external fragment anchor must be a proven edge of its support face.""" + from build123d import Face, Plane + from cdsl_engine.session import ExecutionSession + from cdsl_engine.topology import SelectorResolution + + support = Face.make_rect(10, 10, Plane.XY) + cap_edge = next( + edge for edge in support.edges() + if abs(edge.bounding_box().min.Y + 5.0) < 1e-9 + and abs(edge.bounding_box().max.Y + 5.0) < 1e-9 + ) + sketch = { + "id": "attached-imprint", "workplane": _workplane(), + "attachment": {"kind": "face", "tag": "support"}, + "profile": { + "type": "planar_imprint", + "source_entities": [ + {"id": "cut", "curve": {"type": "line", "start": [0, -5], "end": [0, 5]}}, + {"id": "top", "curve": {"type": "line", "start": [-5, 5], "end": [5, 5]}}, + ], + "external_anchors": [{"id": "cap", "selector": {"kind": "edge", "tag": "cap"}}], + "selections": [{ + "source_entity_id": "cut", "face_side": 1, + "fragment": {"external_anchor_id": "cap", "side": -1}, + }], + }, + } + session = ExecutionSession(sketches={"attached-imprint": sketch}, nodes={}) + support_record = TopologyRecord("face:base", "face", "base", "body:base", value=support) + edge_record = TopologyRecord("edge:base", "edge", "base", "body:base", value=cap_edge) + session.resolve = lambda selector: SelectorResolution( # type: ignore[method-assign] + selector=selector, status="resolved", + record=support_record if selector.get("tag") == "support" else edge_record, + ) + session.resolve_sketch_attachment("attached-imprint", feature_id="consumer") + self.assertTrue(session.sketch_attachment_faces["attached-imprint"].wrapped.IsSame(support.wrapped)) + self.assertTrue(session.sketch_imprint_external_edges["attached-imprint"]["cap"].wrapped.IsSame(cap_edge.wrapped)) + self.assertNotIn("runtime_record", session.sketches["attached-imprint"]) + + outside = Face.make_rect(10, 10, Plane((0, 0, 2), (1, 0, 0), (0, 0, 1))).edges()[0] + bad = ExecutionSession(sketches={"attached-imprint": sketch}, nodes={}) + bad_edge_record = TopologyRecord("edge:outside", "edge", "base", "body:base", value=outside) + bad.resolve = lambda selector: SelectorResolution( # type: ignore[method-assign] + selector=selector, status="resolved", + record=support_record if selector.get("tag") == "support" else bad_edge_record, + ) + with self.assertRaisesRegex(Exception, "not an exact boundary") as rejected: + bad.resolve_sketch_attachment("attached-imprint", feature_id="consumer") + self.assertEqual(rejected.exception.code, "imprint_external_anchor_not_support_boundary") + + def test_planar_imprint_external_anchor_requires_an_attached_support(self) -> None: + """A profile cannot introduce external topology without an attachment selector.""" + from cdsl_engine.semantic_validation import validate_semantic_cdsl + + cdsl = self._base_block() + cdsl["geometry"]["sketches"][0]["profile"] = { + "type": "planar_imprint", + "source_entities": [ + {"id": "cut", "curve": {"type": "line", "start": [-5, 0], "end": [5, 0]}}, + {"id": "other", "curve": {"type": "line", "start": [0, -5], "end": [0, 5]}}, + ], + "external_anchors": [{"id": "cap", "selector": { + "kind": "edge", "stable_id": "never-bind", "source": "runtime_snapshot", "confidence": 1.0, + }}], + "selections": [{ + "source_entity_id": "cut", "face_side": 1, + "fragment": {"external_anchor_id": "cap", "side": -1}, + }], + } + with self.assertRaisesRegex(ValueError, "external anchors require a runtime face attachment"): + validate_semantic_cdsl(cdsl) + def test_analytic_ellipse_preserves_its_workplane_orientation_and_volume(self) -> None: from cdsl_engine.runtime import rebuild_cdsl @@ -556,6 +874,24 @@ class EngineRuntimeFoundationTests(unittest.TestCase): if item["feature_id"] == "boolean" and item["kind"] in {"face", "edge", "vertex"} )) + def test_targetless_body_set_keep_tools_retains_the_complete_input_set(self) -> None: + """The lowered left operand is still an original tool for keepTools.""" + from cdsl_engine.runtime import prepare_cdsl_execution + + cdsl = _two_body_boolean_cdsl( + "intersect", _rectangle([-4, -2], [2, 2]), _rectangle([-1, -2], [5, 2]), + ) + boolean = cdsl["features"][-1] + boolean["params"]["keep_tools"] = True + boolean["params"]["targetless_body_set"] = True + execution = prepare_cdsl_execution(cdsl) + self.assertTrue(all(item.executable for item in execution.analysis.feature_results)) + execution.execute_all() + self.assertEqual(set(execution.session.body_members), {"left_body", "right_body", "boolean"}) + self.assertAlmostEqual(float(execution.session.body_members["left_body"].volume), 96.0) + self.assertAlmostEqual(float(execution.session.body_members["right_body"].volume), 96.0) + self.assertAlmostEqual(float(execution.session.body_members["boolean"].volume), 48.0) + def test_boolean_intersection_owner_selector_executes_downstream_fillet(self) -> None: from cdsl_engine.runtime import rebuild_cdsl @@ -613,6 +949,29 @@ class EngineRuntimeFoundationTests(unittest.TestCase): item["event"] == "generated" and item["status"] == "recorded_without_owner_transfer" for item in delta["relations"] )) + # Dress-up builders create the patch as a FACE from the + # selected EDGE. Keep this cross-kind OCC fact explicit; + # it is diagnostic lineage only, not a BLEND_EDGE selector. + self.assertTrue(any( + item["event"] == "generated" + and item["source_kind"] == "edge" + and item["result_kind"] == "face" + and item["coverage"] == "complete" + and item["lineage_status"] == "proven" + for item in delta["relations"] + )) + self.assertTrue(any( + item.get("blend_transition") is True + and item["status"] == "exact_blend_boundary" + and item["source_kind"] == "edge" + and item["result_kind"] == "edge" + and len(item["source_record_ids"]) == 1 + and len(item["blend_into_source_record_ids"]) == 1 + and len(item["blend_into_result_record_ids"]) == 1 + and len(item["patch_face_record_ids"]) == 1 + and len(item["result_record_ids"]) == 1 + for item in delta["relations"] + )) self.assertTrue(any( item["owner_feature_ids"] == ["base_add"] for item in result["topology_records"] @@ -632,6 +991,54 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertFalse(any(item["operation"] == "chamfer" for item in angled_result["topology_deltas"])) + def test_single_member_multibody_dressup_preserves_other_members_and_history(self) -> None: + """A dress-up on one Compound member must not discard its OCC delta.""" + from cdsl_engine.runtime import prepare_cdsl_execution + + base = _two_body_boolean_cdsl( + "union", _rectangle([-12, -4], [-4, 4]), _rectangle([4, -4], [12, 4]), + ) + base["features"] = base["features"][:2] + execution = prepare_cdsl_execution(base) + execution.execute_all() + edge = next( + record for record in execution.session.topology.records() + if record.feature_id == "right_body" and record.kind == "edge" and record.owners == ("right_body",) + ) + for atomic_id, params in (("fillet", {"radius_mm": 0.5}), ("chamfer", {"distance_mm": 0.5})): + with self.subTest(atomic_id=atomic_id): + dressed = deepcopy(base) + dressed["features"].append({ + "id": atomic_id, "atomic_id": atomic_id, "depends_on": ["left_body", "right_body"], + "params": params, "selectors": [{ + "kind": "edge", "stable_id": edge.record_id, "snapshot_id": edge.record_id, + "source": "runtime_snapshot", "confidence": 1, "owner_feature_id": "right_body", + "geometry": edge.geometry, + }], + }) + execution = prepare_cdsl_execution(dressed) + execution.execute_all() + + self.assertEqual(set(execution.session.body_members), {"left_body", atomic_id}) + delta = next( + item for item in execution.session.topology.topology_deltas() + if item["feature_id"] == atomic_id + ) + self.assertTrue(delta["relations"]) + self.assertTrue(any( + item["event"] == "generated" + and item["source_kind"] == "edge" + and item["result_kind"] == "face" + and item["coverage"] == "complete" + for item in delta["relations"] + )) + self.assertTrue(any( + lineage.operation == "body_member_preserve" + and lineage.feature_id == atomic_id + and lineage.source_kind == "edge" + for lineage in execution.session.topology.lineage() + )) + def test_shell_captures_exact_kernel_history_for_downstream_selector(self) -> None: from cdsl_engine.runtime import rebuild_cdsl @@ -763,6 +1170,64 @@ class EngineRuntimeFoundationTests(unittest.TestCase): "left_body", "right_body", "copy_pair", "move_left_copy", ]) + def test_boolean_consumes_only_source_qualified_multi_source_copy_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-boolean", + "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": "join_copies", "atomic_id": "boolean_bodies", "depends_on": ["copy_pair"], "params": { + "operation": "union", + "target_transform_copy_refs": [{"transform_feature_id": "copy_pair", "source_feature_id": "left_body"}], + "tool_transform_copy_refs": [{"transform_feature_id": "copy_pair", "source_feature_id": "right_body"}], + "keep_tools": False, + }}, + ], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "multi-source-copy-boolean.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, 2.0]}) + self.assertEqual([item["feature_id"] for item in result["feature_results"]], [ + "left_body", "right_body", "copy_pair", "join_copies", + ]) + + def test_boolean_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": "join", "atomic_id": "boolean_bodies", "depends_on": ["copy_pair"], "params": { + "operation": "union", + "target_transform_copy_refs": [{"transform_feature_id": "copy_pair", "source_feature_id": "missing_body"}], + "tool_feature_ids": ["base_add"], "keep_tools": 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_transform_copy_reference_rejects_unselected_source(self) -> None: from cdsl_engine.semantic_validation import validate_semantic_cdsl @@ -1488,7 +1953,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "requires a preceding body feature"): validate_semantic_cdsl(invalid_shell_target) - def test_up_to_surface_consumes_only_an_immediate_direct_cap_output_role(self) -> None: + def test_up_to_surface_consumes_only_an_immediate_cap_output_role(self) -> None: from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl from cdsl_engine.semantic_validation import validate_semantic_cdsl @@ -1535,7 +2000,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase): non_new_body = deepcopy(cdsl) non_new_body["features"][0]["params"]["result_mode"] = "fuse" - with self.assertRaisesRegex(ValueError, "direct new_body blind extrusion cap"): + with self.assertRaisesRegex(ValueError, "direct new_body or primary ADD blind extrusion cap"): validate_semantic_cdsl(non_new_body) self.assertIn( "unsupported_extent_output_role_selector", @@ -1548,7 +2013,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase): "params": {"plane": _workplane()}, "execution_status": "supported", }) non_immediate["features"][-1]["depends_on"] = ["gap"] - with self.assertRaisesRegex(ValueError, "immediately preceding direct new_body blind extrusion cap"): + with self.assertRaisesRegex(ValueError, "immediately preceding direct new_body or primary ADD blind extrusion cap"): validate_semantic_cdsl(non_immediate) self.assertIn( "unsupported_extent_output_role_selector", @@ -1800,6 +2265,67 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(analysis.feature_results[0].resolved_status, "executable") self.assertEqual(analysis.document_blockers[0].code, "no_solid_feature") + def test_reference_point_is_non_mutating_and_requires_finite_coordinates(self) -> None: + from cdsl_engine.runtime import analyze_cdsl, prepare_cdsl_execution + + cdsl = self._base_block() + cdsl["features"].append({ + "id": "datum", "atomic_id": "reference_point", "depends_on": ["base_add"], + "params": {"point_mm": [1.0, 2.0, 3.0]}, + }) + execution = prepare_cdsl_execution(cdsl) + base = execution.execute_next() + point = execution.execute_next() + self.assertEqual(base.status, "executed") + self.assertEqual(point.status, "executed") + self.assertIsNone(point.body_id) + self.assertEqual(execution.session.body_id, "body:base_add") + + invalid = deepcopy(cdsl) + invalid["features"][-1]["params"]["point_mm"] = [0.0, math.nan, 0.0] + analysis = analyze_cdsl(invalid) + datum = next(item for item in analysis.feature_results if item.feature_id == "datum") + self.assertIn("invalid_reference_point", [item.code for item in datum.blockers]) + + def test_assign_variable_is_non_mutating_and_rejects_nonfinite_values(self) -> None: + from cdsl_engine.runtime import analyze_cdsl, prepare_cdsl_execution + from cdsl_engine.semantic_validation import validate_semantic_cdsl + + cdsl = self._base_block() + for feature in cdsl["features"]: + feature["execution_status"] = "supported" + cdsl["features"].append({ + "id": "thickness", "atomic_id": "assign_variable", "depends_on": ["base_add"], + "params": {"name": "thickness", "value": 4.0, "value_kind": "any"}, + "execution_status": "supported", + }) + validate_semantic_cdsl(cdsl) + execution = prepare_cdsl_execution(cdsl) + base = execution.execute_next() + variable = execution.execute_next() + self.assertEqual(base.status, "executed") + self.assertEqual(variable.status, "executed") + self.assertIsNone(variable.body_id) + self.assertEqual(execution.session.body_id, "body:base_add") + self.assertFalse(any(record.feature_id == "thickness" for record in execution.session.topology.records())) + + invalid = deepcopy(cdsl) + invalid["features"][-1]["params"]["value"] = math.nan + analysis = analyze_cdsl(invalid) + assignment = next(item for item in analysis.feature_results if item.feature_id == "thickness") + self.assertIn("invalid_assign_variable", [item.code for item in assignment.blockers]) + with self.assertRaisesRegex(ValueError, "finite scalar value"): + validate_semantic_cdsl(invalid) + + duplicate = deepcopy(cdsl) + duplicate["features"].append({ + "id": "thickness_again", "atomic_id": "assign_variable", "depends_on": ["thickness"], + "params": {"name": "thickness", "value": 5.0, "value_kind": "any"}, + "execution_status": "supported", + }) + with self.assertRaisesRegex(ValueError, "redeclares source variable thickness"): + validate_semantic_cdsl(duplicate) + def test_unused_invalid_profile_does_not_block_runtime_preflight(self) -> None: from cdsl_engine.runtime import analyze_cdsl @@ -2713,9 +3239,17 @@ class EngineRuntimeFoundationTests(unittest.TestCase): 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"]}, + {item.get("output_role") for item in delta["relations"] if item.get("output_role") is not None}, {"sweep.start", "sweep.end"}, ) + self.assertTrue(any( + item.get("source_kind") == "edge" + and item.get("result_kind") == "face" + and item.get("derivation") == "boundary" + and item.get("coverage") == "complete" + and item.get("status") == "recorded_without_owner_transfer" + for item in delta["relations"] + )) self.assertTrue(all( item["event"] == "generated" and item["status"] == "recorded_without_owner_transfer" and item["result_record_ids"] @@ -2732,6 +3266,64 @@ class EngineRuntimeFoundationTests(unittest.TestCase): for item in downstream["selector_resolution"] )) + def test_sweep_add_builds_a_solid_from_a_directed_arc_path(self) -> None: + from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl + + profile_plane = {"origin_mm": [0, 0, 0], "x_dir": [0, 1, 0], "normal": [1, 0, 0]} + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "arc-sweep", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "profile", "workplane": profile_plane, + "profile": {"type": "circle", "radius_mm": 2}, + }]}, + "features": [{ + "id": "sweep", "atomic_id": "sweep_add", "depends_on": [], "sketch_id": "profile", + "params": {"path": {"workplane": _workplane(), "segment": { + "type": "arc", "start": [0, 0], "end": [10, 10], "center": [0, 10], + "radius_mm": 10, "clockwise": True, + }}}, + }], + } + self.assertTrue(analyze_cdsl(cdsl).runtime_eligible) + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "arc-sweep.step") + self.assertEqual(result["solid_count"], 1) + self.assertGreater(result["volume_mm3"], 0) + + invalid = deepcopy(cdsl) + invalid["features"][0]["params"]["path"]["segment"].pop("clockwise") + blocker = analyze_cdsl(invalid).feature_results[0].blockers[0] + self.assertEqual(blocker.code, "invalid_sweep_path") + + def test_sweep_cut_uses_a_transient_tool_and_preserves_target_cut_history(self) -> None: + from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl + + profile_plane = {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 1, 0]} + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "sweep-cut", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [ + {"id": "base", "workplane": _workplane(), "profile": _rectangle([-5, -5], [5, 5])}, + {"id": "tool", "workplane": profile_plane, "profile": {"type": "circle", "radius_mm": 1}}, + ]}, + "features": [ + {"id": "base", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base", + "params": {"distance_mm": 10}}, + {"id": "cut", "atomic_id": "sweep_cut", "depends_on": ["base"], "sketch_id": "tool", + "params": {"path": {"workplane": _workplane(), "segment": {"type": "line", "start": [0, 0], "end": [0, 20]}}}}, + ], + } + self.assertTrue(analyze_cdsl(cdsl).runtime_eligible) + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "sweep-cut.step") + self.assertEqual(result["solid_count"], 1) + self.assertLess(result["volume_mm3"], 1000) + delta = next(item for item in result["topology_deltas"] if item["feature_id"] == "cut") + self.assertEqual(delta["operation"], "subtract") + self.assertTrue(delta["relations"]) + self.assertFalse(any(item["record_id"].startswith("transient:cut") for item in result["topology_records"])) + def test_sweep_add_builds_a_solid_from_two_point_bspline_with_endpoint_tangents(self) -> None: from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl @@ -2766,6 +3358,44 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(result["solid_count"], 1) self.assertGreater(result["volume_mm3"], 0) + def test_sweep_add_builds_a_solid_from_spatial_segmented_path(self) -> None: + from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl + + profile_plane = {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]} + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "spatial-sweep", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "profile", "workplane": profile_plane, + "profile": {"type": "circle", "radius_mm": 2}, + }]}, + "features": [{ + "id": "sweep", "atomic_id": "sweep_add", "depends_on": [], "sketch_id": "profile", + "params": {"path": {"segments": [ + { + "type": "line", "start_mm": [0, 0, 0], "end_mm": [0, 0, 10], + "source_sketch_id": "F1", "source_entity_id": "E0", + }, + { + "type": "line", "start_mm": [0, 0, 10], "end_mm": [10, 0, 10], + "source_sketch_id": "F2", "source_entity_id": "E1", + }, + ]}}, + }], + } + self.assertTrue(analyze_cdsl(cdsl).runtime_eligible) + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "spatial-sweep.step") + self.assertEqual(result["solid_count"], 1) + self.assertGreater(result["volume_mm3"], 0) + + duplicate_source = deepcopy(cdsl) + duplicate_source["features"][0]["params"]["path"]["segments"][1]["source_sketch_id"] = "F1" + duplicate_source["features"][0]["params"]["path"]["segments"][1]["source_entity_id"] = "E0" + blocker = analyze_cdsl(duplicate_source).feature_results[0].blockers[0] + self.assertEqual(blocker.code, "invalid_sweep_path") + self.assertEqual(blocker.message, "Sweep spatial path must not repeat one source sketch entity") + 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 @@ -2781,6 +3411,22 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertIsNone(delta) self.assertAlmostEqual(float(result.volume), 120 * math.pi, places=5) + def test_sweep_native_fallback_translates_empty_kernel_assertion(self) -> None: + from build123d import Face, Plane, Wire + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + adapter = Build123dGeometryAdapter() + outer = Face(Wire.make_circle(28.03, Plane.XY)) + inner = Face(Wire.make_circle(24.77, Plane.XY)) + profile = adapter.face_with_holes(outer, [inner]) + path = adapter.sweep_path_segments([ + {"type": "line", "start_mm": [0, 0, 0], "end_mm": [0, 0, 55.85]}, + {"type": "line", "start_mm": [0, 0, 55.85], "end_mm": [63.5, 0, 55.85]}, + ]) + + with self.assertRaisesRegex(ValueError, "OCC sweep operation raised while building the native sweep"): + adapter.sweep_with_topology_delta(profile, path) + 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 @@ -2798,9 +3444,36 @@ class EngineRuntimeFoundationTests(unittest.TestCase): 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], + [relation.output_role for relation in delta.relations if relation.kind == "face"], ["sweep.start", "sweep.end"], ) + side_relations = [ + relation for relation in delta.relations + if relation.source_kind == "edge" and relation.result_kind == "face" + ] + self.assertEqual(len(side_relations), 1) + self.assertEqual(side_relations[0].coverage, "complete") + self.assertEqual(side_relations[0].status, "proven") + + def test_sweep_history_captures_each_direct_profile_vertex_edge(self) -> None: + from build123d import Edge, Face, Plane, Vector + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + adapter = Build123dGeometryAdapter() + profile = Face.make_rect(8, 6, Plane.XZ) + _solid, delta = adapter.sweep_with_topology_delta( + profile, Edge.make_line(Vector(0, 0, 0), Vector(0, 20, 0)), + ) + + self.assertIsNotNone(delta) + swept_vertex_relations = [ + relation for relation in delta.relations + if relation.source_kind == "vertex" and relation.result_kind == "edge" + ] + self.assertEqual(len(swept_vertex_relations), 4) + self.assertTrue(all(relation.result_values for relation in swept_vertex_relations)) + self.assertTrue(all(relation.coverage == "complete" for relation in swept_vertex_relations)) + self.assertTrue(all(relation.status == "proven" for relation in swept_vertex_relations)) def test_initial_loft_captures_builder_proven_cap_evidence(self) -> None: from cdsl_engine.runtime import rebuild_cdsl @@ -2888,6 +3561,63 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(result["solid_count"], 3) self.assertAlmostEqual(result["volume_mm3"], 30 * math.pi, delta=1e-4) + def test_sweep_accepts_a_closed_circle_path_as_a_wire(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "closed-circle-sweep", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "profile", + "workplane": {"origin_mm": [20, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 1, 0]}, + "profile": {"type": "circle", "radius_mm": 2}, + }]}, + "features": [{ + "id": "sweep", "atomic_id": "sweep_add", "depends_on": [], "sketch_id": "profile", + "params": {"path": { + "workplane": _workplane(), + "segment": {"type": "circle", "center": [0, 0], "radius_mm": 20}, + }}, + }], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "closed-circle-sweep.step") + self.assertEqual(result["solid_count"], 1) + self.assertEqual(result["runtime_diagnostics"], []) + self.assertAlmostEqual(result["volume_mm3"], 160 * math.pi**2, delta=1e-4) + + def test_circular_pattern_rotates_a_spatial_sweep_path(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "spatial-sweep-pattern", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "profile", "workplane": {"origin_mm": [10, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "circle", "radius_mm": 1}, + }]}, + "features": [ + {"id": "sweep", "atomic_id": "sweep_add", "depends_on": [], "sketch_id": "profile", "params": {"path": {"segments": [ + { + "type": "line", "start_mm": [10, 0, 0], "end_mm": [10, 0, 10], + "source_sketch_id": "F1", "source_entity_id": "E0", + }, + { + "type": "line", "start_mm": [10, 0, 10], "end_mm": [20, 0, 10], + "source_sketch_id": "F2", "source_entity_id": "E1", + }, + ]}}}, + {"id": "pattern", "atomic_id": "pattern_circular", "depends_on": ["sweep"], "params": { + "source_feature_ids": ["sweep"], "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}, + "pattern_count": 3, "sweep_angle_deg": 360, + }}, + ], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "spatial-sweep-pattern.step") + self.assertEqual(result["solid_count"], 3) + self.assertGreater(result["volume_mm3"], 0) + def test_shell_removes_a_selected_cap_and_offsets_inward(self) -> None: from cdsl_engine.runtime import rebuild_cdsl @@ -3078,8 +3808,8 @@ class EngineRuntimeFoundationTests(unittest.TestCase): execution.execute_all() self.assertEqual(set(execution.session.body_members), {"hole"}) - def test_scoped_hole_requires_a_sole_live_member_before_execution(self) -> None: - from cdsl_engine.runtime import analyze_cdsl + def test_scoped_hole_cuts_only_its_explicit_live_member(self) -> None: + from cdsl_engine.runtime import prepare_cdsl_execution cdsl = _two_body_boolean_cdsl( "union", _rectangle([-5, -5], [5, 5]), _rectangle([20, -5], [30, 5]), @@ -3095,10 +3825,12 @@ class EngineRuntimeFoundationTests(unittest.TestCase): "scope_feature_id": "left_body", }, }) - analysis = analyze_cdsl(cdsl) - result = analysis.feature_results[-1] - self.assertFalse(result.executable) - self.assertIn("hole_scope_body_ambiguous", [blocker.code for blocker in result.blockers]) + execution = prepare_cdsl_execution(cdsl) + self.assertTrue(execution.analysis.feature_results[-1].executable) + execution.execute_all() + self.assertEqual(set(execution.session.body_members), {"left_body", "right_body"}) + self.assertAlmostEqual(float(execution.session.body.volume), 800.0 - 2.0 * math.pi, places=5) + self.assertAlmostEqual(float(execution.session.body_members["right_body"].volume), 400.0, places=5) def test_counterbore_and_countersink_holes_execute_with_explicit_host_frames(self) -> None: """Keep both legacy hole contracts covered by an actual kernel rebuild.""" @@ -3339,6 +4071,39 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(result["bbox_mm"]["min"][2], -3.0) self.assertEqual(result["bbox_mm"]["max"][2], 2.0) + def test_two_sided_prism_fuse_does_not_preserve_half_prism_swept_edge_handles(self) -> None: + """A symmetric prism needs a dedicated final relation for SWEPT_EDGE. + + Each half prism has exact ``Generated(vertex)`` evidence, but the + fuse replaces those handles with the complete final edge. A resolver + must not treat geometric continuity as a provenance continuation. + """ + from build123d import Face, Vector, Wire + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + face = Face(Wire.make_polygon([ + Vector(-5, -5, 0), Vector(5, -5, 0), Vector(5, 5, 0), + Vector(-5, 5, 0), Vector(-5, -5, 0), + ])) + forward, forward_delta = Build123dGeometryAdapter.extrude_with_topology_delta(face, (0, 0, 2)) + reverse, reverse_delta = Build123dGeometryAdapter.extrude_with_topology_delta(face, (0, 0, -3)) + fused = Build123dGeometryAdapter.fuse(forward, reverse) + final_edges = [edge.wrapped for edge in fused.edges()] + + for delta in (forward_delta, reverse_delta): + generated = [ + value + for relation in delta.relations + if relation.source_kind == "vertex" and relation.result_kind == "edge" + for value in relation.result_values + ] + self.assertTrue(generated) + self.assertFalse(any( + generated_edge.IsSame(final_edge) + for generated_edge in generated + for final_edge in final_edges + )) + def test_two_sided_extrude_requires_reverse_distance_contract(self) -> None: from cdsl_engine.runtime import analyze_cdsl @@ -3504,6 +4269,65 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(len(rebuilt.solids()), 0) self.assertEqual(len(rebuilt.faces()), 1) + def test_extrude_surface_preserves_an_explicit_open_source_wire(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "surface-wire", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "surface_wire", "workplane": _workplane(), + "profile": {"type": "analytic_contours", "contours": [{ + "role": "open", "closed": False, "surface_wire": True, + "segments": [{"type": "line", "start": [0, 0], "end": [10, 0]}], + }]}, + }]}, + "features": [{ + "id": "surface_extrude", "atomic_id": "extrude_surface", "depends_on": [], + "sketch_id": "surface_wire", "params": {"distance_mm": 5}, + }], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "surface-wire.step") + self.assertEqual(result["solid_count"], 0) + self.assertEqual(result["surface_count"], 1) + self.assertEqual(result["surface_face_count"], 1) + self.assertAlmostEqual(result["surface_area_mm2"], 50.0) + + def test_extrude_surface_preserves_connected_and_disconnected_open_source_wires(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "surface-wires", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "surface_wires", "workplane": _workplane(), + "profile": {"type": "analytic_contours", "contours": [ + { + "role": "open", "closed": False, "surface_wire": True, + "segments": [ + {"type": "line", "start": [0, 0], "end": [10, 0]}, + {"type": "line", "start": [10, 0], "end": [10, 10]}, + ], + }, + { + "role": "open", "closed": False, "surface_wire": True, + "segments": [{"type": "line", "start": [20, 0], "end": [26, 0]}], + }, + ]}, + }]}, + "features": [{ + "id": "surface_extrude", "atomic_id": "extrude_surface", "depends_on": [], + "sketch_id": "surface_wires", "params": {"distance_mm": 5}, + }], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "surface-wires.step") + self.assertEqual(result["solid_count"], 0) + self.assertEqual(result["surface_count"], 1) + self.assertEqual(result["surface_face_count"], 3) + self.assertAlmostEqual(result["surface_area_mm2"], 130.0) + def test_surface_limited_chamfer_requires_an_explicit_shell_boundary(self) -> None: from build123d import Plane, Solid from cdsl_engine.build123d_adapter import Build123dGeometryAdapter @@ -3612,6 +4436,30 @@ class EngineRuntimeFoundationTests(unittest.TestCase): result = rebuild_cdsl(cdsl, root / f"{name}.step") self.assertAlmostEqual(result["volume_mm3"], 1000 - expected_depth * 3.141592653589793, places=5) + def test_up_to_vertex_source_datum_does_not_resolve_runtime_topology(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + base = self._base_block() + base["geometry"]["sketches"].append({ + "id": "cut", "source_sketch_id": "source_cut", "workplane": _workplane(), + "profile": {"type": "circle", "center": [0, 0], "radius_mm": 1}, + }) + base["features"].append({ + "id": "cut_to_source_vertex", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], + "params": {"distance_mm": 0, "end_condition": { + "type": "up_to_vertex", "solidworks_code": 5, + "reference": { + "kind": "source_vertex", "source_sketch_id": "source_cut", + "source_entity_id": "E0.end", "point_mm": [0, 0, 10], + }, + }}, + "sketch_id": "cut", + }) + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(base, Path(directory) / "source-vertex.step") + self.assertAlmostEqual(result["volume_mm3"], 1000 - 10 * math.pi, places=5) + self.assertEqual(result["selector_resolution"], []) + def test_up_to_vertex_intersection_requires_one_shared_current_body_vertex(self) -> None: from cdsl_engine.runtime import RuntimeExecutionError, rebuild_cdsl diff --git a/backend/tests/test_selector_provenance_contract.py b/backend/tests/test_selector_provenance_contract.py index 35474d59..11972050 100644 --- a/backend/tests/test_selector_provenance_contract.py +++ b/backend/tests/test_selector_provenance_contract.py @@ -3,7 +3,8 @@ from __future__ import annotations import unittest from engine.cdsl_engine.topology import ( - TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry, TopologySectionRelation, + TopologyBlendRelation, TopologyDelta, TopologyDeltaRelation, TopologyLineage, TopologyRecord, TopologyRegistry, + TopologySectionRelation, ) @@ -53,7 +54,495 @@ def _intersection_intent() -> dict: } +def _query_expr_leaf(label: str) -> dict: + return { + "node": "topology_query", + "owner": {"node": "literal", "value": "f1.opExtrude"}, + "topology_type": {"node": "literal", "value": "SWEPT_FACE"}, + "entity_type": {"node": "literal", "value": "FACE"}, + "arguments": [{"node": "literal", "value": label}], + } + + +def _swept_face_operand(entity_id: str, *, all_fragments: bool = False) -> dict: + expression = _query_expr_leaf(entity_id) + allowed = ["boundary", "fragment"] if all_fragments else ["boundary"] + return { + "kind": "face", "owner_feature_id": "f1", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("SWEPT_FACE", allowed=allowed, multiplicity="all_fragments" if all_fragments else "one"), + "source_entity": {"sketch_id": "F0", "entity_id": entity_id}, + "query_expr": {"version": "1.0", "root": expression}, + }, + } + + +def _proven_set(operands: list[dict], *, operator: str = "union", kind: str = "face") -> dict: + contracts = { + "union": "proven_operand_union", + "intersection": "proven_operand_intersection", + "subtraction": "proven_operand_subtraction", + } + roots = [item["selector_intent"]["query_expr"]["root"] for item in operands] + return { + "kind": kind, + "source": "runtime_snapshot", + "confidence": 1.0, + "query_operands": operands, + "selector_intent": { + **_intent("QUERY_SET", allowed=["boundary"], multiplicity="source_qualified"), + "query_expr": { + "version": "1.0", + "root": {"node": "set", "operator": operator, "operands": roots}, + }, + "query_set_contract": contracts[operator], + "set_kind": kind, + "body_scope": "active_member", + "empty_policy": "reject", + "multiple_policy": "all", + }, + } + + +def _proven_union(operands: list[dict], *, kind: str = "face") -> dict: + return _proven_set(operands, kind=kind) + + +def _owner_body_selector(query_input: dict) -> dict: + input_root = query_input["selector_intent"]["query_expr"]["root"] + return { + "kind": "body", "source": "runtime_snapshot", "confidence": 1.0, + "query_input": query_input, + "selector_intent": { + **_intent("OWNER_BODY", allowed=["boundary"]), + "kind": "body", + "query_expr": { + "version": "1.0", + "root": { + "node": "filter", "filter": "owner_body", "input": input_root, + "arguments": [], + }, + }, + "owner_body_contract": "exact_input_owner", + "body_scope": "active_member", + "empty_policy": "reject", + "multiple_policy": "one", + }, + } + + +def _primary_cut_copy_cap_edge_selector() -> dict: + input_root = { + "node": "topology_query", + "owner": {"node": "literal", "value": "f1.opExtrude"}, + "topology_type": {"node": "literal", "value": "CAP_EDGE"}, + "entity_type": {"node": "literal", "value": "EDGE"}, + "arguments": [], + } + query_input = { + "kind": "edge", "owner_feature_id": "f1", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("CAP_EDGE", allowed=["boundary", "continuation"]), + "kind": "edge", + "source_entity": {"sketch_id": "F0", "entity_id": "E0"}, + "lineage_role": "extrude.end", + "query_expr": {"version": "1.0", "root": input_root}, + }, + } + return { + "kind": "edge", "owner_feature_id": "f1", "source": "runtime_snapshot", "confidence": 1.0, + "query_input": query_input, + "selector_intent": { + **_intent("COPY", allowed=["boundary", "continuation"]), + "kind": "edge", + "copy_contract": "primary_cut_cap_edge", + "query_expr": { + "version": "1.0", + "root": { + "node": "topology_query", + "owner": {"node": "literal", "value": "f1.boolean.opBoolean"}, + "topology_type": {"node": "literal", "value": "COPY"}, + "entity_type": {"node": "literal", "value": "EDGE"}, + "arguments": [{ + "node": "map", + "entries": [{"key": "derivedFrom", "value": input_root}], + }], + }, + }, + }, + } + + +def _primary_cut_copy_cap_face_selector() -> dict: + input_root = { + "node": "topology_query", + "owner": {"node": "literal", "value": "f1.opExtrude"}, + "topology_type": {"node": "literal", "value": "CAP_FACE"}, + "entity_type": {"node": "literal", "value": "FACE"}, + "arguments": [], + } + query_input = { + "kind": "face", "owner_feature_id": "f1", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("CAP_FACE", allowed=["boundary", "continuation"]), + "kind": "face", + "source_entities": [{"sketch_id": "F0", "entity_id": "E0"}], + "lineage_role": "extrude.end", + "query_expr": {"version": "1.0", "root": input_root}, + }, + } + return { + "kind": "face", "owner_feature_id": "f1", "source": "runtime_snapshot", "confidence": 1.0, + "query_input": query_input, + "selector_intent": { + **_intent("COPY", allowed=["boundary", "continuation"]), + "kind": "face", + "copy_contract": "primary_cut_cap_face_workplane", + "query_expr": { + "version": "1.0", + "root": { + "node": "topology_query", + "owner": {"node": "literal", "value": "f1.boolean.opBoolean"}, + "topology_type": {"node": "literal", "value": "COPY"}, + "entity_type": {"node": "literal", "value": "FACE"}, + "arguments": [{ + "node": "map", + "entries": [{"key": "derivedFrom", "value": input_root}], + }], + }, + }, + }, + } + + class SelectorProvenanceContractTests(unittest.TestCase): + def test_primary_cut_copy_cap_edge_requires_exact_two_link_lineage(self) -> None: + registry = TopologyRegistry() + source, transient, result = object(), object(), object() + registry.register(TopologyRecord( + "anchor:f1:edge:0", "edge", "f1", value=source, source_entity=("F0", "E0"), + )) + registry.register(TopologyRecord( + "transient:f1:edge:0", "edge", "f1", "transient:f1", value=transient, transient=True, + )) + registry.register(TopologyRecord( + "body:f1:edge:0", "edge", "f1", "body:f1", value=result, + )) + registry._lineage.extend([ + TopologyLineage( + ("anchor:f1:edge:0",), ("transient:f1:edge:0",), "boundary", "kernel_history", + "complete", "proven", "extrude", "extrude.end", "f1", "edge", "edge", + ), + TopologyLineage( + ("transient:f1:edge:0",), ("body:f1:edge:0",), "continuation", "kernel_history", + "complete", "proven", "subtract", None, "f1", "edge", "edge", + ), + ]) + selector = _primary_cut_copy_cap_edge_selector() + + resolved = registry.resolve(selector, active_body_id="body:f1") + + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.resolution_mode, "copy_lineage") + self.assertEqual(resolved.record.record_id, "body:f1:edge:0") + self.assertEqual(resolved.evidence["transient_records"], ["transient:f1:edge:0"]) + self.assertEqual([item["operation"] for item in resolved.evidence["relations"]], ["extrude", "subtract"]) + + selector["selector_intent"]["query_expr"]["root"]["arguments"][0]["entries"][0]["value"] = { + "node": "literal", "value": "unrelated", + } + rejected = registry.resolve(selector, active_body_id="body:f1") + self.assertEqual(rejected.status, "not_found") + self.assertEqual(rejected.diagnostic.code, "selector_copy_invalid") + + def test_primary_cut_copy_cap_face_requires_one_complete_active_successor(self) -> None: + selector = _primary_cut_copy_cap_face_selector() + + def registry_for( + *, + continuation_count: int = 1, + continuation_coverage: str = "complete", + result_body_id: str = "body:f1", + ) -> TopologyRegistry: + registry = TopologyRegistry() + registry.register(TopologyRecord("transient:f1:face:0", "face", "f1", "transient:f1", transient=True)) + registry.register(TopologyRecord("body:f1:face:0", "face", "f1", result_body_id)) + registry._lineage.append(TopologyLineage( + ("source:f1:profile",), ("transient:f1:face:0",), "boundary", "kernel_history", + "complete", "proven", "extrude", "extrude.end", "f1", "face", "face", + )) + for index in range(continuation_count): + registry._lineage.append(TopologyLineage( + ("transient:f1:face:0",), ("body:f1:face:0",), "continuation", "kernel_history", + continuation_coverage, "proven", "subtract", None, "f1", "face", "face", + )) + return registry + + resolved = registry_for().resolve(selector, active_body_id="body:f1") + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.resolution_mode, "copy_lineage") + + for label, registry in ( + ("missing", registry_for(continuation_count=0)), + ("ambiguous", registry_for(continuation_count=2)), + ("partial", registry_for(continuation_coverage="partial")), + ): + with self.subTest(continuation=label): + rejected = registry.resolve(selector, active_body_id="body:f1") + self.assertEqual(rejected.status, "not_found") + self.assertEqual(rejected.diagnostic.code, "selector_copy_kernel_history_missing") + + inactive = registry_for(result_body_id="body:other").resolve(selector, active_body_id="body:f1") + self.assertEqual(inactive.status, "not_found") + self.assertEqual(inactive.diagnostic.code, "selector_copy_inactive") + + def test_primary_cut_copy_swept_face_requires_exact_source_edge_continuation(self) -> None: + selector = _primary_cut_copy_cap_face_selector() + selector["selector_intent"]["copy_contract"] = "primary_cut_swept_face_workplane" + query_input = selector["query_input"] + input_intent = query_input["selector_intent"] + input_intent["query_family"] = "SWEPT_FACE" + input_intent.pop("source_entities") + input_intent.pop("lineage_role") + input_intent["source_entity"] = {"sketch_id": "F0", "entity_id": "E0"} + input_root = input_intent["query_expr"]["root"] + input_root["topology_type"] = {"node": "literal", "value": "SWEPT_FACE"} + + def registry_for(*, continuation_count: int = 1, result_body_id: str = "body:f1") -> TopologyRegistry: + registry = TopologyRegistry() + registry.register(TopologyRecord( + "anchor:f1:edge:0", "edge", "f1", value=object(), source_entity=("F0", "E0"), transient=True, + )) + registry.register(TopologyRecord("transient:f1:face:0", "face", "f1", "transient:f1", transient=True)) + registry.register(TopologyRecord("body:f1:face:0", "face", "f1", result_body_id)) + registry._lineage.append(TopologyLineage( + ("anchor:f1:edge:0",), ("transient:f1:face:0",), "boundary", "kernel_history", + "complete", "proven", "extrude", None, "f1", "edge", "face", + )) + for _ in range(continuation_count): + registry._lineage.append(TopologyLineage( + ("transient:f1:face:0",), ("body:f1:face:0",), "continuation", "kernel_history", + "complete", "proven", "subtract", None, "f1", "face", "face", + )) + return registry + + resolved = registry_for().resolve(selector, active_body_id="body:f1") + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.resolution_mode, "copy_lineage") + self.assertEqual(resolved.record.record_id, "body:f1:face:0") + + for label, registry in (("missing", registry_for(continuation_count=0)), ("ambiguous", registry_for(continuation_count=2))): + with self.subTest(continuation=label): + rejected = registry.resolve(selector, active_body_id="body:f1") + self.assertEqual(rejected.status, "not_found") + self.assertEqual(rejected.diagnostic.code, "selector_copy_kernel_history_missing") + + inactive = registry_for(result_body_id="body:other").resolve(selector, active_body_id="body:f1") + self.assertEqual(inactive.status, "not_found") + self.assertEqual(inactive.diagnostic.code, "selector_copy_inactive") + + def _two_side_registry( + self, + *, + missing_right_result: bool = False, + split_left_result: bool = False, + right_shares_left_result: bool = False, + ) -> TopologyRegistry: + registry = TopologyRegistry() + left_source, right_source = object(), object() + left_result, left_fragment, right_result = object(), object(), object() + if right_shares_left_result: + right_result = left_result + missing_result = object() + anchors = [ + TopologyRecord("anchor:left", "edge", "f1", value=left_source, source_entity=("F0", "left")), + TopologyRecord("anchor:right", "edge", "f1", value=right_source, source_entity=("F0", "right")), + ] + for anchor in anchors: + registry.register(anchor) + left_values = (left_result, left_fragment) if split_left_result else (left_result,) + right_values = (right_result, missing_result) if missing_right_result else (right_result,) + relations = [ + TopologyDeltaRelation( + "generated", "edge", left_source, left_values, + source_kind="edge", result_kind="face", derivation="boundary", + ), + ] + if not right_shares_left_result: + relations.append(TopologyDeltaRelation( + "generated", "edge", right_source, right_values, + source_kind="edge", result_kind="face", derivation="boundary", + )) + registry.replace_body_topology( + "f1", "body:f1", [ + TopologyRecord("body:f1:left", "face", "f1", "body:f1", value=left_result), + *([TopologyRecord("body:f1:left-fragment", "face", "f1", "body:f1", value=left_fragment)] if split_left_result else []), + *([] if right_shares_left_result else [ + TopologyRecord("body:f1:right", "face", "f1", "body:f1", value=right_result), + ]), + ], + topology_delta=TopologyDelta("extrude", tuple(relations)), + additional_predecessors=anchors, + ) + if right_shares_left_result: + # Set algebra can overlap only when separate complete operations + # prove the same active record. Keeping this fact in a different + # operation component avoids converting it into an artificial + # source merge, which the direct child policy must still reject. + registry._lineage.append(TopologyLineage( + source_record_ids=("anchor:right",), + result_record_ids=("body:f1:left",), + derivation="boundary", + evidence="kernel_history", + coverage="complete", + status="proven", + operation="independent_extrude", + feature_id="f1", + source_kind="edge", + result_kind="face", + )) + return registry + + def test_proven_q_union_preserves_operand_order_and_deduplicates_handles(self) -> None: + registry = self._two_side_registry() + left = _swept_face_operand("left") + right = _swept_face_operand("right") + + resolved = registry.resolve(_proven_union([left, right, left]), active_body_id="body:f1") + + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.resolution_mode, "query_set_union") + self.assertEqual([record.record_id for record in resolved.records], ["body:f1:left", "body:f1:right"]) + self.assertEqual( + [item["result_records"] for item in resolved.evidence["operand_resolutions"]], + [["body:f1:left"], ["body:f1:right"], ["body:f1:left"]], + ) + + def test_proven_q_union_keeps_the_complete_fragment_set_per_operand(self) -> None: + registry = self._two_side_registry(split_left_result=True) + split_left = _swept_face_operand("left", all_fragments=True) + right = _swept_face_operand("right") + + resolved = registry.resolve(_proven_union([split_left, right]), active_body_id="body:f1") + + self.assertEqual(resolved.status, "resolved") + self.assertEqual( + [record.record_id for record in resolved.records], + ["body:f1:left", "body:f1:left-fragment", "body:f1:right"], + ) + self.assertEqual( + resolved.evidence["operand_resolutions"][0]["result_records"], + ["body:f1:left", "body:f1:left-fragment"], + ) + + def test_proven_q_union_evaluates_nested_set_tree_without_flattening(self) -> None: + registry = self._two_side_registry() + left = _swept_face_operand("left") + right = _swept_face_operand("right") + nested = _proven_union([right, left]) + + resolved = registry.resolve( + _proven_union([left, nested]), active_body_id="body:f1", + ) + + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.resolution_mode, "query_set_union") + self.assertEqual( + [record.record_id for record in resolved.records], + ["body:f1:left", "body:f1:right"], + ) + self.assertEqual( + resolved.evidence["operand_resolutions"][1]["resolution_mode"], + "query_set_union", + ) + + def test_proven_q_intersection_keeps_first_operand_order_for_exact_shared_records(self) -> None: + registry = self._two_side_registry(split_left_result=True, right_shares_left_result=True) + left = _swept_face_operand("left", all_fragments=True) + right = _swept_face_operand("right") + + resolved = registry.resolve( + _proven_set([left, right], operator="intersection"), active_body_id="body:f1", + ) + + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.resolution_mode, "query_set_intersection") + self.assertEqual([record.record_id for record in resolved.records], ["body:f1:left"]) + self.assertEqual(resolved.evidence["set_operator"], "intersection") + + def test_proven_q_subtraction_keeps_left_order_and_removes_exact_right_records(self) -> None: + registry = self._two_side_registry(split_left_result=True, right_shares_left_result=True) + left = _swept_face_operand("left", all_fragments=True) + right = _swept_face_operand("right") + + resolved = registry.resolve( + _proven_set([left, right], operator="subtraction"), active_body_id="body:f1", + ) + + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.resolution_mode, "query_set_subtraction") + self.assertEqual([record.record_id for record in resolved.records], ["body:f1:left-fragment"]) + self.assertEqual(resolved.evidence["set_operator"], "subtraction") + + def test_proven_q_intersection_and_subtraction_reject_an_empty_final_set(self) -> None: + left = _swept_face_operand("left") + right = _swept_face_operand("right") + cases = ( + (_proven_set([left, right], operator="intersection"), self._two_side_registry()), + (_proven_set([left, left], operator="subtraction"), self._two_side_registry()), + ) + + for selector, registry in cases: + with self.subTest(operator=selector["selector_intent"]["query_expr"]["root"]["operator"]): + resolved = registry.resolve(selector, active_body_id="body:f1") + self.assertEqual(resolved.status, "not_found") + self.assertEqual(resolved.diagnostic.code, "selector_query_empty") + + def test_proven_q_subtraction_requires_exactly_two_source_operands(self) -> None: + left = _swept_face_operand("left") + selector = _proven_set([left, left, left], operator="subtraction") + + resolved = TopologyRegistry().resolve(selector, active_body_id="body:f1") + + self.assertEqual(resolved.status, "not_found") + self.assertEqual(resolved.diagnostic.code, "selector_query_set_invalid") + self.assertIn("qSubtraction", resolved.diagnostic.detail["reason"]) + + def test_proven_q_union_rejects_empty_or_kind_incompatible_contracts(self) -> None: + left = _swept_face_operand("left") + empty = _proven_union([left, left]) + empty["query_operands"] = [] + mixed = _proven_union([left, _swept_face_operand("right")]) + mixed["query_operands"][1]["kind"] = "edge" + non_provenance = _proven_union([left, _swept_face_operand("right")]) + non_provenance["query_operands"][1]["geometry"] = {"surface_type": "plane"} + + for selector, expected in ( + (empty, "operands do not match"), + (mixed, "parent set kind"), + (non_provenance, "unsupported non-provenance evidence"), + ): + with self.subTest(expected=expected): + resolved = TopologyRegistry().resolve(selector, active_body_id="body:f1") + self.assertEqual(resolved.status, "not_found") + self.assertEqual(resolved.diagnostic.code, "selector_query_set_invalid") + self.assertIn(expected, resolved.diagnostic.detail["reason"]) + + def test_proven_q_union_rejects_inactive_or_partial_operand_lineage(self) -> None: + left = _swept_face_operand("left") + right = _swept_face_operand("right") + selector = _proven_union([left, right]) + + inactive = self._two_side_registry().resolve(selector, active_body_id="body:later") + self.assertEqual(inactive.status, "not_found") + self.assertEqual(inactive.diagnostic.code, "selector_body_member_inactive") + self.assertEqual(inactive.diagnostic.detail["operand_index"], 0) + + partial = self._two_side_registry(missing_right_result=True).resolve(selector, active_body_id="body:f1") + self.assertEqual(partial.status, "not_found") + self.assertEqual(partial.diagnostic.code, "selector_kernel_history_missing") + self.assertEqual(partial.diagnostic.detail["operand_index"], 1) + def test_direct_swept_body_resolves_only_the_active_member(self) -> None: """A direct body query is body-graph evidence, never a stable-ID fallback.""" registry = TopologyRegistry() @@ -87,6 +576,68 @@ class SelectorProvenanceContractTests(unittest.TestCase): self.assertEqual(rejected.status, "not_found") self.assertEqual(rejected.diagnostic.code, "selector_source_unavailable") + def test_q_owner_body_projects_one_proven_active_member_to_its_body_record(self) -> None: + registry = self._two_side_registry() + body = object() + registry.register(TopologyRecord( + "body:f1", "body", "f1", "body:f1", value=body, owner_feature_ids=("f1",), + )) + + resolved = registry.resolve( + _owner_body_selector(_swept_face_operand("left")), active_body_id="body:f1", + ) + + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.resolution_mode, "owner_body") + self.assertIs(resolved.record.value, body) + self.assertEqual(resolved.evidence["input_record_ids"], ["body:f1:left"]) + self.assertEqual(resolved.evidence["body_member"], "body:f1") + + def test_q_owner_body_rejects_ambiguous_or_missing_exact_body_projection(self) -> None: + ambiguous_registry = self._two_side_registry(split_left_result=True) + ambiguous_registry.register(TopologyRecord( + "body:f1", "body", "f1", "body:f1", value=object(), owner_feature_ids=("f1",), + )) + ambiguous = ambiguous_registry.resolve( + _owner_body_selector(_swept_face_operand("left", all_fragments=True)), active_body_id="body:f1", + ) + self.assertEqual(ambiguous.status, "not_found") + self.assertEqual(ambiguous.diagnostic.code, "selector_owner_body_ambiguous") + + missing = self._two_side_registry().resolve( + _owner_body_selector(_swept_face_operand("left")), active_body_id="body:f1", + ) + self.assertEqual(missing.status, "not_found") + self.assertEqual(missing.diagnostic.code, "selector_owner_body_not_found") + + def test_q_owner_body_rejects_inactive_input_and_tampered_contract(self) -> None: + registry = self._two_side_registry() + registry.register(TopologyRecord( + "body:f1", "body", "f1", "body:f1", value=object(), owner_feature_ids=("f1",), + )) + selector = _owner_body_selector(_swept_face_operand("left")) + + inactive = registry.resolve(selector, active_body_id="body:later") + self.assertEqual(inactive.status, "not_found") + self.assertEqual(inactive.diagnostic.code, "selector_owner_body_input_unresolved") + self.assertEqual(inactive.diagnostic.detail["cause"]["code"], "selector_body_member_inactive") + + tampered = {**selector, "geometry": {"surface_type": "plane"}} + rejected = registry.resolve(tampered, active_body_id="body:f1") + self.assertEqual(rejected.status, "not_found") + self.assertEqual(rejected.diagnostic.code, "selector_owner_body_invalid") + + query_set_child = _owner_body_selector(_swept_face_operand("left")) + query_set_child["query_input"] = _proven_union([ + _swept_face_operand("left"), _swept_face_operand("left"), + ]) + query_set_child["selector_intent"]["query_expr"]["root"]["input"] = ( + query_set_child["query_input"]["selector_intent"]["query_expr"]["root"] + ) + rejected_set = registry.resolve(query_set_child, active_body_id="body:f1") + self.assertEqual(rejected_set.status, "not_found") + self.assertEqual(rejected_set.diagnostic.code, "selector_owner_body_invalid") + def test_direct_prism_swept_face_requires_proven_continuation_to_active_body(self) -> None: """A side-wall extent may cross only complete kernel continuations.""" registry = TopologyRegistry() @@ -233,6 +784,16 @@ class SelectorProvenanceContractTests(unittest.TestCase): self.assertEqual(edge_resolution.evidence["relations"][0]["source_kind"], "vertex") self.assertEqual(edge_resolution.evidence["relations"][0]["result_kind"], "edge") + # A later operation may materialize the same source sketch again. Its + # source anchor is not an alternative anchor for this producer query. + registry.register(TopologyRecord( + "anchor:later", "vertex", "later", + source_entities=(("F0", "E0"), ("F0", "E1")), + )) + replayed_edge_resolution = registry.resolve(edge_selector, active_body_id="body:extrude") + self.assertEqual(replayed_edge_resolution.status, "resolved") + self.assertEqual(replayed_edge_resolution.record.record_id, edge_resolution.record.record_id) + cap_records = [] for role in ("extrude.start", "extrude.end"): cap_resolution = registry.resolve({ @@ -251,6 +812,29 @@ class SelectorProvenanceContractTests(unittest.TestCase): cap_records.append(cap_resolution.record.record_id) self.assertEqual(len(set(cap_records)), 2) + cap_vertex_records = [] + for role in ("extrude.start", "extrude.end"): + vertex_resolution = registry.resolve({ + "kind": "vertex", "owner_feature_id": "extrude", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("CAP_VERTEX", allowed=["boundary"]), + "kind": "vertex", + "source_entities": [ + {"sketch_id": "F0", "entity_id": "E0"}, + {"sketch_id": "F0", "entity_id": "E1"}, + ], + "lineage_role": role, + }, + }, active_body_id="body:extrude") + self.assertEqual(vertex_resolution.status, "resolved") + self.assertEqual(vertex_resolution.resolution_mode, "kernel_lineage") + self.assertEqual(vertex_resolution.evidence["relations"][0]["source_kind"], "vertex") + self.assertEqual(vertex_resolution.evidence["relations"][0]["result_kind"], "vertex") + self.assertEqual(vertex_resolution.evidence["relations"][0]["output_role"], role) + cap_vertex_records.append(vertex_resolution.record.record_id) + self.assertEqual(len(set(cap_vertex_records)), 2) + missing_role = registry.resolve({ "kind": "edge", "owner_feature_id": "extrude", "source": "runtime_snapshot", "confidence": 1.0, @@ -303,6 +887,71 @@ class SelectorProvenanceContractTests(unittest.TestCase): self.assertEqual(resolution.status, "not_found") self.assertEqual(resolution.diagnostic.code, "selector_kernel_history_missing") + def test_exact_preserved_member_keeps_cap_vertex_lineage_across_compound_snapshot(self) -> None: + registry = TopologyRegistry() + source_vertex = object() + cap_vertex = object() + other_vertex = object() + anchor = TopologyRecord( + "anchor:vertex", "vertex", "f1", value=source_vertex, + source_entities=(("F0", "E0"), ("F0", "E1")), + ) + registry.register(anchor) + registry.replace_body_topology( + "f1", "body:f1", [TopologyRecord("f1:cap", "vertex", "f1", "body:f1", value=cap_vertex)], + topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation( + "generated", "vertex", source_vertex, (cap_vertex,), output_role="extrude.end", + source_kind="vertex", result_kind="vertex", + ), + )), additional_predecessors=[anchor], + ) + # F3 adds an independent member; F1's cap vertex is exactly the same + # B-rep item in its new compound member, while F3 itself is distinct. + registry.replace_body_topologies( + "f3", [ + ("body:f3:0", [TopologyRecord("f3:preserved-cap", "vertex", "f3", "body:f3:0", value=cap_vertex)]), + ("body:f3:1", [TopologyRecord("f3:new", "vertex", "f3", "body:f3:1", value=other_vertex)]), + ], active_body_id="body:f3", member_preservations=[("body:f1", "body:f3:0")], + ) + selector = { + "kind": "vertex", "owner_feature_id": "f1", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("CAP_VERTEX", allowed=["boundary", "continuation"]), + "kind": "vertex", + "source_entities": [ + {"sketch_id": "F0", "entity_id": "E0"}, + {"sketch_id": "F0", "entity_id": "E1"}, + ], + "lineage_role": "extrude.end", + }, + } + resolution = registry.resolve(selector, active_body_id="body:f3") + self.assertEqual(resolution.status, "resolved") + self.assertEqual(resolution.record.record_id, "f3:preserved-cap") + self.assertEqual( + [relation["operation"] for relation in resolution.evidence["relations"]], + ["extrude", "body_member_preserve"], + ) + + # A merely equal-looking replacement has a new opaque handle and must + # not inherit a member-preservation relation. + replacement = TopologyRegistry() + replacement.register(anchor) + replacement.replace_body_topology( + "f1", "body:f1", [TopologyRecord("f1:cap", "vertex", "f1", "body:f1", value=cap_vertex)], + topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation("generated", "vertex", source_vertex, (cap_vertex,), output_role="extrude.end", source_kind="vertex", result_kind="vertex"), + )), additional_predecessors=[anchor], + ) + replacement.replace_body_topologies( + "f3", [("body:f3:0", [TopologyRecord("f3:replacement", "vertex", "f3", "body:f3:0", value=object())])], + active_body_id="body:f3", member_preservations=[("body:f1", "body:f3:0")], + ) + rejected = replacement.resolve(selector, active_body_id="body:f3") + self.assertEqual(rejected.status, "not_found") + self.assertEqual(rejected.diagnostic.code, "selector_body_member_inactive") + def test_cap_edge_continuation_requires_the_complete_active_chain(self) -> None: """A direct cap edge may cross only a proven one-to-one continuation.""" registry = TopologyRegistry() @@ -594,6 +1243,35 @@ class SelectorProvenanceContractTests(unittest.TestCase): {(item["kind"], item.get("source_entity")) for item in anchors}, ) + def test_direct_circle_profile_uses_only_its_declared_source_anchor(self) -> None: + """A direct CDSL circle carries its source identity on the profile.""" + from engine.cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + sketch = { + "id": "profile", "source_sketch_id": "F0", + "workplane": { + "origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1], + }, + "profile": { + "type": "circle", "center": [0, 0], "radius_mm": 5, + "source_entity_id": "E5", + }, + # This generic entity entry deliberately disagrees. A direct + # profile must not look it up or infer the source by geometry. + "entities": [{"type": "circle", "center": [0, 0], "radius_mm": 5, "source_entity_id": "wrong"}], + } + faces, anchors = Build123dGeometryAdapter().faces_for_sketch_with_source_anchors(sketch) + + self.assertEqual(len(faces), 1) + self.assertEqual( + {(item["kind"], item.get("source_entity")) for item in anchors}, + {("edge", ("F0", "E5"))}, + ) + unlabelled = {**sketch, "profile": {**sketch["profile"]}} + unlabelled["profile"].pop("source_entity_id") + _faces, no_anchors = Build123dGeometryAdapter().faces_for_sketch_with_source_anchors(unlabelled) + self.assertFalse(no_anchors) + def test_direct_multiregion_prism_keeps_each_source_side_face(self) -> None: """Each independently built source region keeps its own final evidence.""" from engine.cdsl_engine.runtime import prepare_cdsl_execution @@ -670,6 +1348,111 @@ class SelectorProvenanceContractTests(unittest.TestCase): for relation in side_relations )) + def test_multiregion_imprint_prism_fuse_uses_only_final_history(self) -> None: + """A fused IMPRINT region keeps exact outer lineage and rejects a deleted seam.""" + from engine.cdsl_engine.build123d_adapter import Build123dGeometryAdapter + from engine.cdsl_engine.sketch_solver import resolve_all_sketches + + 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]}}, + ], + # A bare source set selects both bounded regions above the divider. + "selections": [{"source_entity_id": "top", "face_side": 1}], + } + resolved = resolve_all_sketches({"geometry": {"sketches": [{ + "id": "imprint", "source_sketch_id": "F0", + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": profile, + }]}}) + adapter = Build123dGeometryAdapter() + faces, anchor_specs = adapter.faces_for_sketch_with_source_anchors( + resolved["geometry"]["sketches"][0], + ) + self.assertEqual(len(faces), 2) + composed = adapter.extrude_faces_with_composed_topology_delta(faces, (0, 0, 2)) + self.assertIsNotNone(composed) + body, topology_delta = composed + self.assertTrue(body.is_valid) + self.assertAlmostEqual(float(body.volume), 200.0, places=6) + self.assertEqual(topology_delta.history_status, "proven") + self.assertEqual(topology_delta.history_reason, "exact_prism_fuse_history") + self.assertTrue(all( + adapter._is_result_topology_member(body.wrapped, value) + for relation in topology_delta.relations + for value in relation.result_values + )) + + registry = TopologyRegistry() + anchors = [ + TopologyRecord( + f"anchor:{index}", str(spec["kind"]), "f1", value=spec["value"], + source_entity=spec.get("source_entity"), + source_entities=tuple(spec.get("source_entities") or ()), + ) + for index, spec in enumerate(anchor_specs) + ] + for anchor in anchors: + registry.register(anchor) + registry.replace_body_topology( + "f1", "body:f1", adapter.topology_records(body, "f1", "body:f1"), + topology_delta=topology_delta, additional_predecessors=anchors, + ) + + def swept_selector(entity_id: str, multiplicity: str = "all_fragments") -> dict: + return { + "kind": "face", "owner_feature_id": "f1", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("SWEPT_FACE", allowed=["boundary", "fragment"], multiplicity=multiplicity), + "source_entity": {"sketch_id": "F0", "entity_id": entity_id}, + }, + } + + outer = registry.resolve(swept_selector("left"), active_body_id="body:f1") + self.assertEqual(outer.status, "resolved") + self.assertEqual(len(outer.records), 1) + split_outer = registry.resolve(swept_selector("top"), active_body_id="body:f1") + self.assertEqual(split_outer.status, "resolved") + self.assertEqual(len(split_outer.records), 2) + + # The shared source edge created two intermediate side walls, both + # deleted by the exact fuse. It must not acquire a nearby final face. + deleted = registry.resolve(swept_selector("divider"), active_body_id="body:f1") + self.assertEqual(deleted.status, "not_found") + self.assertEqual(deleted.diagnostic.code, "selector_kernel_history_missing") + divider_anchor = next(anchor for anchor in anchors if anchor.source_entity == ("F0", "divider")) + divider_relation = next( + relation for relation in topology_delta.relations + if relation.source_kind == "edge" and relation.result_kind == "face" + and relation.source_value.IsSame(divider_anchor.value.wrapped) + ) + self.assertEqual(divider_relation.coverage, "partial") + self.assertEqual(divider_relation.status, "unknown") + self.assertEqual(divider_relation.result_values, ()) + + # A split source has two exact physical anchors; the one-result policy + # stays ambiguous instead of selecting one fragment by geometry. + ambiguous = registry.resolve(swept_selector("top", "one"), active_body_id="body:f1") + self.assertEqual(ambiguous.status, "not_found") + self.assertEqual(ambiguous.diagnostic.code, "selector_ambiguous") + + for role in ("extrude.start", "extrude.end"): + caps = registry.resolve({ + "kind": "face", "owner_feature_id": "f1", "output_role": role, + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("CAP_FACE", allowed=["boundary", "fragment"], multiplicity="all_fragments"), + "output_role": role, + }, + }, active_body_id="body:f1") + self.assertEqual(caps.status, "resolved") + self.assertEqual(len(caps.records), 2) + def test_unknown_source_version_is_preserved_then_rejected_by_resolver(self) -> None: registry = TopologyRegistry() selector = { @@ -948,6 +1731,50 @@ class SelectorProvenanceContractTests(unittest.TestCase): section_relation = next(item for item in resolved.evidence["relations"] if item["derivation"] == "intersection") self.assertEqual(set(section_relation["source_record_ids"]), {"f1:cap", "f2:swept"}) + def test_intersect_uses_the_builder_cap_snapshot_not_a_cached_continuation_role(self) -> None: + registry = self._source_qualified_section_registry() + cap = next(record for record in registry.records() if record.record_id == "f1:cap") + continued = object() + registry.replace_body_topology("f3", "body:f3", [ + TopologyRecord( + "f3:continued-cap", "face", "f3", "body:f3", value=continued, + owner_feature_ids=("f1",), output_roles=("extrude.end",), + ), + ], topology_delta=TopologyDelta("subtract", ( + TopologyDeltaRelation("preserved", "face", cap.value, (continued,)), + ))) + + resolved = registry.resolve({ + "kind": "edge", "owner_feature_id": "boolean", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _intersection_intent(), + }, active_body_id="body:boolean") + + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.resolution_mode, "kernel_intersection") + self.assertEqual(resolved.evidence["source_records"][0], "f1:cap") + + def test_intersect_rejects_duplicate_cap_builder_role_facts(self) -> None: + registry = self._source_qualified_section_registry() + registry._topology_deltas.append({ + "feature_id": "f1", + "operation": "extrude", + "relations": [{ + "output_role": "extrude.end", + "coverage": "complete", + "lineage_status": "proven", + "result_kind": "face", + "result_record_ids": ["f1:cap"], + }], + }) + + result = registry.resolve({ + "kind": "edge", "owner_feature_id": "boolean", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _intersection_intent(), + }, active_body_id="body:boolean") + + self.assertEqual(result.status, "not_found") + self.assertEqual(result.diagnostic.code, "selector_relation_non_unique") + def test_intersect_rejects_unqualified_section_edge(self) -> None: registry = self._source_qualified_section_registry(qualified=False) result = registry.resolve({ @@ -1057,6 +1884,279 @@ class SelectorProvenanceContractTests(unittest.TestCase): self.assertEqual(result.status, "not_found") self.assertEqual(result.diagnostic.code, "selector_body_member_inactive") + def test_blend_transition_evidence_requires_a_complete_three_handle_relation(self) -> None: + registry = TopologyRegistry() + source_edge, source_face = object(), object() + patch_face, modified_face, final_edge = object(), object(), object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:edge", "edge", "base", "body:base", value=source_edge), + TopologyRecord("base:face", "face", "base", "body:base", value=source_face), + ]) + registry.replace_body_topology("chamfer", "body:chamfer", [ + TopologyRecord("chamfer:patch", "face", "chamfer", "body:chamfer", value=patch_face), + TopologyRecord("chamfer:modified", "face", "chamfer", "body:chamfer", value=modified_face), + TopologyRecord("chamfer:edge", "edge", "chamfer", "body:chamfer", value=final_edge), + ], topology_delta=TopologyDelta("chamfer", blend_relations=( + TopologyBlendRelation( + source_edge, source_face, patch_face, modified_face, (final_edge,), + ), + ))) + + relation = registry.topology_deltas()[0]["relations"][0] + self.assertEqual(relation["status"], "exact_blend_boundary") + self.assertEqual(relation["source_record_ids"], ["base:edge"]) + self.assertEqual(relation["blend_into_source_record_ids"], ["base:face"]) + self.assertEqual(relation["blend_into_result_record_ids"], ["chamfer:modified"]) + self.assertEqual(relation["patch_face_record_ids"], ["chamfer:patch"]) + self.assertEqual(relation["result_record_ids"], ["chamfer:edge"]) + self.assertNotIn("base:edge", registry._successors) + self.assertFalse(registry.lineage()) + + incomplete = TopologyRegistry() + incomplete.replace_body_topology("base", "body:base", [ + TopologyRecord("base:edge", "edge", "base", "body:base", value=source_edge), + TopologyRecord("base:face", "face", "base", "body:base", value=source_face), + ]) + incomplete.replace_body_topology("chamfer", "body:chamfer", [ + TopologyRecord("chamfer:patch", "face", "chamfer", "body:chamfer", value=patch_face), + TopologyRecord("chamfer:modified", "face", "chamfer", "body:chamfer", value=modified_face), + TopologyRecord("chamfer:edge", "edge", "chamfer", "body:chamfer", value=final_edge), + ], topology_delta=TopologyDelta("chamfer", blend_relations=( + TopologyBlendRelation( + source_edge, source_face, patch_face, modified_face, (), coverage="partial", status="unknown", + ), + ))) + self.assertEqual( + incomplete.topology_deltas()[0]["relations"][0]["status"], + "incomplete_blend_boundary", + ) + + def test_blend_edge_resolver_requires_one_complete_active_source_pair(self) -> None: + def registry_with_blends( + blend_factory, *, face_role: str = "extrude.end", + ) -> TopologyRegistry: + registry = TopologyRegistry() + anchor_value, cap_edge_value, cap_face_value = object(), object(), object() + anchor = TopologyRecord( + "anchor:F0:E0", "edge", "f1", value=anchor_value, + source_entity=("F0", "E0"), + ) + registry.register(anchor) + registry.replace_body_topology("f1", "body:f1", [ + TopologyRecord("f1:cap-edge", "edge", "f1", "body:f1", value=cap_edge_value), + TopologyRecord( + "f1:cap-face", "face", "f1", "body:f1", value=cap_face_value, + output_roles=(face_role,), + ), + ], topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation( + "generated", "edge", anchor_value, (cap_edge_value,), + source_kind="edge", result_kind="edge", derivation="boundary", + output_role="extrude.end", + ), + )), additional_predecessors=[anchor]) + blends = blend_factory(cap_edge_value, cap_face_value) + current = [] + for index, blend in enumerate(blends): + current.extend(( + TopologyRecord(f"f2:patch:{index}", "face", "f2", "body:f2", value=blend.patch_face_value), + TopologyRecord(f"f2:modified:{index}", "face", "f2", "body:f2", value=blend.blend_into_result_value), + *( + TopologyRecord(f"f2:edge:{index}:{edge_index}", "edge", "f2", "body:f2", value=value) + for edge_index, value in enumerate(blend.result_values) + ), + )) + registry.replace_body_topology( + "f2", "body:f2", current, + topology_delta=TopologyDelta("chamfer", blend_relations=blends), + ) + return registry + + patch, modified, final_edge = object(), object(), object() + # Bind the actual snapshot handles. No geometry and no unrelated + # owner can satisfy this resolver path. + registry = registry_with_blends( + lambda cap_edge, cap_face: ( + TopologyBlendRelation(cap_edge, cap_face, patch, modified, (final_edge,)), + ), + ) + selector = { + "kind": "edge", "owner_feature_id": "f2", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("BLEND_EDGE", allowed=["boundary"]), + "kind": "edge", "evidence": "kernel_history", + "blend_sources": { + "edge": { + "query_family": "CAP_EDGE", "owner_feature_id": "f1", + "source_entity": {"sketch_id": "F0", "entity_id": "E0"}, + "lineage_role": "extrude.end", + }, + "face": { + "query_family": "CAP_FACE", "owner_feature_id": "f1", + "output_role": "extrude.end", + }, + }, + }, + } + resolved = registry.resolve(selector, active_body_id="body:f2") + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.resolution_mode, "blend_boundary") + self.assertEqual(resolved.record.record_id, "f2:edge:0:0") + + inactive = registry.resolve(selector, active_body_id="body:f1") + self.assertEqual(inactive.status, "not_found") + self.assertEqual(inactive.diagnostic.code, "selector_relation_non_unique") + + other_edge = object() + ambiguous = registry_with_blends( + lambda cap_edge, cap_face: ( + TopologyBlendRelation(cap_edge, cap_face, patch, modified, (final_edge,)), + TopologyBlendRelation(cap_edge, cap_face, object(), object(), (other_edge,)), + ), + ).resolve(selector, active_body_id="body:f2") + self.assertEqual(ambiguous.status, "not_found") + self.assertEqual(ambiguous.diagnostic.code, "selector_relation_non_unique") + + incomplete = registry_with_blends( + lambda cap_edge, cap_face: ( + TopologyBlendRelation(cap_edge, cap_face, patch, modified, (), coverage="partial", status="unknown"), + ), + ).resolve(selector, active_body_id="body:f2") + self.assertEqual(incomplete.status, "not_found") + self.assertEqual(incomplete.diagnostic.code, "selector_kernel_history_missing") + + wrong_role = registry_with_blends( + lambda cap_edge, cap_face: ( + TopologyBlendRelation(cap_edge, cap_face, patch, modified, (final_edge,)), + ), + face_role="extrude.start", + ).resolve(selector, active_body_id="body:f2") + self.assertEqual(wrong_role.status, "not_found") + self.assertEqual(wrong_role.diagnostic.code, "selector_source_unavailable") + + def test_blend_edge_resolver_accepts_one_exact_direct_swept_face_source(self) -> None: + registry = TopologyRegistry() + anchor_value, cap_edge_value, swept_face_value = object(), object(), object() + patch, modified, final_edge = object(), object(), object() + anchor = TopologyRecord( + "anchor:F0:E0", "edge", "f1", value=anchor_value, + source_entity=("F0", "E0"), + ) + registry.register(anchor) + registry.replace_body_topology("f1", "body:f1", [ + TopologyRecord("f1:cap-edge", "edge", "f1", "body:f1", value=cap_edge_value), + TopologyRecord("f1:swept-face", "face", "f1", "body:f1", value=swept_face_value), + ], topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation( + "generated", "edge", anchor_value, (cap_edge_value,), + source_kind="edge", result_kind="edge", derivation="boundary", + output_role="extrude.end", + ), + TopologyDeltaRelation( + "generated", "face", anchor_value, (swept_face_value,), + source_kind="edge", result_kind="face", derivation="boundary", + ), + )), additional_predecessors=[anchor]) + registry.replace_body_topology("f2", "body:f2", [ + TopologyRecord("f2:patch", "face", "f2", "body:f2", value=patch), + TopologyRecord("f2:modified", "face", "f2", "body:f2", value=modified), + TopologyRecord("f2:edge", "edge", "f2", "body:f2", value=final_edge), + ], topology_delta=TopologyDelta("fillet", blend_relations=( + TopologyBlendRelation(cap_edge_value, swept_face_value, patch, modified, (final_edge,)), + ))) + selector = { + "kind": "edge", "owner_feature_id": "f2", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("BLEND_EDGE", allowed=["boundary"]), + "kind": "edge", "evidence": "kernel_history", + "blend_sources": { + "edge": { + "query_family": "CAP_EDGE", "owner_feature_id": "f1", + "source_entity": {"sketch_id": "F0", "entity_id": "E0"}, + "lineage_role": "extrude.end", + }, + "face": { + "query_family": "SWEPT_FACE", "owner_feature_id": "f1", + "source_entity": {"sketch_id": "F0", "entity_id": "E0"}, + }, + }, + }, + } + resolved = registry.resolve(selector, active_body_id="body:f2") + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.resolution_mode, "blend_boundary") + self.assertEqual(resolved.record.record_id, "f2:edge") + + selector["selector_intent"]["blend_sources"]["face"]["source_entity"] = { + "sketch_id": "F0", "entity_id": "missing", + } + rejected = registry.resolve(selector, active_body_id="body:f2") + self.assertEqual(rejected.status, "not_found") + self.assertEqual(rejected.diagnostic.code, "selector_source_unavailable") + + def test_blend_face_resolver_requires_one_active_generated_patch(self) -> None: + registry = TopologyRegistry() + anchor_value, cap_edge_value, patch_value = object(), object(), object() + anchor = TopologyRecord( + "anchor:F0:E0", "edge", "f1", value=anchor_value, + source_entity=("F0", "E0"), + ) + registry.register(anchor) + registry.replace_body_topology("f1", "body:f1", [ + TopologyRecord("f1:cap-edge", "edge", "f1", "body:f1", value=cap_edge_value), + ], topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation( + "generated", "edge", anchor_value, (cap_edge_value,), + source_kind="edge", result_kind="edge", derivation="boundary", + output_role="extrude.end", + ), + )), additional_predecessors=[anchor]) + registry.replace_body_topology("f2", "body:f2", [ + TopologyRecord("f2:patch", "face", "f2", "body:f2", value=patch_value), + ], topology_delta=TopologyDelta("chamfer", ( + TopologyDeltaRelation( + "generated", "edge", cap_edge_value, (patch_value,), + source_kind="edge", result_kind="face", derivation="boundary", + ), + ))) + selector = { + "kind": "face", "owner_feature_id": "f2", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("BLEND_FACE", allowed=["boundary"]), + "kind": "face", "evidence": "kernel_history", + "blend_face_source": { + "query_family": "CAP_EDGE", "owner_feature_id": "f1", + "source_entity": {"sketch_id": "F0", "entity_id": "E0"}, + "lineage_role": "extrude.end", + }, + }, + } + resolved = registry.resolve(selector, active_body_id="body:f2") + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.resolution_mode, "blend_patch_face") + self.assertEqual(resolved.record.record_id, "f2:patch") + + inactive = registry.resolve(selector, active_body_id="body:f1") + self.assertEqual(inactive.status, "not_found") + self.assertEqual(inactive.diagnostic.code, "selector_relation_non_unique") + + ambiguous = TopologyRegistry() + ambiguous.register(anchor) + ambiguous.replace_body_topology("f1", "body:f1", [ + TopologyRecord("f1:cap-edge", "edge", "f1", "body:f1", value=cap_edge_value), + ], topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation("generated", "edge", anchor_value, (cap_edge_value,), source_kind="edge", result_kind="edge", derivation="boundary", output_role="extrude.end"), + )), additional_predecessors=[anchor]) + ambiguous.replace_body_topology("f2", "body:f2", [ + TopologyRecord("f2:patch:a", "face", "f2", "body:f2", value=patch_value), + TopologyRecord("f2:patch:b", "face", "f2", "body:f2", value=object()), + ], topology_delta=TopologyDelta("chamfer", ( + TopologyDeltaRelation("generated", "edge", cap_edge_value, (patch_value, object()), source_kind="edge", result_kind="face", derivation="boundary", coverage="partial", status="unknown"), + ))) + rejected = ambiguous.resolve(selector, active_body_id="body:f2") + self.assertEqual(rejected.status, "not_found") + self.assertEqual(rejected.diagnostic.code, "selector_kernel_history_missing") + if __name__ == "__main__": unittest.main() diff --git a/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md b/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md index d4409ecf..c603feeb 100644 --- a/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md +++ b/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md @@ -86,6 +86,17 @@ feature history 生成工程相似的 STEP。 | `extrude_profile_topology:mid_cap_edge` | 2 | | `loft_profile_topology:cap_edge` / `cap_vertex` | 1 / 1 | +`extrude_surface_or_mixed` 的既有 4 条计数只描述当时的混合/闭合 wire +诊断,不是纯曲面全量覆盖计数。当前已额外实现一个受限的纯曲面 tuple: +`ToolBodyType.SURFACE` 的同一 source sketch 中明确原始非 construction 的一个或多个 `line`,或 +一个或多个 `circle`,blind/symmetric extent,且 operation 为缺省或 +`NewBodyOperationType.ADD`。line 直接扫为 ruled shell;circle 保持既有独立圆柱 shell +语义。多 line 时每个连通分量必须是由有限、互异的原始 line 构成的非分叉开链;可存在多个 +断开的开链,并分别输出 shell。普通开放 contour 不会因此获准,只有 solver 输出的显式 +source-wire witness 才能进入该 surface contract;都不创建 active solid/body-member 或 CAP/SWEPT +selector lineage。arc/ellipse/spline、construction、跨 sketch、重复/退化/闭合/分叉 line set、query +filters/combinators、REMOVE、surface-solid boolean/trim、pattern 和后续 selector/body lifecycle 仍为未完成项。 + 历史报告还记录了 1,335 个未支持操作诊断。当前已知的 P2 操作包括 `draft`、 `thicken`、`split`、`moveFace`、`deleteFace`、`replaceFace`、完整 `transform`、 `derive`、`import` 和 `bend_add`。重新扫描时必须从诊断原文生成完整操作清单; @@ -99,12 +110,17 @@ feature history 生成工程相似的 STEP。 角度、数量、终止条件和 result mode。 2. 几何输入:直线、圆弧、圆、椭圆、B-spline、开口/闭合 wire、多 region、孔洞、 退化和自交拒绝路径。 -3. 拓扑来源:原草图、`CAP_FACE`、`CAP_EDGE`、`SWEPT_FACE`、`SWEPT_EDGE`、 +3. 拓扑来源:原草图、`CAP_FACE`、`CAP_EDGE`、`CAP_VERTEX`、`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。 +5. 比较状态:strict、RP、实际几何拒绝、conversion defer、semantic validation failure、 + runtime ineligible、rebuild failure、comparison timeout 与 source exception。 + +转换阶段必须将 source parse、lowering 和 CDSL semantic validation 分别分类。语义无效的 +CDSL 不是 parse failure,也不得成为 rebuild 输入;系统应保留 history、诊断和 rejected +candidate 工件。未证明的 provenance selector 必须在 lowering 阶段以 capability diagnostic +defer,不能借 stable ID、几何、current body 或最终 STEP 形成“待 runtime 决定”的引用。 维护三个固定层级的回归集: @@ -121,6 +137,324 @@ feature history 生成工程相似的 STEP。 ## 实施路线 +### Selector 精度与覆盖目标(2026-09-10 起) + +本节是 CADFS `select` 能力的实施目标。它补充本文件的全量路线; +`FEATURESCRIPT_QUERY_CAPABILITY_MATRIX.md` 仍只记录当前已经可以执行的 +versioned allow-list,路线中的项目在具备完整证据前不得加入该矩阵。 + +目标不是让 OCC 在最终形状中找一张“看起来像”的面,而是把 FeatureScript 查询 +解释为一个有来源、集合基数和 body 生命周期约束的请求: + +```text +FeatureScript query AST + exact source/library version + -> CDSL selector_intent (set expression, source anchors, policy) + -> one ordered OCC replay + -> per-operation topology delta + output roles + body-member graph + -> resolver proves active result set, or emits one attributable diagnostic +``` + +最终 STEP 只能用于离线比较或 candidate-recovery 诊断;stable ID、几何相似度、 +current aggregate body 和最终 STEP 都不得成为 provenance selector 的生产回退。 +`all_fragments` 必须返回完整结果集,不能把集合拆成若干任意的 single-select 决策。 + +#### 目标状态 + +完成后的 selector 系统应具有下列性质: + +1. **查询是集合表达式。** CDSL 保留可递归解释的 query AST,而不只保留 + `makeQuery` 的一个 owner。它应支持 `qUnion`、`qIntersection`、 + `qSubtraction`、`qAdjacent`、`qOwnerBody`、`qBodyType`、construction filter, + 以及一般 `qCreatedBy`/created/modified/generated 关系。任何 filter 或 + combinator 缺失都必须是 deferred source query,而不是在 lowering 时静默抹去。 +2. **拓扑关系是 operation-wide N:M 图。** 每个 operation 的输入、输出、 + `preserved`、`modified`、`generated`、`deleted`、section 和 output role 都带有 + snapshot/body-member 身份、source/result kind、coverage 和 evidence。resolver + 在完整 relation component 上检查 split、merge、replacement 和 deletion,不能把 + 多个 OCC 回调误当一对一 continuation。 +3. **body 是一等 provenance 对象。** `NEW`、ADD、REMOVE、INTERSECT、keep tools、 + copy、transform、delete、mirror 和 pattern instance 都转换为显式 member graph + 转移。一个 selector 的最终结果必须同时是来源证明的后继和 consumer 指定的 active + member;不能只因它位于导出的 Compound 中而选中。 +4. **output role 可跨已证明后继传播。** `CAP_*`、`SWEPT_*`、`OFFSET_*`、 + `MID_CAP_*`、boolean section 与 sweep/loft/revolve start/end 都以 source + qualification、target kind 和 cardinality policy 解释。允许跨 feature 的前提是 + 每一段 relation 都完整、proven、且仍落在 active member。 +5. **consumer 接受集合而非单个猜测。** extent、fillet/chamfer、shell、hole、 + transform/pattern 和 reference plane 都声明所需 kind、空集/多集语义、方向与 + body scope。`up_to_surface`、`up_to_next`、`up_to_body`、`up_to_vertex`、双向 + 和 offset extent 以真正的 target body 相交或已证明 source reference 计算终止。 + +#### 实施工作包与优先级 + +工作包按依赖关系排序,但同层 family 可并行实现。优先级由全量缺口和可复用性决定, +不按某一个样本是否能重建决定。 + +| 优先级 | 通用工作包 | 需要新增或修改的 contract | 首批覆盖的 family / consumer | 当前全量影响 | +| --- | --- | --- | --- | ---: | +| S0 | Query AST 与集合语义 | versioned `selector_intent.query_expr`;显式 set kind、filter、body scope、empty/multiple policy;lowering 不再只接受 singleton `qUnion` | `qUnion`、`qIntersection`、`qSubtraction`、`qBodyType`、construction filter、`qCreatedBy` | 所有 deferred composed query | +| S0 | 通用 topology delta 与 relation component | adapter 对每个支持 builder 输出 exact input/final handles;registry 保存 N:M component、cross-kind relation、coverage/status | boolean、extrude/revolve、fillet/chamfer、shell、transform | `INTERSECT`/IMPRINT 383,CAP 335,SWEPT 115 | +| S1 | CAP/SWEPT/role propagation | `CAP_FACE`、`CAP_EDGE`、`SWEPT_FACE`、`SWEPT_EDGE`、`MID_CAP_*` 的 source anchor、role 和 all-fragments policy | boolean/dress-up 后继、revolve、sweep、loft、transform | CAP_FACE 201,CAP_EDGE 134,SWEPT_FACE 99 | +| S1 | Body lifecycle 与 COPY provenance | body-member transition graph、instance-qualified source set、active/inactive diagnostic | `SWEPT_BODY`、`qOwnerBody`、booleanBodies、COPY、mirror、circular/nested pattern | booleanBodies 187,transform 252,pattern 139 | +| S1 | Extent 与 dress-up consumers | set-valued target contract、direction and hit policy、tangent-chain policy | up-to-surface/next/body/vertex、fillet、chamfer、shell、hole host face | up-to-surface 100,up-to-next 49 | +| S2 | Datum / adjacency / geometry-derived queries | explicit datum versus topology provenance distinction;curve/surface tangent and adjacency contracts | cPlane、revolve axis、sweep path/axis、hole location | cPlane 168 与下游 consumers | +| S2 | Wider operation producers | complete deltas and output roles for sweep/loft/revolve surface, split, move/delete/replace face, thicken | all selectors produced after those operations | 由下一轮全量 capability scan 计数 | + +当前进度(2026-09-10):S0 的 source-preservation foundation 已落地: +`selector_intent.query_expr@1.0` 已将 parser 的 `qUnion` / `qIntersection` / +`qSubtraction` set 边界、`qAdjacent` / `qOwnerBody` / `qBodyType` / +construction filter,以及已知和未知 query calls 递归、版本化地写入 CDSL,schema 和 +semantic validation 共同校验其形状和 version。其上已有一个严格受限的可执行 direct set bridge: +对顶层、直接 `makeQuery` leaves,且每个 leaf 已通过现有 direct-provenance lowering path 时, +`fillet` / `chamfer` 才可 lower 为一个 `QUERY_SET` parent。它支持 +`qUnion([..])`、`qIntersection([..])` / 双参 `qIntersection(a, b)`,以及双参 +`qSubtraction(a, b)`;该 parent 显式声明 `set_kind`、`active_member` body scope、empty reject 与 +multiple all policy,且每个 child 的 typed expression 必须逐项等于 parent source leaf。 +runtime 仍要求 parent 和每个 child 通过 FeatureScript 1511 / `geometry.fs@1511.0` source gate; +resolver 递归组合 child 的 complete active records:union 按 source 顺序以 exact record ID 去重, +intersection 保留第一 operand 的顺序且只取所有 operand 共同的 exact ID,subtraction 保留左 +operand 顺序并移除右 operand 的 exact ID。每个 operand(包括 nested set parent)必须非空且完整 +proven;空集、kind/source-version 不一致、output-role 混用、inactive member 或 partial lineage +都拒绝并保留可归因 diagnostic;不会使用 stable ID、最终 STEP、current aggregate 或 geometry +fallback。`QUERY_SET` 不独立授权任何 FeatureScript topology family,所有 leaf 仍逐一经过已有的 +versioned capability gate。 + +当前 corpus 未出现实际 `qIntersection` / `qSubtraction` source call;两者的 FeatureScript 1511 +source semantics、合成 relation algebra 和由 `00000715` 改写的真实 1511 history 变体验证仅证明 +这个窄 bridge,不构成真实语料 family coverage,故不加入 capability matrix。nested union 已在 +lowering、schema、semantic validation 和 resolver 中保留递归 AST,并有合成与 `00000715` 变体 +重建证据;但 nested `qIntersection`/`qSubtraction` 尚无真实语料证据。`qAdjacent`、runtime +`qBodyType`、construction filter、general `qCreatedBy`,以及 extent/hole/transform/reference-plane +consumers 仍只有 source-preservation/deferred contract,尚未具备 filter/body-scope active-member +解释或 producer relation coverage;不得因这一 bridge 加入通用 allow-list。shell 对直接叶子 +`qUnion` 继续使用既有 flat removal-face contract,nested/intersection/subtraction shell set +仍未开放。当前聚焦验证为 selector provenance 35 项、lowering 117 项、runtime foundation +140 项(1 项既有 skip);fresh Core-17 与固定 25-sample shard 均保持既有 +strict/RP/rejected/rebuild-failure 分类。 + +`qOwnerBody` 现有一个独立、受限的 `exact_input_owner` bridge:仅在 `UP_TO_BODY` 中, +FeatureScript 1511 的 `qOwnerBody` 可包裹一个 direct-prism `SWEPT_FACE` 或 immediate +direct-builder `CAP_FACE`。lowering 必须保留 `filter: owner_body` AST、唯一 typed child、相同 +FeatureScript/library tuple 和 `active_member`/reject-empty/one-result policy;runtime 先按 child +的既有 provenance contract 解析,只有其恰返回一个 record,且该 record 的 `body_id` 与 active +aggregate 完全相等并存在唯一同 ID 的 non-transient body record 时才返回 owner。它不从 +`session.body`、producer、geometry、stable/snapshot/binding ID 或 aggregate 猜测 owner;集合、 +multi-solid member、部分/非 active lineage、later lifecycle、non-direct producer 和其它 consumer +均拒绝并保留 `selector_owner_body_*` diagnostic。合成 `00694309` 变体(以 +`qOwnerBody(SWEPT_FACE)` 取代等价 direct `SWEPT_BODY`)完成重建,但语料尚无 native call; +因此这是执行边界,不是通用 `qOwnerBody` 或 body-filter 完成项。 + +`COPY@1511` 现有一个独立、受限的 `primary_cut_cap_edge` bridge:只接受紧邻 primary +`extrude_cut_blind` 的 fillet/chamfer consumer 所使用的 direct +`COPY(CAP_EDGE)`。lowering 同时保留 outer COPY 的 typed AST、nested CAP_EDGE AST 和同一 +FeatureScript/library tuple;nested edge 必须来自该 cut 的 direct、undrafted blind prism +source profile。adapter/runtime 只接受两条连续的 complete/proven kernel relation:source +edge -> transient `extrude.start|end` cap boundary,随后是同 feature `subtract` 的一对一 +continuation 到唯一 active edge。transient tool 从不成为 selectable topology,resolver +返回 `copy_lineage`,不读取 stable ID、geometry、current body 或 source STEP。`00252195` +F4 的 selector 已解析至 `body:f_F3:edge:7`;其随后 chamfer 因 OCC feasibility 失败,故 +保留 F3 STEP checkpoint,不能误报为该模型的质量通过。`00113994` 的七个 general COPY +variants 不满足此 contract,继续以 `selector_query_unsupported` deferred。COPY face/body、 +pattern/transform copy、nested/later lifecycle、draft/two-sided/non-direct tools、多个 relation +或任何 partial/split/merge history 均不属于这条 bridge,general COPY family 仍未完成。 + +`COPY(FACE)` 现另有一个不共享上述 edge contract 的 `primary_cut_cap_face_workplane` bridge。 +它只接受 1511、紧邻 default primary `extrude_cut_blind` 的 direct undrafted blind tool,以及 outer +`COPY(FACE)` 同 owner、nested direct `CAP_FACE` 完整 OSD 等于未变 source profile 的工作平面。 +lowering 只保存 outer/nested typed query 和 local sketch data,不复用 CAP static frame;runtime 必须先以 +`extrude.start|end` 得到唯一 transient cap,再以同 owner `subtract` 的唯一 complete/proven continuation +得到一个 active planar face。只有此后才从该 exact face 的 native UV direction 和 support plane 物化右手 +workplane 并重新解析 local profile;hole 同样从该 runtime face 计算 local locations。没有 geometry、stable/ +snapshot ID、current body 或 source STEP fallback,transient tool 也永远不可选。`00573124` F3--F5(hole)和 +`00951631` F3--F5(blind cut)都以 `copy_lineage` rebuild;fresh strict pipeline +`/private/tmp/copy-face-attachment-20260913` 的两个完整结果均 strict/RP rejected,故仅为执行证据。 +`00252794` 的 F8 则在 F3 之后已有 F5/F6/F7 body mutations,不满足 immediate active-successor lifecycle,仍以 +`COPY(FACE) workplane requires a dedicated complete/proven runtime face relation` deferred 并保留 F1--F5 prefix。 +`COPY(SWEPT_FACE)` 现另有独立的 `primary_cut_swept_face_workplane` bridge:只接受 1511、同 owner、紧邻 default direct undrafted `extrude_cut_blind` 的一个 original source-profile edge。runtime 仅接受该 edge anchor 到唯一 transient prism side face 的完整/proven relation,再接受同 owner subtract 到唯一 active planar face 的完整/proven continuation;随后才从此 exact face 物化 sketch frame。当前 9,347-history fresh lowering materializes 12 个此类 attachment hosts;这只是 contract-shape 覆盖,不能代表 runtime 或 comparison 通过。`00321940` F3--F5 已完整 rebuild,但 fresh comparison 是 strict/RP rejected;`00171671` F3--F4 完成 attachment lowering 但 F5 的独立 selector 仍 deferred,均仅为 execution/lowering evidence。`00326645` 的相同 source form 因没有唯一 subtract continuation 在 runtime 明确拒绝,证明不能将 source edge、静态 frame 或任何近似面当作 successor。two-sided/IMPRINT/partial/non-direct profile、multiple source edges、non-immediate lifecycle、nonplanar/split/merge/inactive successor、COPY body/pattern/transform、其它版本及 geometry/stable/snapshot/current-body/source STEP fallback 均不属于该 bridge;general COPY family 仍未完成。 +COPY `SWEPT_FACE`/body、pattern/transform、draft/two-sided/non-direct tools、partial/split/merge/nonplanar/ +multi-member relation、oppositeDirection attached hole、其它版本和 non-immediate lifecycle 都不属于此 bridge; +general COPY family 仍未完成。 + +direct-prism `CAP_*` / `SWEPT_*` source anchor 现按 selector 明示的 producer owner 限定: +后续 feature 即使用同一 source sketch 再次 materialize 相同 source entity 或 endpoint pair, +也不是该 producer 的第二个 anchor。resolver 只从该 owner 的 source anchor 出发,再检查 +complete/proven relation 与 active result;缺少 owner 直接产生 `selector_source_unavailable`, +不会因跨 feature source-label 重复变成 ambiguity 或选择较新的 producer。`00407468` F3 的 +四个 `qUnion(SWEPT_EDGE)` children 因而各自从 F1 anchor 经 F2 的一对一 continuation 解析; +此规则只修复 direct source-anchor identity,不扩大 generic SWEPT/CAP、boolean/IMPRINT/COPY/ +pattern 或 N:M lineage coverage。fresh `output/swept-edge-producer-anchor-00407468-20260912` +中 F1--F3 均执行,四个 child 返回 F2 `edge:19/22/25/28`,RP engineering comparison +通过;strict 仍因面积与体积精度检查失败,不能以此标记 general family 完成。 + +`CAP_EDGE@2491` 现有一个独立的 `symmetric_direct_prism` bridge:只接受紧邻 +fillet/chamfer 的、无 draft、独立 `new_body`、两侧均 blind 且 profile 完整等于 original +source sketch 的 `extrude_add_two_sided`。executor 为两个 `BRepPrimAPI_MakePrism` 保存 +独立 topology delta;primary extent 的 final `LastShape(source_edge)` 是 +`extrude.end`,reverse extent 的 final `LastShape(source_edge)` 被明确映射为 +`extrude.start`,source-plane `FirstShape` 接缝从不作为 cap 结果。每条 role relation 仍需 +在 fused final snapshot 上以 exact `IsSame` 成立,resolver 只接受 immediate `boundary`,不允许 +continuation、stable ID、geometry、current body 或 STEP fallback。`00049094` F2 的四项 +CAP_EDGE query set 全部解析并使 F1--F8 rebuild;其完整 comparison 为 rejected,不能宣称 +RP/strict。`00404726` 的多区域/IMPRINT profile 不满足完整 direct-profile contract,仍在 F2 +`selector_query_unsupported` 并保留 F1 checkpoint。其它 source/library versions、CUT、draft、 +非 blind、partial/multi-region/IMPRINT、fused、boolean/COPY/pattern 和任何 later lifecycle +都不在该 bridge 内,general CAP_EDGE family 仍未完成。 + +`CAP_EDGE@1511` 现有一个更窄的 `primary_add_union_continuation` bridge,作为 S1 +body-lifecycle 的局部验证,而不是 general ADD 或 CAP_EDGE 完成项。producer 必须是默认 +ADD/fuse 的 direct、undrafted blind `extrude_add_blind`;旧 active body 和 tool 都必须恰为 +一个 solid,tool 必须具有 complete/proven direct-prism delta 和 source anchors。executor 先把 +tool 登记为 transient prism snapshot,再且仅在 `BRepAlgoAPI_Fuse` 返回 exact topology history +时登记它到 active union result 的 continuation;tool 本身不能成为可选拓扑。lowering 只对 +1511 `CAP_EDGE` immediate fillet/chamfer 写 `boundary + continuation`,resolver 要求完整的 +`extrude -> union` 一对一链和 active member。`00953397` F7、`00957101` F4 均由 +`kernel_lineage` 解析并在 fresh pipeline 中 RP approximate;`00406939` 的 multi-solid tool +没有唯一 lifecycle proof,保持 deferred。split/merge、multi-solid、IMPRINT/partial profile、 +missing/partial/fuzzy union、非 CAP_EDGE family、later lifecycle、COPY/pattern 以及 geometry、 +stable-ID、current-body、source STEP fallback 一律不在范围内。这条 bridge 不完成 S1 body graph、 +general ADD 或 general CAP_EDGE。 + +`CAP_FACE@1511` 另有同一 lifecycle 的 `primary_add_shell_union_continuation` bridge, +但 consumer 严格限定为紧邻 shell 的一个 removal face。producer 仍必须是 default ADD/fuse 的 +direct、undrafted blind `extrude_add_blind`,旧 active body 与 tool 都为单一 solid;tool 只作为 +transient prism snapshot 存在,只有 exact `BRepAlgoAPI_Fuse` history 能把其 cap role 以完整 +proven、一对一 `extrude -> union` relation 交给 active final member。27 个真实 history 的 fresh +scan 中只有 7 个具备这个 relation;`00074047` F4 与 `00350698` F7 由 `operation_role` 解析后分别 +进入 native shell,再因 OCC shell feasibility 失败,说明 selector 成功不等于模型质量成功。 +`00293014` 的一对多 output role 保持 `selector_output_role_ambiguous`,`00590599` multi-solid ADD +保持 `selector_query_unsupported`。它不开放 extent、dress-up、多 removal、split/merge、 +multi-solid/IMPRINT/partial profile、missing/partial/fuzzy union、later lifecycle、COPY/pattern 或 +任何 geometry、stable-ID、current-body、source STEP fallback;不完成 S1 body graph、general ADD、 +CAP_FACE 或 shell。 + +`CAP_FACE@1511` 还具备独立的 `primary_add_up_to_surface_union_continuation` bridge: +仅单向 `up_to_surface` extent 可消费紧邻 default ADD/fuse、undrafted blind +`extrude_add_blind` 的一个 cap role。lowering 将这条消费意图显式标成该 contract;runtime +要求一个 old active solid、一个 transient prism tool、exact `BRepAlgoAPI_Fuse` history,且 +cap 至 active member 的 `extrude -> union` relation 必须 complete/proven 且一对一。 +`00007973` F5 -> F7 fresh rebuild 通过 `operation_role` 解析并完成重建;`00638700` F3 -> F5 +在一对多 union successor 上保持 `selector_output_role_ambiguous`,证明 role 不会因同名 cap +或几何相近而被任选。双向 extent、非紧邻 producer、split/merge/multi-solid/IMPRINT 或 +partial/fuzzy union、draft、later lifecycle、其它 consumer,以及 geometry、stable-ID、 +current-body、source STEP fallback 都不在范围内;这不完成 general `CAP_FACE`、general ADD、 +general `up_to_surface` 或 S1 body graph。 + +`CAP_FACE@1511` 的 `primary_add_dressup_union_continuation` bridge 允许同一 strict primary +ADD cap contract 的紧邻 `fillet` 或 `chamfer` consumer。face 先必须通过 complete/proven、一对一 +`extrude -> union` relation 解析到 active member;dress-up executor 随后只展开这个已解析物理 +face 的真实 body-boundary edges,周期 seam 不会被当作边界。`00051494` F3 -> F4 以 +`operation_role` 解析并完整 rebuild;`00660816` F3 -> F4 的一对多 union successor 保持 +`selector_output_role_ambiguous` 与 F3 prefix。非紧邻 consumer、shell/extent 以外的其他操作、 +多个 CAP face、split/merge/multi-solid/IMPRINT/partial profile、draft、later lifecycle,以及 +geometry、stable-ID、current-body、source STEP 或 transient-tool fallback 均不开放;它不完成 +general CAP_FACE、general ADD、fillet/chamfer 或 S1 body graph。 + +`CAP_FACE@1511` 另有一个 independent `symmetric_circle_shell` bridge:只接受紧邻 +shell 的独立、无 draft、`new_body`、两端 blind 的 `extrude_add_two_sided`,且完整 source +profile 恰为一个原始 circle,两个 CAP OSD 也必须各自只指向这个同一 source edge。与双向 +CAP_EDGE 一样,两个 prism builder 在 source plane 的 `FirstShape` 都是内部 seam;executor +只将 primary/reverse 的 far final handles 分别注册为 `extrude.end`/`extrude.start`,并以 final +snapshot `IsSame` 证明。lowering、semantic validation 与 capability preflight 均只允许该 role +作为 immediate shell removal 的 `boundary`,不开放 extent、dress-up、continuation 或其它 consumer。 +`00019252` F1->F2 的双 cap shell fresh RP comparison 通过(strict 仍不通过);独立的 +`00000316` F2->F3 同样解析两端并执行 shell,之后才在无关 F6 `OFFSET_EDGE` query 停止,保留 +F3 prefix。multi-edge/hole/IMPRINT/split profile、draft、ADD/CUT、非 blind、later lifecycle、其它 +version 及任何 stable-ID、geometry、current-body/source-STEP fallback 继续拒绝。这只是 CAP_FACE +的窄 consumer bridge,S1 CAP family 与双向 generic output-role propagation 仍未完成。 + +S0 topology-delta 的一个受限 producer bridge 现已落地:对同一 workplane 的多区域 +`planar_imprint`,仅在无 draft、单向 blind prism 的情况下,adapter 为每个区域保留 +`BRepPrimAPI_MakePrism` 的 direct output,再以一个 `BRepAlgoAPI_Fuse` history 将每一条 +intermediate handle 映射到最终 snapshot。registry 只接收原 profile anchor 到最终 +`CAP_*` / `SWEPT_*` handle 的 composed relation;fuse 删除、未返回或不在最终 B-rep 的 +handle 必须以 `coverage: partial` / `status: unknown` 保留,不能被 final geometry、stable ID +或邻近 shape 补齐。它使已完整存活的 IMPRINT side/cap/edge lineage 可供现有 set-valued +fillet/chamfer consumer 使用,但不把被 fuse 删除的 seam、complex/trimmed/multi-extent/drafted +profile、不同 sketch,或之后的 boolean/COPY/pattern 变成已支持 selector。该 bridge 不改变 +query allow-list,也不表示通用 IMPRINT/SWEPT 完成。 + +2026-09-12 的通用 builder-history 修正补齐了 dress-up 的 cross-kind 事实: +`BRepFilletAPI_MakeFillet` / `BRepFilletAPI_MakeChamfer.Generated(inputEdge)` 的实际 +OCC 类型按最终 B-rep 成员分组记录。因此输入 `edge` 生成的 final patch `face` 以 +complete/proven `edge -> face` relation 保留,不再被错误标为 edge 或静默丢弃;非最终、 +部分或未知生成物仍显式标记 partial/unknown。该事实目前只供诊断和后续通用 component +设计使用。受限 `BLEND_EDGE` contract 也不能从 patch face 的任意 boundary edge 推断 +source `blendedInto` face;它必须同时取得下文规定的 edge/face source pair 和 exact relation。 + +同一 adapter 现进一步记录 exact `source edge + source face + generated patch face -> final +shared edge` transition:只有 patch face 的 final boundary edge 与唯一 final +`Modified(source_face)` 的 boundary edge 通过 `IsSame`,且 source edge、source face、patch +face、modified target face 和结果边均为唯一 complete/proven history 时,该 relation 才标记为 +`exact_blend_boundary`。它与普通单源 lineage 分开保存, +不会改变 owner transfer 或现有 selector 的 cardinality。该内核证据现已接入一个受限 +`BLEND_EDGE@1511` contract:outer query 必须是一个 direct `CAP_EDGE`,以及同一 direct source +prism 的完整 `CAP_FACE`,或与该 cap-edge 共用同一 source-profile edge 的 `SWEPT_FACE`,作为 +`blendedFrom` 和同语义的唯一 `blendedInto`;producer 为独立、无 draft、`new_body` blind prism, +consumer 必须紧邻 native fillet/chamfer。lowering 将 source pair 写为 `blend_sources`,resolver 同时 +验证 cap-edge anchor、cap-face role 或 exact `Generated(source-edge -> swept-face)` relation,及唯一 +active `exact_blend_boundary` relation。它仍不是 family completion:`SWEPT_*`、`MERGE`、`SPLIT`、 +`COPY`、`BLEND_FACE`、multi-set、非 immediate lifecycle、angle/two-offset dress-up、其他版本或 +producer 都保持 deferred,且没有 geometry、stable-ID、current-body 或 source STEP fallback。 + +S0 是降低 `selector_query_unsupported` 的最快路径,但只应将 source query +转换为可执行 contract;若下游 producer 没有完整 OCC relation,结果仍必须是 +`selector_kernel_history_missing`,不得降级为 geometry binding。S1 旨在让已存在的 +direct-prism 特例变成由同一 lineage rule 支撑的普遍能力,而不是继续增加 +“immediate `new_body`”分支。 + +#### 分层改造边界 + +| 层 | 责任 | 不允许承担的工作 | +| --- | --- | --- | +| `cadfs_to_cdsl/lowering.py` | 解析并保留完整 query AST、source version/import、source sketch/entity anchors、consumer policy | 根据最终 STEP、样本 ID、OCC 面号或几何评分填充 selector | +| CDSL schema / semantic validation | 校验 expression 类型、kind、基数、owner/body scope、consumer 集合要求和版本化 contract | 决定“哪个候选长得最像”或替换 source 语义 | +| `build123d_adapter.py` | 从一个 OCC builder,或同一次 feature replay 内显式闭合至最终 snapshot 的 builder chain,捕获 exact handles、relation、section edge、output role 和 coverage | 从不同 replay、无 exact final-history bridge 的 builder,或 geometry fingerprint 拼接 lineage | +| runtime body graph | 原子 feature 的 member create/mutate/copy/delete/activate 转移;保留 tool 生命周期 | 用 current aggregate body 补全来源不明的 query | +| `TopologyRegistry.resolve` | 解释 AST、计算完整 relation component、验证 policy/cardinality/active body,并返回 record set 与 evidence | 对 provenance selector 走 stable-ID 或 geometry fallback | +| offline candidate recovery | 排序假设、搜索分支、以 strict STEP 判断唯一行为候选 | 修改 production resolver 的证明标准 | + +#### Query family 的推广顺序 + +1. 先将目前 parser 已记录的 composed query 规范化为不丢失顺序和集合边界的 AST; + 对 `qUnion` 先实现集合并集,再实现与 `qIntersection`/`qSubtraction` 的交叉,最后 + 接入 `qAdjacent` 和 owner/body filters。每个 operator 需要明确空集、重复项、kind + 不匹配、不同 body 和 construction entity 的语义。 +2. 将 `qCreatedBy`、CAP、SWEPT、OFFSET、INTERSECT 统一为“source anchor + relation + predicate + result-set policy”,而不是由 family 名称分散处理。保留 family-specific + source semantics,但共用 cardinality、coverage、body-member 和 version gates。 +3. 对每种 producer 先实现完整 delta,再开放其 selector 后继:direct boolean 与 + fillet/chamfer/shell 优先;随后 revolve、sweep、loft、transform;最后 COPY/pattern、 + split 和 face-edit operations。没有 delta 的 executable fallback 可以保留可执行模型, + 但必须截断 provenance propagation。 +4. 对每个 consumer 明确要求:例如 `fillet` 可以消费 edge set,`shell` 可以消费 + removal-face set,`up_to_surface` 必须能定义同方向首个有效 hit,`up_to_body` 只能 + 接受 body-member set,`up_to_vertex` 必须处理投影距离是否一致。consumer 不得以 + “取第一个 record”替代集合语义。 + +#### 快速推进与验收方式 + +“快”指按能力族推进并批量验证,不指围绕单个失败模型反复改 selector: + +1. 每个工作包先交付 schema/lowering/adapter/resolver/consumer 的最小纵向 contract, + 并以合成原子矩阵覆盖一对一、split、merge、deleted、inactive、empty 和 multiple。 +2. 然后从 capability matrix 为该 tuple 自动抽取至少三个不同几何/生命周期的真实样本, + 不以其中一个样本的 STEP 量测指导实现;再运行受影响核心 17、扩展矩阵和可控全量 shard。 +3. 一次 batch 只报告能力级指标:已解析 selector 数、按 diagnostic code 的失败数、可执行 + prefix 数、strict/RP 分类和未覆盖 tuple。单样本只作为可复现证据,不作为优先级或 + 行为分支。 +4. 每个合并的 family 更新当前 allow-list、能力矩阵和 + `ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`。只有真实 FeatureScript source contract、 + CDSL contract、exact kernel evidence、consumer 执行和多样本回归齐备,才将 status + 从“部分完成”改为“完成”。 + +下列失败码是能力推进的正常信号,不能用其它层掩盖: +`selector_query_unsupported` 表示 AST/family/consumer 未实现; +`selector_query_version_unknown` 表示来源语义缺证据; +`selector_kernel_history_missing` 表示 producer delta 不完整; +`selector_relation_non_unique` 表示 policy 尚不能表达真实 N:M 关系; +`selector_body_member_inactive` 表示 body lifecycle 尚未覆盖。每一种都必须保留最后 +可执行 STEP/GLB/checkpoint,并进入下一次 capability scan,而不是重试同一模型直到偶然成功。 + ### 开源实现策略 每个新的内核级能力在自研前,先检查官方 OCCT/OCP API、项目现有依赖和成熟开源实现。 @@ -164,7 +498,14 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 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 以 + body member。一个单源链式 COPY 的 source query 可继续保留最初 `SWEPT_BODY` 的 + FeatureScript owner,而实际执行成员已是前一个明确 COPY output;此时 lowering 只在 + `make_copy:true`、一个 direct source、一个 recursive `derivedFrom` 链和一个 preceding + active member 都成立时写入 `{source_feature_id, active_member_feature_id}` + `source_member_aliases`。它是 source provenance metadata:运行时仍且只能从 + `source_feature_ids` 选择 body member,semantic validation 要求 alias member 与该唯一 + source 完全相同,绝不把 alias 解释为 current-body/aggregate 选择。对于前序 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 都要求 @@ -199,9 +540,13 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 不引用 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,继续保留明确诊断。 + strict 仍因 max/p99 surface `0.01 mm` 与体积/面积阈值失败而保留为诊断。通常 copy、boolean、 + pattern 与 delete 均清空 sole-body successor lineage;这不阻止前述受限的 direct + single-source transform COPY provenance metadata。`00184423` 的 F3--F5 与 `00322866` + 的多层链均由原始 owner 到每一层 explicit active member 记录 alias,强制 RP pipeline + 分别为 `rebuilt_strict` 与 `rebuilt_approximate`。multi-source/aggregate COPY、pattern 或 + boolean/deletion 后继、非-`makeCopy` transform、缺失或不匹配 member,以及任何 selector + topology propagation 仍属未完成 contract,继续保留明确诊断。 `SWEPT_BODY@1511` 另有一条不能与上述 successor lineage 混同的即时终止 contract:只有 singleton `qUnion([makeQuery(..., SWEPT_BODY, EntityType.BODY)])` 引用紧邻前序独立、无 draft、 `new_body` blind prism,且其 producer body record 仍唯一 active 时,单侧 `up_to_body` 才能 @@ -231,8 +576,17 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 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、 + 任意嵌套 CAP query 替代。它仍不是可 lower 的通用 CAP/SWEPT/OFFSET query。对于 primary + `REMOVE` 的一个 solid tool 和一个含多个独立 solid 的 active target,adapter 现在逐 target + 调用 `BRepAlgoAPI_Cut(SetToFillHistory)`,仅拼接每个 builder 的 exact relation,并以 + `per_member_exact_cut_history` 标记组合 delta;它不按 member index、aggregate/current body + 或几何相近性建立跨成员 relation。未相交或未修改的成员仍只依赖 body graph 的 exact + equivalence;任何 tool 多实体、member 间 relation、N:M section/CAP/SWEPT propagation 或 selector + consumer 仍不由该 delta 开放。真实 `00423838` 的 F4 已有 exact circular instance owner,先前缺口 + 是 F6 六个成员主切除丢失 builder history;fresh + `/private/tmp/circular-instance-owner-20260913-member-delta-final` 现在在 F6 记录 281 条 + exact relation 并保留六实体 checkpoint,但 F7 的 outer `INTERSECT(VERTEX)` 仍为 + `selector_query_unsupported`。多实体 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 上,会记录 @@ -280,6 +634,21 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 primary query 的未核实 `disambiguationData` inputs 均不执行。 这不构成通用 INTERSECT coverage,真实语料 RP 矩阵仍待补齐。这些记录是通用 topology 事实,不是由 stable ID 或最终几何相似性反推的替代品。 + +2026-09-13 的同心圆 IMPRINT profile source-identity 修复补齐了一条前置事实:当多个 +bounded IMPRINT region 的 union 唯一物化为一个圆盘时,materializer 现在只在该圆盘的 +outer boundary 恰对应一条唯一 source circle 时保留 `source_entity_id`。例如 `00423838` F6 +由 E4 两侧区域的 union 物化为 E5 外圆;lowered `sketch_F5__f_F6` 因而明确携带 E5, +而非以半径或 final geometry 猜测它。环域、非圆 profile、重复 source boundary 和任何不唯一 +mapping 仍不标记 source identity。该项只恢复 materialized profile 到其实际原始边界的 source +contract;adapter 将 direct `profile.type: circle` 的显式 `source_entity_id` 原样转为 exact circle-wire +anchor,而不再错误读取通用 `sketch.entities`,也不以半径或圆心反推 identity。F6 的 multi-member +primary cut 因而可在 direct-prism history、tool singleton 与完整 source anchor 都具备时登记 +`transient:f_F6` tool snapshot;它是不可选择的历史输入,不改变六个 target member 的 +`per_member_exact_cut_history`,也不使任何 multi-member selector 自动可用。仍没有 OCC section-edge +或 complete source-face-set -> vertex relation,因此 F7 `INTERSECT(VERTEX)` 保持 +`selector_query_unsupported`。这不扩展 general IMPRINT、SWEPT_FACE、primary-cut section 或 +pattern/COPY selector capability,也不允许 geometry、stable ID、current body 或 source STEP fallback。 3. 建立统一 selector resolver,并让 runtime 与诊断性的 prefix binder 共用 `TopologyRegistry.resolve` 及已导出的 topology facts。resolver 按“语义 source -> kernel lineage -> derivation policy/cardinality -> active body member”顺序绑定,报告实际分支使用的 @@ -300,7 +669,8 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 version/direct standard-library revision 通过显式 allow-list 注册,不得从 `15xx` 数字前缀、相同 import path 或单一样本推断兼容性。当前 `FEATURESCRIPT_QUERY_CAPABILITY_MATRIX.md` 将 FeatureScript `1511` 的受限 - `CAP_FACE`、`OFFSET_FACE` 以及 direct-prism `SWEPT_FACE`/`SWEPT_EDGE` contract 标为 + `CAP_FACE`、`OFFSET_FACE`、direct-prism `SWEPT_FACE`/`SWEPT_EDGE` contract,以及受限 + `1511` / `2491` full-solid-revolve `SWEPT_EDGE` contract 标为 端到端可执行。后两者只适用于紧邻独立、无 draft、`new_body` blind prism 的 consumer: adapter 必须保留 direct source-profile edge/vertex construction handle,并分别由 `BRepPrimAPI_MakePrism.Generated(edge)` 的 edge -> side-face 或 @@ -328,6 +698,79 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 `rebuild_failed`、1 `comparison_timeout`;其中 `00212904` 的分类由 selector failure 变为 executable-but-rejected,不能将此记为几何相似通过。它们不允许在 producer 后续 mutation、 non-immediate owner、stable ID、binding ID 或 geometry fallback 中继续解析; + `CAP_FACE@1511` 现还有一个单独的 initial-direct-loft shell bridge:producer 必须是首个 + 实体结果、恰好两个 direct closed-IMPRINT sheet profile、default/`NEW` operation,且没有 + wire profile、connection/match 或端点 derivative 参数。lowerer 将这组 source profile 顺序写为 + `cap_output_profile_sources`;CAP query 的 OSD 必须唯一命中其中一个 profile,再映射到 adapter 的 + `BRepOffsetAPI_ThruSections.FirstShape/LastShape` `loft.start`/`loft.end` relation。`isStart` 不 + 决定 OCC role,因此不会把 source 的 cap-direction 名称误当作 wire order。仅紧邻 `shell` 能消费该 + role,semantic/preflight/registry 都要求唯一 complete/proven active boundary relation,且 selector + 不带 stable ID、binding ID、geometry 或 snapshot fallback。fresh + `output/loft-cap-shell-20260912` 中 `00023963`、`00051031`、`00059941` 均以 `operation_role` + 解析并执行 shell,但 RP comparison rejected;`00157619`、`00139197` 均在 selector 成功后由 native + OCC thick-solid 拒绝。`00330207` 的非 initial loft 和 `00842069` 的 1549 source 均保留 deferred + selector;这不是 general loft/CAP_FACE 或 shell completion。 + `CAP_FACE@1511` 另有一个 initial-direct-sweep shell bridge:仅独立 `new_body` + direct PipeShell sweep 记录 `initial_output_roles` 及 profile edge/path edge 的 + source contract。CAP OSD 必须精确含该 profile edge 和 source path 的对应 + `start`/`end` vertex;lowering 记录 path 是否为 profile attachment 而反向,随后将 + source endpoint 映射为物理 `sweep.start`/`sweep.end`,resolver 仍只接受唯一 + complete/proven active `FirstShape`/`LastShape` relation。`00330012` F3 以 + `sweep.end` 解析并完整 rebuild;`00658358` F4 的 source pair 不匹配而保持 + `selector_query_unsupported` 和 F3 checkpoint。该 bridge 不开放 segmented/spatial/ + hollow/fused/additive sweep、COPY/pattern/later lifecycle、non-shell consumer、其他版本 + 或 general sweep CAP 语义,且绝无 geometry、stable-ID、current-body 或 source STEP fallback。 + `CAP_EDGE@1511` 另有独立的 immediate direct-sweep dress-up bridge:PipeShell 不提供 + `FirstShape(source_edge)` / `LastShape(source_edge)` 重载,因此只接受一个 retained direct + circle profile edge、一个 direct source path edge、以及匹配 `start`/`end` endpoint 的完整 OSD。 + adapter 仅在 `FirstShape`/`LastShape` cap face 恰有一条 boundary edge 且该 edge 以 `IsSame` + 属于 final solid 时,记录 profile edge -> role-qualified `sweep.start`/`sweep.end` boundary + relation;executor 把同一 profile edge 注册为 transient source anchor。lowering、semantic + validation 和 preflight 共同限制 consumer 为紧邻的 `fillet`/`chamfer`。`00330012` 的真实 + source CAP_EDGE 变体经 `kernel_lineage` resolve 并 rebuilt;端点 `isStart` 语义不一致的 + variant 保留 F2 checkpoint。多 edge/inner-wire/split profile、segmented/spatial/hollow/fused/ + additive sweep、continuation、COPY/pattern/later lifecycle、非 dress-up consumer 与 generic + sweep CAP_EDGE 都继续 deferred;不得把 cap face 的任意边、几何、stable ID、current body 或 + source STEP 当成 edge lineage。 + `SWEPT_FACE@1511` 也新增同一 initial direct-PipeShell 的独立 bridge:PipeShell + `Generated(profile_edge)` 的 final `FACE` outputs 逐一通过 solid `IsSame` membership,作为 + profile edge -> side face 的 complete/proven `boundary` relation。当前接受一个完整、直接、无 inner + wire 的 analytic closed profile(每条 source edge 必须在 contract 内唯一出现)和单一 direct source + path edge 的 exact OSD pair,且仅紧邻 `fillet`/`chamfer` 能消费;path source 只在 intent + disambiguation 中保留,运行时 anchor 始终是被选的 profile edge。`00330012` 的 circle source variant + 及其 four-edge direct-profile source-form variant 都以 `kernel_lineage` resolve 并 rebuilt;profile/path + pair 不匹配保留无 geometry/stable-ID 的 deferred query。真实 `00954785` 的 multi-segment qUnion path + 不得借其 representative leaf 进入此 contract。inner/split profile、segmented/spatial/hollow/fused/additive + sweep、continuation、COPY/pattern/ + later lifecycle、shell/extent/sketch-host 等其他 consumers、其他版本与 generic sweep side-face + semantics 均未完成。 + `sweep` 的 source-wire path 现在还接受同一 source sketch 的直接 + `qUnion([sQuery(EDGE), ...])`。每一个 leaf 必须是原始、非 construction 的 line、arc 或 + 非周期 B-spline,不能跨 sketch、重复实体,也不能是 nested/filter/derived query;全部 source + endpoint 必须唯一组成一个开放、连通、无分支的 wire。lowering 以 endpoint 拓扑排列完整 segment + 集合并保留精确圆弧方向,绝不允许 parser 的末尾 representative leaf 替代该集合。`00684140`、 + `00687707`、`00293516`、`00860314` 的 line/arc path 都已在 runtime 无诊断 rebuild。closed、 + disconnected、branched、construction、跨 sketch、重复 leaf、runtime/derived path 仍拒绝。这只扩展 + source wire path 的显式选择,不生成 CAP/SWEPT output lineage,也不构成一般 sweep/query 完成。 + `SWEPT_EDGE@1511` 另有独立的 initial direct-PipeShell bridge:PipeShell + `Generated(profile_vertex)` 的 final `EDGE` outputs 逐一通过 solid `IsSame` membership,作为 + profile vertex -> swept edge 的 complete/proven `boundary` relation。仅完整、直接、无 inner wire 的 + analytic closed profile 可记录所有唯一 source edge;SWEPT_EDGE OSD 必须恰含两条相邻 profile edge + 和一条 direct source path edge,前两者作为 runtime vertex anchor,path 仅保留在 disambiguation。 + lowerer、schema、semantic/preflight 均限制为 independent `new_body`、紧邻 `fillet`/`chamfer`,并拒绝 + non-adjacent pair、circle/inner/split profile、multi-segment/spatial/hollow/fused/additive path、continuation、 + COPY/pattern/later lifecycle 和所有其他 consumers。`00330012` 的 four-edge/one-line source-form variant + 以 `kernel_lineage` resolve 并 rebuilt;现有全量语料的 12 条 sweep SWEPT_EDGE history 没有一条满足 + 该 strict direct contract,故这不是 real-corpus coverage 或 general sweep completion,且绝无 geometry、 + stable-ID、current-body 或 source STEP fallback。 + 1511 direct-prism `SWEPT_EDGE` dress-up consumer no longer treats feature order as + topology proof: lowering can retain `boundary + continuation`, but the registry must + still trace the same source vertex through complete/proven one-to-one relations into + the active member. `00094474` F3 is a strict real-corpus positive after an intervening + datum feature. `00370320` F4 proves the same source edge through its F3 subtract + continuation and is RP passing. `00027017` F3 finds a non-unique post-fillet relation and is rejected + with its F2 checkpoint. Split/merge/deletion/partial history and IMPRINT, draft, + fused, multi/two-sided, boolean, sweep/loft/COPY/pattern producers remain deferred. direct-prism `SWEPT_FACE` 也可作为单侧 `up_to_surface` reference,但它不是 output role:lowering 只保留一个 direct source-profile edge anchor,resolver 必须逐段验证 `boundary` 以及后续 `continuation` 的 complete/proven kernel relation、operation-wide cardinality 和 active body。 @@ -348,6 +791,37 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 shared runtime 回归分类证据,不把非 strict/RP 样本重述为 selector success。IMPRINT/split source profile、draft、多/two-sided extent、fused source result、未证明或 split continuation、boolean/SPLIT 后继、revolve、 sweep、loft、copy/pattern 及一般 SWEPT query 仍未完成,绝不能标记为 query-family 完成。 + 2026-09-14 的 `immediate_retained_source_prism_swept_face_up_to_surface` 是此处单侧 extent 的另一条 + 独立、非 continuation contract:producer 必须是紧邻的 1511 independent、undrafted、blind `new_body` + `extrude_add_blind`。它允许 IMPRINT profile 没有保留完整 source sketch,但 OSD 只能指向一个在 selected + profile 与原 source sketch 中均恰好出现一次、workplane 与完整 segment 完全相同的 original edge;selector + 仅允许 `boundary`、不带 output role/stable ID/geometry/binding ID,也只能作为下一 feature 的 + one-sided `up_to_surface` reference。semantic validation 与 capability preflight 重复检查 producer、source + import、精确 source edge 和即时 owner;runtime 仍只接受 adapter 已登记的 exact `Generated(source edge -> face)` + final-snapshot relation。`00408613` F1 -> F3 以 `F0/E0.bottom` 由 `kernel_lineage` 解析并执行;F4 仍在既有 + `CAP_EDGE` query 停止,F3 checkpoint STEP 被保留。把 source entity 篡改为被 IMPRINT 排除的 `E1` 会同时被 + semantic validation 与 runtime preflight 拒绝。该条不授权任意 IMPRINT region、多个/changed/split edge、draft、 + ADD/CUT/two-sided、later continuation、其他 consumer/version,或任何 geometry/stable-ID/current-body/STEP fallback。 + 2026-09-13 增加的 `symmetric_direct_prism_shell_swept_face_up_to_surface_pair` 是另一条严格的双侧 extent + tuple:1511 direct undrafted `new_body` blind prism 之后必须紧邻一个只依赖该 prism 的 shell;两条 + `SWEPT_FACE` query 必须来自同一 producer 的两个不同原始、非 construction line,且 shell 不能移除任一 + target side。runtime 仍逐条证明 `edge -> prism side -> shell.offset_face` 的 complete/proven relation;不接受 + `shell.closing_descendant`、partial/split/merge、其他 lifecycle 或任何 geometry/stable-ID/current-body/STEP + fallback。`00180262` F1 -> F2 -> F4 的两个 target 都以 `kernel_lineage` resolve 并执行,且新的 planar + supporting-surface extent 仅在有限 planar target 对全部 profile ray 都无 hit、但支持平面给出唯一正向等距时使用; + partial finite-face hit 仍走已有 trimmed-solid 语义。该 full rebuild 的 RP comparison 是 rejected,故这是 + executable selector/extent evidence,不是相似性或 general two-sided `up_to_surface` completion。 + `SWEPT_FACE` 还有一个与 extent 分离的 shell-removal continuation tuple:1511 的 independent、undrafted、 + direct-source blind prism 后可有且仅有一个直接依赖它的 blind `extrude_cut_blind`,紧邻 shell 才可继续消费 + prism 的一个 original non-construction line side face。runtime 必须先证明 source edge 到 prism side face 的 + complete boundary,再证明 cut 的 target-side `BRepAlgoAPI_Cut` 将同一 face 一对一、complete/proven 地继续到 + active member;shell 不可从 tool face、closing descendant、面几何、stable ID、current body 或 source STEP + 取代其中任一环。`00020670` F1 -> F3 -> F4 是当前唯一的 native positive,F4 由两段 + `kernel_lineage` relation 执行,完整 history 随后才在既有 F10 selector 停止并保留 F8 STEP checkpoint。 + source-wide 9,347-history scan 的另外五条表面相似 form 分别是 immediate case、pattern COPY、two-sided 或 + non-direct-profile/lifecycle rejection,故这是 bounded execution evidence,不是 general shell、general + SWEPT_FACE 或 RP acceptance;ADD、boolean、dress-up、COPY/pattern、multi-step、split/merge/deleted/inactive + continuation、revolve/sweep/loft、IMPRINT/partial profile 和其它版本均继续 deferred。 CADFS production rebuild 以一个 `IncrementalCdslExecution` 按 history 顺序执行:每个 feature 在执行前只对本 session 已登记的 topology snapshot 调用 resolver,随后立即执行并登记新的 body/history evidence。`binding_feature_id` 读取同一 session 保留的历史 snapshot,不能通过 @@ -365,6 +839,23 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 entity 都带两端导数;当前 10 条历史反例矩阵的 9 个可生成候选保留了 18 条该曲线。 该增量只完成此一 profile contract,不覆盖由 `INTERSECT`、`CAP/SWEPT` 等拓扑查询 派生的 profile,也不把后续 dress-up 或内核失败误记为 spline 成功。 + 2026-09-14 增加了一个独立的同平面多来源 profile contract:一个 extrude 的顶层 + `qUnion`(可含只由 `qUnion` 组成的关联嵌套包装)仅当每个 leaf 都是 `qSketchRegion(source, true)`、至少两个 source sketch + 都已在 executable prefix 中、均无 runtime attachment、且其 `origin_mm` / `x_dir` / + `normal` 完全一致时,才 lower 为 `multi_source_regions`。CDSL 保留每个完整 direct + `circle` / `polygon` / `analytic_contours` child 及 source-sketch 列表,但不写单一 + `source_sketch_id`,故 adapter 不会为这个合成 profile 伪造 source topology anchors。 + sketch solver 对每个 child 独立分类 outer/hole region 后才组合,而不是把来自另一草图的 + 同心/嵌套 contour 误作孔;primary extrusion 对生成的 faces 仍使用现有 fuse 语义。`00011635` + 的 F7 从 F6 两个三角区及 F4 两个 circle region 生成该 profile,F1--F7 fresh prefix + rebuild 全部执行。`00383990`、`00415720` 与 `00984782` 在该 contract 下完整 rebuild; + `00766980` 的 profile extrusion 执行后才在独立的后续 selector active-lineage 缺口停止, + `00891962` 则在其上游 CAP_FACE profile gap 前 defer。原子 runtime 回归以不同 source 的同心 `r=5` / `r=2` circle 证明结果为 + 外圆柱 `2*pi*5^2`,而不是带孔圆环。derived/CAP/SWEPT/IMPRINT/COPY/MERGE query、 + `qIntersection` / `qSubtraction` 或 mixed combinator、future/unavailable source、attachment、不同或反向 frame、重复 source、 + open/non-executable profile,以及所有 selector lineage/后续 topology consumer 都继续 defer; + 任一 composed sketch-region union 未满足这些条件时必须以 + `extrude_multi_source_sketch_region` defer,不能回退到 parser 最后遇到的 source sketch。 ### P1:派生 profile 与拉伸终止 @@ -387,9 +878,73 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 pair -> vertical edge 为其它真实语料证据。direct all-circle construction 还保留 annular inner edge 及每个 direct circle region 的独立 delta;后者目前只有原子 executor evidence。IMPRINT 或 split profile、draft、multiple/two-sided extent、fused result、boolean/SPLIT、revolve、 - sweep、loft、copy/pattern 及 profile/wire consumer 也未覆盖。因此这只是两个受限 end-to-end - contract,`SWEPT_*` family 和 P1 的通用派生 profile 目标仍未完成,不能由 AST、unit relation - 或上述两个样本提前完成。 + sweep、loft、copy/pattern 及 profile/wire consumer 也未覆盖。FeatureScript `2491` 另有一个 + 独立 full-solid-revolve `SWEPT_EDGE` contract:unchanged direct profile 的完整 incident source-edge + set 唯一命名一个 vertex,adapter 仅以 `BRepPrimAPI_MakeRevol.Generated(vertex)` 且 final-snapshot + `IsSame` 证明对应 circular edge;`00404735` F3 为执行证据,F2 的非共享 OSD pair 仍 deferred。 + 因此这些只是受限 end-to-end contract,`SWEPT_*` family 和 P1 的通用派生 profile 目标仍未完成, + 不能由 AST、unit relation 或上述样本提前完成。 + `BLEND_EDGE@1511` 也仅有一个独立 direct-prism/dress-up contract:`Generated(source_edge)` + patch face、唯一 `Modified(source_face)` target face 与它们 final `IsSame` shared boundary edge + 必须全由 builder history 证明;source face 仅可为完整 direct `CAP_FACE`,或与 CAP edge 共用一条 + original profile edge 的 direct `SWEPT_FACE`。`00414347` F2->F3 与 `00690433` F2->F3 均以 + `blend_boundary` 执行;前者 fresh pipeline 为 RP pass(strict volume/area diagnostic 约 + `5.7e-5` / `4.0e-5`),后者 comparison rejected。`00596552` F4->F5 与 `00456146` F3->F4 + 已验证 SWEPT_FACE source-pair lowering 和 exact resolver contract;其 fresh replay 分别在已有的 + native chamfer feasibility 与其它 selector ambiguity 停止,故同样不构成 strict/RP 通过证据。 + `BLEND_FACE@1511` 另有独立的 runtime-attached sketch contract:outer query 只能是一个 + immediate native fillet/chamfer 的单一 `BLEND_FACE`,其唯一 OSD source edge 必须等于该 dress-up + 已消费的、role-qualified direct-prism `CAP_EDGE`。producer 只限独立、无 draft、blind `new_body` + direct profile prism;adapter/runtime 只接受 complete/proven `Generated(cap_edge -> patch_face)` 的唯一 + active final patch face,再由该实际 planar B-rep face materialize sketch frame。`00313870` F2->F3/F4 + 与 `00436592` F3->F4 是 source/lowering/runtime-attachment evidence:前者 full replay 在已有 native + F2 chamfer feasibility failure 前保留 F1 checkpoint,后者在已执行 F3 后因 later F6 unsupported query + 停止,均不是 strict/RP 通过。multi-edge OSD、two-sided/IMPRINT/split/drafted or non-direct profile、 + non-immediate/nested dress-up、MERGE/SPLIT/COPY/pattern/boolean successor、non-planar/ambiguous/partial/ + inactive patch、other versions 以及 geometry/stable-ID/current-body/source-STEP fallback 仍拒绝;这不完成 + general `BLEND_FACE` 或 generic face attachment。 + `MID_CAP_EDGE` 仍未获得这一类 relation:对默认 smooth `BRepOffsetAPI_ThruSections` + loft,OCP 7.9.3.1 的 `Generated(source_edge)` 只给侧面,source 和中间 section edge + 均不在 final snapshot;`FirstShape`/`LastShape` 也没有 source-shape overload。虽然 + `ruled:true` 会保留 section edge,当前 23 条真实 MID 历史均未要求 ruled loft,不能以此 + 改写默认 loft 语义。`capPos` 不能通过排序、面边界遍历或几何邻近推测,故保持有界 defer, + 直到有 exact source-section-to-final-edge witness 和多语料 consumer 回归。 + `00614954` 的 cap role mismatch 和 `00407186` 的 revolve/SWEPT tuple 继续拒绝,不能把 + patch face 或任意输出 edge 当作可用替代。 + `CAP_VERTEX@1511` 现另有一条严格的 direct-prism `up_to_vertex` contract:OSD 必须恰好包含同一 + unchanged direct profile 中两个不同、共享唯一原始端点的 source edge;adapter 以 + `BRepPrimAPI_MakePrism.FirstShape(source_vertex)` / `LastShape(source_vertex)` 获取端帽 vertex,并以 + final B-rep explorer 的 `IsSame` 将历史句柄绑定到最终顶点 record。由于 build123d 重复 + `body.vertices()` 调用不会保留该句柄 identity,vertex record 改为从最终 B-rep explorer 导出;这不是 + 坐标或近邻匹配。lowering 只接受 `1511`、紧邻、独立、无 draft 的 `new_body` blind + `extrude_add_blind` producer 与 one-sided `UP_TO_VERTEX` consumer;resolver 只沿唯一 + `boundary`、complete/proven、role-qualified `vertex -> vertex` relation。若 consumer 前仅插入 + reference plane 或 independent undrafted `new_body` blind prism,且 session 对旧成员和新 Compound + member 的全部 face/edge/vertex 逐项作 reciprocal `IsSame`,registry 才追加 `body_member_preserve` + continuation;aggregate `body` metadata 不参与该 B-rep 校验,也没有几何等价匹配。真实 `00330726` + 的 F1 -> F3 -> F5 因而已由 `extrude` + `body_member_preserve` 的 kernel lineage 执行;fresh + `output/cap-vertex-body-member-20260912` 完整历史仍因 F7/F8/F9/F10 的既有 sketch/cPlane/loft + 缺口而 `converted_partial` / `rebuilt_rejected`(3 vs 4 solids),不能误报为质量通过。two-sided/draft/ + ADD/CUT、IMPRINT/split/multi-profile、成员变异/替换/删除、不完整或不唯一 `IsSame`、COPY/pattern/ + boolean、其它 consumer/version 与任何 stable-ID、geometry、current-body/source-STEP fallback 均不在 + 范围内,不能标记 `CAP_VERTEX` family 或 P1 extent 完成。 + `source VERTEX@1511` 另有一条不经过 topology resolver 的 `UP_TO_VERTEX` datum contract:当 extent + query 恰为一个 direct source-sketch `sQuery(..., VERTEX, ...)`,lowering 保留 + `{source_sketch_id, source_entity_id, point_mm}`,runtime 只将该已验证 source point 沿 extrusion + direction 作 uniform-distance 检查。selector-binding 明确不绑定该 datum,因而没有 runtime record、 + `selector_intent`、stable ID、几何或 current-body fallback。`00444951` 的三个单向 datum extent 完整 + convert/rebuild,fresh `/private/tmp/cadfs-source-vertex-extent-20260914-r2` strict/RP 均通过; + 2026-09-14 的全语料 source audit 发现 16 个 `UP_TO_VERTEX` history:15 个已 lower target 为这条 + direct datum(`00444951` 三个、`00510558` 四个、`00753006` 两个、`00894150` 三个以及 + `00383982`/`00503730`/`00975649` 各一个),其余一个是 `CAP_VERTEX`、一个是 + `INTERSECT(VERTEX)`;其余 history 在上游 capability 前停止。7-sample matrix + `/private/tmp/cadfs-source-vertex-extent-matrix-20260914` 中,`00510558`、`00753006`、`00894150`、 + `00975649` 的所有 datum feature 均执行,`00503730` 也保留 F5 executable prefix;它们的整模 rejected/ + partial 分别由 independent shell/workplane/pattern/geometry gaps 造成。`00383982` 在到达 datum feature + 前因 unbounded IMPRINT profile runtime failure 停止,不能计入 datum success。`qAdjacent`/`COPY`/pattern/IMPRINT/ + derived vertex、`INTERSECT(VERTEX)`、非 1511 source、非唯一/非有限 source point 和任意 selector consumer + 仍 defer。这只补齐原草图 vertex 的 extent datum,不完成 general `up_to_vertex`、`CAP_VERTEX` 或 vertex + selector family。 `CAP_EDGE` 现在也有一条独立的 1511 direct-prism contract,但它不物化 profile/wire:source sketch entity 必须仍对应一个 exact direct-profile edge,query 的 `isStart` 被保存为 `lineage_role: extrude.start|extrude.end`,adapter 以 @@ -405,7 +960,9 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 仅给同一 source circle 拆出的全部四条 arc 加 `logical_circle_source_entity_id`;adapter 再要求这个 ID 在原 profile 中唯一指向一个未拆分圆、重建 native wire,并以 finished-face `IsSame` 重新证明。 任一 marker、source 或 final membership 缺失都没有 anchor。`00735367` 的 F2 因而以 - `kernel_lineage` 解析、F1--F3 执行,随后 F4 才以 `selector_query_unsupported` 导出 F3 checkpoint。 + `kernel_lineage` 解析。完整 candidate 在后续 F6 选择已不再独立存在的 F2 body 时保持 + `runtime_ineligible`;rebuild 仍增量重放 F1--F3,随后在 F4 的 + `selector_query_unsupported` 导出 F3 checkpoint,绝不因预检失败隐藏该 STEP。 不完整 logical-circle loop 不产生该 circle 的 anchor;含 hole 的 multi-region、trimmed/split profile、 draft、multiple/two-sided extent、fused result、boolean/SPLIT successor、revolve、sweep、loft、copy/ pattern 继续拒绝。 @@ -442,6 +999,19 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 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。 + 另一个不重建草图的 1511 端盖 profile 例外为 + `shell_retained_direct_prism_cap_offset_face_profile`:紧邻 shell 必须只依赖一个未拔模的 + direct `new_body` blind prism、恰移除该 prism 的一个 CAP;后续 OFFSET query 必须由一个 + OSD 完整列出未改变 direct profile 的全部 source edge。lowering 用被移除 CAP 的反角色 + source-qualify `shell.offset_face`,semantic/preflight 复核 direct producer、唯一 removal、 + immediate owner、完整 source set 与单侧 blind additive `new_body` consumer;runtime 仍要求 + 唯一 active `CAP -> shell.offset_face` complete/proven kernel relation,绝不以 wall、closing + descendant 或几何接近面代替。`00719927` F1--F4 在 + `output/offset-face-retained-cap-20260913` 全部执行,F3 绑定 `F1/extrude.start` 的 retained + cap;其 strict/RP 均 rejected,故这是 executable evidence,不改变上述受控 shard 的历史计数, + 更不代表 general OFFSET_FACE 完成。partial/multi-source OSD、多 CAP removal、被移除 cap、 + draft/cut/fused/revolve/sweep/COPY/pattern producer、non-immediate lifecycle、非 1511 和所有 + geometry/stable-ID/current-body/source-STEP fallback 继续拒绝。 对 CAP_EDGE,现有的受限 outer-profile 规则还会递归展开仅由 `qUnion` 构成的关联嵌套, 再要求恰有一个原始闭合 IMPRINT 外 region 和一个唯一 CAP_EDGE-derived IMPRINT region, 并由两个明确 face-side 判断完整外 region 或外环加孔。该展开只消除 FeatureScript 局部 @@ -491,6 +1061,23 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 该能力的完整覆盖。共享核心 17 回归 `output/core-17-planar-imprint-20260909` 分类保持 3 strict、2 RP、10 rejected、1 rebuild failure、1 comparison timeout。 + 2026-09-10 的 direct prism/fuse bridge 只对多个 bounded IMPRINT region、blind one-direction + extrusion 且每一条 source anchor 均由 exact splitter image 给出的 path 启用。每一区域先由 + `BRepPrimAPI_MakePrism` 产生 direct history,随后一个 `BRepAlgoAPI_Fuse` 将 direct cap、side face、 + cap edge 与 vertex-swept edge 映射至 final B-rep;registry 只登记 final-handle relation。fuse + 删除的 source seam 显式以 partial/unknown relation 保留,`all_fragments` 必须报 + `selector_kernel_history_missing`,而不是在 final solid 中找相邻面。合成矩阵覆盖 complete + outer side、split outer source、deleted seam、single-result ambiguity 和两端 CAP set;`00354246` + 的 F2 seven `SWEPT_FACE` queries 现由 native kernel lineage 解析,并随后以 OCC 的 + `Failed creating a fillet with radius of 5.08` 失败,证明失败层已从 selector 转移至 dress-up。 + `00403485` 的 F2 仍因两个 deleted side branches 报 `selector_kernel_history_missing` 并保留 + F1 STEP;不能用它作为 completed family 或通过 geometry recovery。`00253824`、`00304488`、 + `00354246`、`00403485` 的 fresh forced RP pipeline 位于 + `/private/tmp/cadfs-imprint-prism-fuse-20260910`:1 rebuilt_rejected、3 rebuild_failed,所有 + 可执行 prefix 保留。共享 selector/runtime tests 为 162 passed、1 skipped;fresh core-17 + `/private/tmp/cadfs-core17-prism-fuse-20260910` 为 2 strict、1 RP、6 rejected、7 rebuild failed、 + 1 comparison timeout,与既有分类一致。此项仍为部分完成,尚缺三条可 RP/strict 接受的真实 + IMPRINT 生命周期、一般 fused relation component、draft/trim/multi-extent、多 body 与后继 lifecycle。 对同一逻辑 circle source,solver 现在保留一条带 workplane frame 的 OCC circle edge,而不将 它预拆为四条 contour arc;fragment 的 directed successor/predecessor 由 splitter image 在 精确交点处的端点和原 curve 同向切向解析,避免周期参数接缝的数值排序歧义。无 fragment @@ -531,21 +1118,91 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 vertex(含一个 analytic region 的 exact hole wires)由 exact construction anchor 和 `Generated` cross-kind history 绑定给紧邻 consumer;含 hole 的 multi-region profile 不产生这类 anchor。 这不覆盖本段 sweep/loft/revolve 的 output role,亦不把任一受限 output role 或特征专用 - selector 升级为通用 CADFS query-family 支持。 - 对 `sweep.path`,另有一个 source-sketch-only lowering contract:FeatureScript 1511 的 exact - `qUnion([qConstructionFilter(qBodyType(qCreatedBy(sketch, EDGE), WIRE), NO)])` 仅在 source 还直接 import - `onshape/std/geometry.fs@1511.0`、owner 是 source - sketch、恰有一个 non-construction `line` 或 `bspline` entity 时解为路径,不能作为 runtime - `qBodyType` selector。两点 `skFitSpline` 只有 source 明确给出 start/end derivative 时才会保留为 - B-spline 并由 OCC 执行;没有两个导数时拒绝,绝不改成 chord。`00896761` F2(F1/E2 two-point - B-spline)现在 converted/rebuilt 且无 runtime diagnostic,artifact bundle + selector 升级为通用 CADFS query-family 支持。`SWEPT_EDGE@2491` 还登记了一个独立 full-solid + revolve source-vertex -> circular-edge contract;它必须以 `MakeRevol.Generated(vertex)` 的 complete + final-snapshot relation 解析,不与上述 prism contract 共用版本或 producer 假设。 + 对 `sweep.path`,另有一个 source-sketch-only lowering contract:FeatureScript 1511 和直接 + `onshape/std/geometry.fs@1511.0` import 下的 exact singleton + `qUnion([qBodyType(qCreatedBy(sketch, EDGE), WIRE)])`,可带 exact + `qConstructionFilter(..., NO)`。它从 source sketch 的 `EDGE` 曲线集取路径,绝不是 runtime + `qBodyType` selector;`skPoint` 不属于该集。singleton `line` / open `skArc` / `bspline` 保持既有 + contract;另外允许一个原始闭合 `skCircle`:它可由唯一 direct `sQuery` 命名,或由上述 exact + source-wire query 的完整结果唯一选择,携带精确 center/radius 与 source workplane,并作为一条 + closed `Wire` 传给 PipeShell。arc 与 circle 都不能从 + `qUnion` representative leaf 截断或伪造 singleton。闭合 circle 没有 physical start/end cap,因而不写 + `cap_output_contract`、`swept_face_contract`、`swept_edge_contract` 或任何 endpoint output role。两点 + `skFitSpline` 必须同时携带 source start/end derivative, + 否则拒绝,绝不改成 chord。多段 form 有两种 + source-only 边界:一个直接 source sketch 的完整 query result 可含至少两条 `line` / `arc` / + non-periodic `bspline`;或 outer `qUnion` 可含至少两个上述 exact wire-query operand,每个 operand + 仍独立经过相同 version/filter/source gate。前者使用同一 workplane;后者把每条 direct source curve + 通过其显式 sketch workplane 捕获为 global 3D segment,绝不将多个 sketch 的局部坐标投影到任意一个 + sketch,也不读取 datum output、OCC result、final STEP 或 current body。filter 存在时去除 construction + curves;无 filter 时只要任一 operand 的 source result 含 construction curve 即 deferred。lowering + 仅按精确 source endpoints 验证、排序为 connected、non-branching、open wire,且拒绝重复 + `(source_sketch_id, source_entity_id)`;跨 sketch variant 的 self-contained `path.segments` 不携带虚构的 + common `workplane`,只保留每段 global geometry、方向与 source identity。两种 form 均由 + `BRepBuilderAPI_MakeWire` / `BRepOffsetAPI_MakePipeShell` 执行;profile 在 terminal point 时仅反转已 + 捕获路径方向,circular pattern 也会显式旋转所有 global curve point/vector fields。closed、 + disconnected、repeated/branching、degenerate、ellipse/unknown curve、非 exact wrapper/filter、其它 + version,以及 loft guide/surface/profile/runtime-body consumers 均继续拒绝或 deferred;该 CDSL + `path.segments` contract 不产生 `selector_intent`、topology lineage、CAP output-role 或 stable-ID fallback + permission。 + `00175627` F2 是 singleton direct `skArc` 的 execution evidence:以显式 source arc 完整 rebuild, + 但 RP comparison 当前 rejected,不能称为相似通过。`00007135` F2 还证明 direct arc 的 CAP endpoint + contract 可准确解析;其后 F3 shell 是独立 OCC failure,F2 STEP prefix 保留。`00030209` F6 也能 lower + arc path,随后因既有 multi-region profile runtime boundary 停止。相同 source-wire contract 的 exact + `qBodyType(qCreatedBy(sketch, EDGE), WIRE)` 结果若完整选择恰好一条原始 open `skArc`,也可作为 + singleton path:这是 complete source set 的 cardinality proof,不是 parser 保留的 `qUnion` representative + leaf。`00109840` F2 与 `00627942` F2 均 converted/rebuilt;`00071885` 已通过该 path gate,但其 profile + attachment 独立失败为 `reference plane x direction is degenerate`,故没有 CDSL 可执行 feature。该边界 + 不开放 general `qBodyType`、runtime topology selector、general circle path 或 multi-leaf representative fallback。 + `00498974` F2 进一步证明 filtered source-wire 的完整结果唯一为 circle 时,可不经 parser leaf 执行并 + rebuilt;其 F3 以后的 `SWEPT_FACE` host 仍独立 deferred,故只保留 F2 prefix。多 circle、mixed curve、 + construction ambiguity、closed multi-segment wire 与其它 source version 仍不在此 closed-circle tuple 内。 + fresh 9,347-history lowering scan 因此为 6,625 `converted_complete`、2,371 `converted_partial`、351 + `deferred_no_executable_feature`、zero exception;`sweep_path` 已为 zero,仍有 62 条独立的 + `sweep_path_query` contract rejection。closed-circle source-wire bridge 后的同一 fresh scan 保持 zero + exception,`sweep_path_query` 降为 61。 + `00232443` F2 和 `00310959` F2 是 + direct singleton `skCircle` 的完整 executable evidence:二者均重建为一个 solid;`00074481` F2 同样 + 执行并在后续无关 F4 OCC failure 前保留 STEP prefix。该结果只证明闭合-spine execution,不是 + strict/RP similarity claim,也不为 CAP/SWEPT selector 生成角色。closed/derived arc、multi-`sQuery` + representative arc/circle、closed multi-segment wire 与其它 source version 继续不支持。`00896761` F2(F1/E2 two-point B-spline)现在 + converted/rebuilt 且无 runtime diagnostic,artifact bundle `output/qbodytype-direct-sketch-wire-20260910-v2` 包含 STEP、GLB 和七视图;但 RP comparison 在 60s 预算超时,分类为 `comparison_timeout`,不是相似通过。fresh seven-sample RP matrix `output/qbodytype-direct-sketch-wire-matrix-20260910-v1` confirms all direct-path cases rebuild: `00191739` strict, `00726304` RP-only approximate, `00227428`/`00287471`/`00500952`/`00816123` rejected, and - `00896761` timed out during comparison. These classifications are evidence of executable paths, not a claim - that rejected/timeout output is similar. `00786708` F2 因多个 non-construction path entity 继续 deferred。这不是 generic `qBodyType`、qConstructionFilter、sweep - path、loft guide、arc/circle path 或 sweep complete 的完成声明。 + `00896761` timed out during comparison. `00885126` F2 is the first unfiltered multi-segment evidence: + `E0 -> E2.filletArc -> E1` rebuilt as an independent STEP in + `/private/tmp/cadfs-segmented-wire-00885126-20260910`, while the full sample is RP-rejected because F3+ remain + deferred. These classifications are execution evidence, not similarity claims. `00786708` is the filtered + line/arc/line evidence: F2 lowers and executes, as do downstream F5 and F7; its complete three-feature + history is `rebuilt_rejected`, not deferred at F2 or a similarity success. This is not generic `qBodyType`, + `qConstructionFilter`, general sweep path, loft guide, circle/closed or derived arc path, or sweep completion. `00610979` supplies the + first cross-sketch execution evidence: F4's outer union of F1 and F3 exact filtered wires is captured as four + connected global segments and executes as one independent solid in forced pipeline + `output/cadfs-spatial-wire-00610979-20260910`; F2/F4 have no runtime diagnostic and the artifact contains the + executable STEP, GLB and seven-view render bundle. Its complete history remains `converted_partial` / `rebuilt_rejected` because F5/F9 CAP_FACE + attachment and their downstream operations remain deferred; source final STEP has four solids while the retained + F4 prefix has one. This is a bounded executable-prefix classification, not RP/strict or sweep-family completion. + 同一 source-wire query 另有一个严格独立的曲面 loft bridge:`ToolBodyType.SURFACE`、`NEW`、无 + spine/guide/connection/match/endpoint derivative/sheet profile 时,两个来自不同 source sketch 的完整 + non-construction closed wire 才可 lower 为 `loft_surface`。它支持单一 circle 或由 direct line/arc/B-spline + 组成的 connected non-branching closed cycle;adapter 以 `BRepOffsetAPI_ThruSections(False, False)` 生成 + shell 并独立登记,绝不 fuse 到 active solid,也不开放 surface topology 的 selector continuation。 + `00174697` 以两条圆 source wire fresh rebuild 为一张 shell(`surface_count: 1`、`solid_count: 0`)。 + open/branched/disconnected/inner/derived/runtime wire、construction ambiguity、spine/guide/derivative、 + 非 `NEW` 或其它 source version 一律 `loft_surface_wire_profiles` deferred;这不是 general loft、general + `qBodyType`/construction filter 或 surface selector completion。 + `sweep_cut` 现是一个独立 primary body operation:对已经满足现有 closed-profile 与 open-path + contract 的 `NewBodyOperationType.REMOVE/CUT`,runtime 将 PipeShell 结果只作为 transient tool,逐个 + active body member 调用带 history 的 `BRepAlgoAPI_Cut`;只有 target-side complete/proven delta 才进入 + body graph,tool 的 sweep builder history、CAP/SWEPT roles 和 source anchors 不得泄漏为 selector lineage。 + `00259897` F7 与 `00229963` F10 都完成完整 replay,但当前 RP 均 rejected,因此这是 operation execution + evidence 而不是 geometry acceptance 或 generic sweep/selector completion。INTERSECT、tool selector、guide/ + transition、open/多 profile、surface sweep、后续 sweep-derived topology query 与不支持的 path source 仍 deferred。 2. `shell`:单一 selected solid 的 direct-builder topology delta 与有限 output-role evidence 已覆盖。CADFS lowering 现额外接受一个受限的 removal selector:直接 blind 或 two-sided linear extrusion 的立即后继 shell,可从同一未修改 source profile 中唯一的、非 @@ -601,30 +1258,78 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 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、 + 后者不声称曲线类型,因为内核可将其物化为 B-spline。`1511` 与 `2491` 均有一个独立 `NEW` + full-solid revolve 的 source-vertex contract:profile 与 line axis 必须来自同一 source sketch, + original-set 的至少两条 non-construction edges 必须唯一指向一个 non-axis vertex;adapter 仅接受 + `BRepPrimAPI_MakeRevol.Generated(vertex)` 中仍属于 final snapshot 的 exact edge。`1511` 只接受 + 原始、非 `IMPRINT` 的 profile source;`2491` 可额外接受 materialized workplane 和完整 profile + 均经显式验证未改变的 complete materialization。`00025622` F2 与 `00048326` F3 已 strict rebuild, + `00407186` 仅证明 F2 初始 dress-up,F3 的非唯一 continuation 仍必须拒绝。此处不使用 axis + projection、circle centre/radius 或 OCC geometry signature 绑定。任何 changed/incomplete/split + materialization 或实际 region 选择都不继承 revolve source vertex。该受限路径不覆盖 sweep、surface/partial/fused revolve、 multi-section loft、generated/trimmed/transformed profile 或 boolean/pattern 后继;任一 端点、axis 或 owner 不唯一时必须保留前缀并诊断。内核不能完成时不能伪造较小半径或不同 - 孔型。这里的 endpoint-bbox/revolve selector 不属于 capability matrix 的 direct-prism - `SWEPT_EDGE` provenance contract,不能借其成功。只有该受限 blind-prism path 具备 query - source semantics、lowering、adapter evidence、consumer 和真实语料回归;其它 generator/ + 孔型。旧 endpoint-bbox 与 circle-signature selector 只能保留为诊断,不能作为 provenance + fallback。除 1511 direct-prism 和 1511/2491 full-solid-revolve 两条 versioned contracts 外,其他 generator/ consumer 仍须逐一补齐后,才可将整个 query family 标记为完成。 `hole` 现增加一个独立且更窄的 source-location/body-scope contract:lowering 仅接受原始 `sQuery`/`sketchEntityQuery(VERTEX, ...)` 的 `skPoint`、`skCircle.center`、`skLineSegment` - 或 `skArc` 的 `.start`/`.end`,并要求所有 location 都来自同一显式草图平面;派生 suffix、 - CAP/SWEPT/COPY topology、query combinator 与几何邻近性均不会成为 location fallback。CADFS - `scope` 必须是一个直接 `SWEPT_BODY`,并在 lowering-time body graph 中仍是唯一独立成员,才写入 - `hole_wizard.params.scope_feature_id`。schema、semantic validation、capability preflight 和 executor - 共同检查该成员仍是唯一 active solid;executor 在每次切除后保留原 scope member key,因而后续 - CADFS hole 可以继续引用同一 body,而无 scope 的既有 CDSL hole 仍保留 feature-owned lifecycle。 + 的 `.start`/`.end`,以及由同一原始 `skArc(start, mid, end)` 精确计算的 `.start`/`.end`/`.center`, + 并要求所有 location 都来自同一显式草图平面;派生 suffix、 + CAP/SWEPT/COPY topology、query combinator 与几何邻近性均不会成为 location fallback。只有这个 + direct source-vertex form 在其 source sketch 未能执行时才报告上游 + `hole_location_sketch_unavailable`;wrapper/combinator 即使递归包含 `sQuery`,仍如实报告 + `hole_location_vertex`,不会被误归因成 host failure。CADFS + `scope` 必须是一个直接 `SWEPT_BODY`,并在 lowering-time body graph 中仍是一个显式、独立且存活的成员,才写入 + `hole_wizard.params.scope_feature_id`。scope 不要求它是唯一 active solid:schema、semantic validation、 + capability preflight 和 executor 共同确认该指定成员仍 active;executor 只切除该成员并以原 scope + member key 注册结果,其它独立 active members 原样保留。因而后续 CADFS hole 可以继续引用同一 body, + 而无 scope 的既有 CDSL hole 仍保留 feature-owned lifecycle。`00219259` 的 F6 是多成员正例:F1 + 与 F3 同时存活时,F6 的直接 `SWEPT_BODY(F1)` scope 仅切除 F1,rebuild 保留两个 solid。这是执行 + 证据,不是 strict/RP 比较通过声明。以当前 lowerer 重放,`00039800` F7 同样在 F1 与 F5 两个 + 独立成员共存时只切 F5,`00302703` F3 则只切 F2;二者均 `converted_complete` 且 rebuilt。它们 + 证明 explicit direct scope 不要求 sole active solid,但不扩大为 COPY、boolean、pattern、transform、 + fused/replaced/deleted successor 或 aggregate/current-body scope。 `00406667` 的 F1/F3/F5 是端到端证据:两个 `skCircle.center` hole 与同一 F1 revolve scope 完整执行, `output/hole-direct-sketch-vertex-20260910-v2` 分类为 `rebuilt_approximate`(RP passed,strict failed)。 - 这不覆盖 B-spline/interior vertex、多个 host plane、多个 scope body、COPY/boolean/pattern successor、 - non-direct query、全部 hole start/end styles 或完整孔型/螺纹语义。 + `00392565` 的两个 direct `skArc.center` hole、`00091356` 与 `00486207` 的 direct arc-center source + forms 已完成 lowering regression;它们不以 runtime arc fitting 推断圆心。此路径不覆盖 B-spline/interior + vertex、多个 host plane、多个 scope body、COPY/boolean/pattern/transform + successor、fused/replaced/deleted member、non-direct query、全部 hole start/end styles 或完整孔型/螺纹语义。 + 对上游未执行草图的 direct `CAP_FACE` hole host 复核确认,当前五个真实 history 不是同一缺失的 + direct-prism attachment:`00590640` 的 host producer 带 IMPRINT profile、typed ADD 与 draft cut, + `00606499` 的 producer 同时 draft、offset 和 second direction,`00215642` 是双向 + UP_TO_SURFACE 且 hole scope 为两个 body,`00758713` 的 profile 来自 revolve side-face 上的 IMPRINT, + `00622779` 则是多 region IMPRINT profile。单凭 outer `CAP_FACE`、owner 或静态 cap frame 都不能证明 + 这些 host 的 active planar successor。它们继续保留 `hole_location_sketch_unavailable` 与最后可执行 + checkpoint;未来 capability 必须分别提供 source-to-materialized-profile identity、完整 producer delta、 + active member/scope 与 runtime face attachment relation,不能以 frame、geometry、stable ID、current body + 或 source STEP 回退。 + direct source-line `SWEPT_FACE` 的静态 sketch host 同时修复了一个 source-frame 判定错误:先前以 + contour 顶点平均值寻找边的内侧,在有效但不对称的闭合 profile 中该平均值可刚好落到选中边所在直线, + 从而错误报告 `swept face interior is degenerate`。现在仅当 source contour 闭合、non-zero signed area、 + 且恰有一条有序 segment 匹配原始 non-construction line 时,才由 contour winding 计算其 left/right + interior,再形成 attachment frame;没有这些 source witness 时仍走既有有界诊断。`00139790` F6 与 + `00140109` F5 的 hole 完整 rebuild,`00140261` F3 hole 已执行并保留,后续 F4 仍因独立的 open/ + disconnected profile preflight failure 停止。17 条 direct SWEPT_FACE hole host 中其余 14 条仍由未闭合 + profile、unresolved owner frame、INTERSECT、MERGE 或 scope lifecycle 阻塞。该改动不生成 runtime + SWEPT_FACE selector lineage,不检查 final B-rep/geometry,也不完成 general SWEPT_FACE、hole host 或 + selector family。fresh 9,347-history lowering scan 为 6,620 `converted_complete`、2,371 + `converted_partial`、356 `deferred_no_executable_feature`,无 lowering exception,且没有残留 + `swept face interior is degenerate` diagnostic。 + 当前 source corpus 没有 `qAdjacent(...)` 调用,故其递归 AST preservation 仍不能升级为 runtime + filter capability。`SPLIT(FACE)` hole host 的六条实际 history 也未形成可复用的单一 contract:分别包含 + extrude side-face 与 CAP_FACE 的 split、shell OFFSET_FACE split、多段/derived source 及 MERGE + ancestry。`TD(SWEPT_FACE)` 或 nested CAP frame 只能说明 split 输入,不是 active split result 的 N:M + relation;在 adapter 记录完整 split component、source cardinality 和 active member 前,二者继续 deferred + 并保留 prefix。 + `COPY(CAP_FACE)` 的九条 hole-host census 同样确认 selected-region 不是现有 full-profile bridge 的一行 + 放宽:`00950758` 是 1511 primary blind cut,但 CAP OSD 只选择包含 additional `E4/E5` source geometry + 中的 `E3` boundary;`00482362` 则是 1549 的独立 circular cut tool;其余分别带有 IMPRINT + subset/multi-region、`surfaceEntities`、`UP_TO_SURFACE`、mirror 或 non-immediate lifecycle。future contract + 必须显式保存 selected-region 的 source membership,并证明该 region 的 transient cap 经 same-owner subtract + continuation 到唯一 active face。partial OSD、owner、cached frame 或近似平面均不能升级为面选择,也不能使用 + geometry、stable ID、current body 或 source STEP 回退。 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` @@ -637,22 +1342,50 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 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` 和已存活的 + `00000385` F5 的 plane defer,F2 chamfer 与 F7 fillet 仍如实诊断。 + mirror plane 另有第二条、同样非 runtime-selector 的 direct-prism datum contract:仅 + FeatureScript 1511、紧邻、无 draft、blind `new_body` `extrude_add_blind` 的同一个 + `SWEPT_BODY` mirror source,才可将其 direct `CAP_FACE` 或单一原始 non-construction line + `SWEPT_FACE` 物化为 `reference_plane`。CAP 必须先满足现有 direct builder output-role + 证明;side face 必须先满足既有完整 direct-profile/source-edge contract。该平面只来自 source + AST 与 producer frame,不查询 runtime face、current body、stable ID、geometry 或 STEP。 + `00094392` F1 -> F2 的 `CAP_FACE`(`y=-204.44 mm`)和 `00051481` F1 -> F2 的 line + `SWEPT_FACE` 都 fresh rebuild 且 mirror executed;前者完整 history rebuilt,后者后续仍在独立 + MERGE/CAP_EDGE capability 停止。draft、two-sided/CUT/ADD、IMPRINT/partial/derived source、非 + 1511、任何 intervening mutation、plane owner 与 mirrored source 不同,以及所有 generic face + query 保持 mirror-plane defer。这不完成 general CAP/SWEPT selector、mirror plane 或 mirror lifecycle。 + `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 代替。 + tuple 解析对应独立 body member,绝不以 producer aggregate 或 current body 代替。显式 + target/tool 还接受前序 multi-source `make_copy:true` transform 的 + `{transform_feature_id, source_feature_id}`:source 必须是该 transform 的直接输入,且其 + source-qualified COPY member 仍 active;schema、semantic validation、capability + preflight 与 executor 都拒绝 owner/source 不匹配、单来源 COPY、aggregate 和失活成员。 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 + creator 作为 circular source;`00253824` 的 F4 明确为 `NewBodyOperationType.ADD`,在 CDSL + body graph 中 fuse 为 active `f_F4` member,因此 F5 circular source 和 F6 targetless UNION + 都明确绑定 `f_F4` 与 F5/F4/instance-1,2 ownership。该完整样本目前在无关的 F7 + `MERGE(FACE)` workplane provenance 缺口停止,并保留 F1--F6 executable prefix,不能作为 + 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、 + targetless FeatureScript `UNION` / `INTERSECTION` 现只在至少两个 explicit、存活的 source body members + 时按 direct member 优先、否则第一个 qualified COPY member 的确定性 body-graph policy 选一个 CDSL + target、其余 members 为 tools;同一 structured direct member、pattern instance 或 transform COPY member + 若在 source set 中重复出现,只保留首次出现,避免同一 member 同时成为 CDSL target 与 tool。两种操作均为 commutative set,不以此 choice 改变几何。`00293508` 的 direct F1/F3 UNION 以 F1 + target、F3 tool 完整执行并 RP 通过,`00073309` F7 与 `00104425` F4 的 targetless INTERSECTION + 同样完整 rebuild。`keepTools:true` 明确保留完整原 input set(含为 CDSL binary shape 而选出的 left + member)及 boolean result;`00215642` F6 由 F3 left / F1 right 的 UNION 执行至其独立后续 F8 + runtime-ineligible checkpoint。targetless subtract、混合 pattern/transform COPY query、无效或已经吸收的 + instance,及任何缺少 source-order/member proof 的 body set 仍保持拒绝。当前语料没有 native multi-source + COPY-to-boolean history;FeatureScript 1540 的 `00699847` 真实 F3 two-source COPY history 加入 source-only F5 + boolean contract variant 后,两个 source-qualified refs 完整 lower 并执行,但不作为该样本 + 的 strict/RP evidence,也不标记 general COPY lifecycle 完成。受影响的 unit suites 为 + 167 lowering/parser/selector 与 142 runtime tests(1 skipped);`output/core-17-multisource-copy-boolean-20260911` + 保持 2 strict、1 RP、6 rejected、7 rebuild failed、1 timeout,固定 25-sample shard + `output/multisource-copy-boolean-shard-004000-20260911` 为 1 strict、6 RP、7 rejected、11 rebuild + failed,均未出现 runtime/protocol fault。`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 缺口均 @@ -670,6 +1403,33 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 `round`、变量和其它函数不会在 converter 中猜测 FeatureScript 的数值语义。这只是 lowering 前置条件,不能作为 COPY owner、pattern instance lifecycle 或 RP 相似通过的 证据。 + +`MERGE(FACE)` 不得从它的 `derivedFrom` 内部 `CAP_FACE` 或 `SWEPT_FACE` 继承静态 + workplane。`parse_query` 为诊断而递归遍历,不代表外层 MERGE 已有一个 active face + successor。`00013930` F7、`00020631` F4 和 `00036155` F5 均因此在外层 MERGE 处以 + `sketch_deferred` 停止,并保留此前 executable prefix;后续 feature 不得消费臆造的 + 宿主面。全量 294 个 direct outer MERGE host 中,261 个是 1511;即使其中 77 个具有 + 两条单 OSD SWEPT_FACE source,也仍缺少从 FeatureScript merge owner 到 CDSL + body-member merge 的 source contract 与 OCC N:M final-face relation。尤其不能只因 owner + 名称包含 `boolean.opBoolean` 推断某个特定的 source-face merge relation;typed + `NewBodyOperationType.ADD` 虽然按 FeatureScript 语义进入 merge scope,但单纯的 fuse + 结果或 aggregate 仍不能证明 N:M face successor。 + 只有 source-to-member transition、complete/proven final relation、active scope 和 attached + sketch consumer 同时具备后才可开放;geometry、face order、stable ID、aggregate/current +body 与 source STEP 都不是回退。 + +2026-09-14 ADD lifecycle correction: Onshape's standard-library definition of +`NewBodyOperationType.ADD` performs a boolean union with its merge scope; only +`NEW` creates an independent body. The lowerer therefore emits `result_mode: +new_body` only for `NEW`, while typed ADD uses the existing fuse execution path. +`00953397` F7 and `00957101` F4 now resolve CAP_EDGE through exact +`extrude -> union` lineage; `00074047` F4 and `00350698` F7 resolve CAP_FACE +shell inputs through the same chain; and `00007973` F5 -> F7 uses the explicit +`primary_add_up_to_surface_union_continuation` contract. `00293014` and +`00638700` reject an ambiguous union successor rather than choosing a face. +This corrects source/body lifecycle semantics but does not establish generic +ADD, N:M MERGE(FACE), multi-solid fuse provenance, pattern ownership, or any +geometry/stable-ID/current-body/source-STEP fallback. 5. `cPlane`:所有历史出现的 plane constructor、曲线/曲面 attachment、方向手性和 退化输入。`CPlaneType.OFFSET` 必须将 CADFS `oppositeDirection` 编译为 source plane normal 上的负 signed offset,而不是忽略该 flag 或翻转 plane frame;后者会镜像同一草图的 @@ -693,7 +1453,8 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 `eaef87b22d3b15e9377d8b1c6cb701f8111b6a0c`;适配该 MIT 算法但未引入依赖。`00030209` 的 F1/F2/F4/F5 是现有的四个真实 direct-circle evidence: `output/cplane-line-angle-direct-circle-20260910` 不再含 cPlane diagnostic,并保留 - 四个 executable `reference_plane`。该 source 后续因 arc sweep path 未实现而没有 + 四个 executable `reference_plane`。该 source 后续 arc sweep path 现可 lower;其 multi-region profile + 仍超出当前 PipeShell solid contract,因而没有 solid feature,pipeline 正确分类为 `runtime_ineligible`,没有将工作平面 lowering 误报为 STEP/RP 成功。多实体 reference、direct arc/cylinder/cone 等其他 `evAxis` 输入和一般 derived topology 仍未覆盖。focused shared selector/runtime suite 为 @@ -720,7 +1481,69 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 前三条为独立 `rebuilt_rejected`,`00831400` 在后续 F5 `BRep_API: command not done` 保留 prefix。 这只证明受限 frame lowering,不能将后续 geometry 分类归为 `LINE_ANGLE` 成功;direct arc、 non-source vertex、任意 derived topology、一般 face/curve/mate-connector axis 和完整 cPlane - coverage 仍未完成。既有圆柱母线 compatibility path 也已收紧为单一 direct `makeQuery` + coverage 仍未完成。`qBodyType(qCreatedBy(sourceSketch, EDGE), WIRE)` 另有一个 source-only + datum-axis bridge:只在 1511 / `geometry.fs@1511.0`、完整 query 恰选中同一 source sketch 的一条 + `line` 时,`LINE_ANGLE` 才读取该 line 的 explicit source workplane;construction line 在 datum + axis 中合法,和 sweep-path 的 construction filter 规则不同。`00506444` 的 F6 由两个这类 + construction-wire axes 构成并使 F8 完整执行/rebuild;多线、arc/B-spline、query composition、 + runtime topology 和其它版本仍 defer。此路径不生成 selector intent、不读取 active body/geometry + fallback,也不构成 runtime `qBodyType` family completion。 + `cPoint` 现有一条独立的 explicit source-datum contract:一个未带 suffix 的 direct + source-sketch `line` 与有限 `[0, 1]` parameter 按 source workplane 中端点的 affine interpolation + lower 为 `reference_point.point_mm`。1511 另接受两个非 runtime-selector 的 direct-prism datum form: + 两条原始 source line 唯一确定 shared endpoint 的 `SWEPT_EDGE`,以已证明的 direct blind prism span + 作 interpolation;以及一条原始 source line、明确 start/end role 的 `CAP_EDGE`,以同一 direct blind + prism 的 explicit physical cap frame 作 interpolation。三者都不注册 runtime topology record,仅供同一 + lowering pass 的 `qCreatedBy(cPoint, VERTEX)` 被既有 cPlane frame 规则读取。CDSL schema、profile + contract、capability preflight 与 executor 都要求三维有限坐标,executor 不改变 active body。 + `00240949` F3 生成 `[15.05, 0, 22.985]`,F4 使用该 origin,fresh incremental replay 保留 F1--F9 + executable prefix;F10 在既有 fillet selector capability 处停止。`00503730` F1/F2 也生成并消费 + direct datum,后续仍因无关 topology query 保持 partial。`00289068` F3/F4 分别以 `SWEPT_EDGE` 和 + `CAP_EDGE` source form 生成 `[25.85, -42.6, 38.1]` 与 `[25.85, -4.5, 76.2]`,F5/F7 完整执行。 + 该完整 history 的 fresh file-backed pipeline 为 `converted_complete` / `rebuilt_rejected`,strict 与 RP + 均未通过,故 datum/replay 成功不能被表述成模型几何通过。 + 1560 `00238956`、多个 entity、curve、derived suffix、非唯一 source vertex、非 blind/draft/非 direct + prism、out-of-range/non-finite parameter、跨 pass/runtime topology 和任何 geometry/stable-ID/current-body/ + STEP fallback 均稳定 defer;这不完成 `cPoint` 的一般 curve/topology 语义,也不扩大任何 runtime selector family。 + `SWEPT_EDGE` 另有一条不同的 1511 source-datum bridge:紧邻的 undrafted `new_body` + `extrude_add_blind` 必须保留完整未变的 direct source profile,而 OSD 必须刚好由两条不同的 + source profile edge 唯一确定一个 shared endpoint。lowering 以该 source vertex 的 start-cap copy + 和显式 start-to-end prism span 计算 `LINE_ANGLE` axis;它产生 reference-plane frame,不注册 + runtime selector、不查询当前 body、stable ID、几何相似度或 STEP。`00040198` F2 的单轴和 + `00722278` F2 的 axis-plus-Front-datum 两种形式均 `converted_complete` 并 fresh rebuild; + 两样本 fresh comparison 分别为 `00040198` rejected(下游 hole 几何不符)和 `00722278` RP pass + / strict precision diagnostic,不能把 datum-frame 结果概括为两者完整 strict 成功; + `00644299` F2 的另一参照是 derived `SWEPT_FACE`,仍稳定 defer。derived/combined query、非紧邻 + producer、draft、ADD/CUT/two-sided/IMPRINT/changed profile、非唯一 endpoint、其它版本和一般 + derived cPlane 均不在该 contract 内。 + `CAP_VERTEX` 也有一条单独的 1511 source-datum point bridge,供 `THREE_POINT` 和 + `PLANE_POINT` 的 cPlane 计算 frame,不能与 runtime extent selector 混用:owner 必须是 direct + undrafted `new_body` blind prism,lowered profile 与完整 original source profile 相同,OSD 必须恰为 + 两个 distinct non-construction source edge 且唯一共享一个 endpoint。lowering 在 source profile + plane 中计算该 endpoint,再只以 requested start/end prism cap 的显式 frame 平移;不会读取 runtime + topology、current body、stable ID、几何近邻或 source STEP。producer 后只允许 datum、独立的无 draft + `new_body` blind prism,或一个恰由同一 producer 的 qualified `extrude.start|end` CAP_FACE 移除的 + direct single-cap shell;后者仍读取 producer history 的 source-defined point,不将 shell result + vertex 当作 selector。hole/boolean/其它 dress-up/transform/COPY/pattern/delete、draft/ADD/CUT/two-sided、 + IMPRINT/split/multi-profile、重复或不共端的 OSD、其它版本均 defer。`00243142` 和 `00245768` 的 + F2 THREE_POINT 都产生并执行 reference plane。`00245768` 随后的 F4 还暴露并验证了通用 + analytic-contour region 修复:共享顶点或边的 closed loops 不再被 parity ray-cast 误分类为 + self-identical hole,只有所有 sampled boundary 点严格在 outer 内部的 loop 才是 hole;因此 + F4 可完整执行。`00053942` 在 F1 与 F4 间有 hole,正确以 `CAP_VERTEX datum source is unsupported` + defer。`00212904` F2 direct CAP shell 后的 F3 也完整 lower;mutating the shell to remove the other + CAP remains valid datum history for the same reason. fresh `output/cplane-cap-vertex-datum-20260912-r2` 三条均为 `rebuilt_rejected`:`00243142` + 和 `00245768` 都是完整 history 已执行但 RP 比较分别有 volume/area 与 volume/area/solid-count + mismatch,不能把 frame 或 runtime 成功宣称为 RP success。此路径不注册 selector intent 或放宽 + `CAP_VERTEX` runtime family,完整 THREE_POINT/PLANE_POINT cPlane 仍未完成。 + `PLANE_POINT` 的实体角色现按保留的 FeatureScript query kind 判定,而不再以是否出现 + `qCreatedBy` 猜测平面:一个 source `VERTEX` 与一个 `FACE`(包括 direct-prism `CAP_FACE`)才可 + 构成该 frame;两个 face、两个 vertex 或额外实体稳定 defer。`00228556` F3 使用 F1 的 direct + `CAP_FACE` 及 F2 的 direct source vertex,F1/F3/F6 已在 + `output/cplane-plane-point-cap-face-20260912` 完整执行并 RP 通过;strict 仍因 + volume/area 相对误差 `3.89e-5` / `7.29e-5` 未通过。这个判别只读取 source AST、已 lower 的 + physical cap frame 与 source vertex,不查询 runtime body、stable ID、几何近邻或 STEP;一般 + topology face/vertex、composition、歧义集合和完整 PLANE_POINT coverage 仍未完成。 + 既有圆柱母线 compatibility path 也已收紧为单一 direct `makeQuery` `CAP_EDGE` 或 `SWEPT_FACE` 的 source `skCircle`,要求 producer start/end frame 与另一显式 datum/ lowered reference plane;qAdjacent/qUnion composition、CAP/SWEPT line、trim/COPY/boolean source 均稳定 defer,不能因 AST 内出现 source token 获得 `_query_line` fallback。1511 `query.fs`/`evaluate.fs` 确实 @@ -748,11 +1571,38 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 ### P3:尚未支持的 FeatureScript 操作 -按全量诊断计数建立 operation registry,逐项为 `draft`、`thicken`、`split`、 -`moveFace`、`deleteFace`、`replaceFace`、完整 `transform`、`derive`、`import`、 -`bend_add` 建立 schema、lowering、runtime、adapter 和回归。外部资产或生成器缺失时, +按当前 source parser 建立版本化 operation registry,并与 conversion diagnostics 分开报告。 +`cadfs_to_cdsl.operation_registry.v1` 必须从每次 scan 的完整 FeatureScript source 生成,记录 +每个 observed operation 的 feature/sample 计数与 parse failure;路线项而未出现在当前 source 的 +operation 必须显示为 `not_observed_in_current_source`,不得由旧 output diagnostics 推断为已观测或 +已支持。parser 必须保留每个 direct `operation(context, id + "F...", definition)` 调用, +未知 operation 进入 history 并以 `unsupported_operation` defer,不能因名称未列入 parser 表而静默丢失。 +2026-09-14 的 9,347-history scan 有零 parse failure,`assignVariable` 为 34 次、`transform` 为 468 次; +`draft`、`thicken`、`split`、`moveFace`、`deleteFace`、`replaceFace`、`derive`、`import` 和 +`bend_add` 均为零次。因此这些零计数项仍是待接入的 P3 work item,尚无当前真实 source form 可用来 +完成多语料实现/回归;不得创建虚构的 CDSL mapping 或把 import declaration 误作 `import` feature。 +有实际 source 证据后,逐项建立 schema、lowering、runtime、adapter 和回归。外部资产或生成器缺失时, 必须将 capability 标为不可执行并提供部署诊断,不能在 import 时影响无关模型。 +`assignVariable` 现已完成当前 observed scalar source form 的纵向 contract:仅接受有序的、一次声明的 +非空 name,以及恰好一个有限常量 `anyValue` 或 `lengthValue` expression。lowering 只替换后续精确 +`getVariable(context, name)`,保留原有单位算术再交给目标参数的既有 number lowering;未声明、畸形 lookup、 +重复 name、双 value variant、非有限值、非标量/动态 expression 均显式 defer。CDSL `assign_variable` 是一个 +非 body-mutating、non-replayable context feature:schema、semantic validation、preflight 和 executor 都保证 +它不产生 body 或 topology record,也不改变 immediate producer/consumer 的 selector lifecycle。真实 +`00067276`(2 个 `anyValue`)、`00722518`(1 个 `anyValue`)、`00565626`(2 个 `lengthValue`)和 +`00865178`(3 个参与对称 depth 算术的 `anyValue`)均已在 fresh incremental rebuild 中执行变量 feature; +前三者保留其后续可执行 checkpoint,后两者的完整结果仍受独立 runtime/modeling 缺口影响。此条仅完成当前 +34 occurrences 的 scalar declaration/ordered lookup contract,不宣称通用 FeatureScript variable type、 +reassignment、map/array/enum、dynamic expression 或 runtime context state 支持。 + +FeatureScript 标量 parser 现额外将前缀 `+` / `-` 解析为既有的算术 AST,因而 +`-(138.6) / 2 * mm` 不再截断为字符串 `"-"`。这是一项通用的常量表达式修复: +`00292242` F2 的平移可重新 lower,source sketch origin 为 `[-69.3, 0, 0]`,并以既有非-copy +transform contract 烘焙进该 source sketch;parser 回归同时确认 `-(10 + 2) / 2 * mm` 仍经 number +lowering 得到 `-6 mm`。它不增加 transform family、body lifecycle 或任何 selector/query capability; +动态/非标量表达式仍按各自既有 contract defer。 + ### P4:比较、来源和性能 1. 保留 strict 与 RP 两层几何比较,并为被精确量快速拒绝的边缘模型提供可控的 @@ -816,11 +1666,12 @@ query family,不错误继承其内部 CAP/SWEPT component。显式 datum plane face、7 个 SolidWorks face 和 2 个 plane legacy selector,只有少数 direct-prism provenance selector。此次不把这些历史 geometry hint 宣称为 lineage:`00005267`(FeatureScript 1793)F4 的四个 loft `SWEPT_EDGE` 已从 strict geometry success 改为 F3 executable checkpoint 加 -`selector_query_unsupported`。同类未实现的 CAP/SWEPT/OFFSET/COPY/INTERSECT paths 必须保留 +`selector_query_unsupported`。同类未实现的 CAP/SWEPT/OFFSET/COPY/INTERSECT 及未覆盖的 BLEND +tuple 必须保留 候选、bound CDSL、最后 STEP/GLB(可生成时)和诊断,直到具备 source API evidence、lowering、 adapter history、resolver policy 和 corpus regression 的完整 contract。受限 -`CAP_EDGE@1511`、`SWEPT_FACE@1511` 和 `SWEPT_EDGE@1511` direct-prism contracts 不受影响; -它们仍只在 capability matrix 的已证明边界内执行。 +`CAP_EDGE@1511`、`SWEPT_FACE@1511`、`SWEPT_EDGE@1511`、`BLEND_EDGE@1511` direct-prism contracts 与 +`SWEPT_EDGE@1511/@2491` full-solid-revolve contracts 不受影响;它们仍只在 capability matrix 的已证明边界内执行。 2026-09-10 的 source-version migration 补齐了 import-level evidence:parser 逐条保留直接 `onshape/std/*` import 的 `{path, version}`,CDSL 顶层 `source_featurescript` 和嵌套 selector @@ -851,3 +1702,188 @@ build123d deprecation 与 workplane re-orthogonalization warnings)。真实 `0 `output/session-intersection-outer-gate-20260910` 强制 single-sample pipeline 验证:F7 outer deferred `INTERSECT` 在 incremental replay 中报告 `selector_query_unsupported`,并导出 F6 的三个 feature executable `rebuild.step` checkpoint;结果是 `rebuild_failed`,不是该 query family 的 RP 成功。 + +2026-09-13 selector 增量:`CAP_FACE@1511` 现在支持一个严格的 +`symmetric_direct_prism_two_sided_up_to_surface_cap_pair`。它不是双向 extent 的通用 +fallback:仅立即前驱、独立 `new_body`、无 draft、两端 blind 的 direct +`extrude_add_two_sided` 可向其后一个同时双侧 `up_to_surface` 的 extrusion 提供两个远端 +`MakePrism.LastShape` CAP role。两条 source query 必须来自同一 owner、完整且未变的原 +source profile,角色必须恰为 `{extrude.start, extrude.end}`;任一侧单独、同 role、不同 +owner、ADD/CUT、非盲端、IMPRINT/split/partial profile、后续 lifecycle 和所有 +geometry/stable-ID/current-body/STEP fallback 均拒绝。`00215642` F1 -> F3 的 fresh prefix +rebuild 解析两端 `operation_role` 并成功;`00935255` F7 的两个 owner 不同,保持 deferred。 +这只推进 S1/S2 中一个 CAP output-role consumer tuple,不改变“全量 selector、双向 +up_to_surface 与 body lifecycle 尚未完成”的路线判断。 + +2026-09-13 的 `INTERSECT` 真实语料复核没有扩大该 family 的 allow-list。独立强制 +pipeline `output/intersect-section-matrix-20260913` 重跑了先前 lowering 计数中的六个 +候选:`00739738`、`00380281`、`00962295`、`00980729`、`00491632`、`00789417`。其中只有 +`00491632` F4 是 immediate primary blind cut 的实际 +`source_qualified_primary_section`:两个 direct `SWEPT_FACE` anchor 经同一 OCC section +relation 唯一以 `kernel_intersection` 解析到 `body:f_F3:edge:36`;后续 chamfer 被 OCC 以 +`Failed creating a chamfer` 拒绝,故它是 selector execution evidence,而非 RP 或 strict +通过。`00789417` F12 是唯一 explicit `boolean_bodies` section-source form。其 `CAP_FACE` source +先前会把 F3 builder cap 与 F5 continuation 上缓存的同一 role 误判为多个 input snapshot;resolver +现只接受 F3 delta 中唯一 complete/proven builder-role relation,故在 F10 snapshot 唯一以 +`kernel_intersection` 解析 section edge。F11 没有该 section edge 到 active member 的 complete/proven +continuation,F12 在 F11 后仍正确报告 `selector_body_member_inactive`,不能改绑 F10、推断后继或以几何 +替代。因此该样本仍没有 F12 consumer 的 runtime comparison evidence,完整 replay 现保留 F11 prefix。 + +同次运行时修复补齐了 compound body 中单 member dress-up 的历史保留:当所有 fillet/chamfer +selected edges 以 exact `IsSame` 唯一归属一个 solid member 时,adapter 只向该 member 的 OCC +builder 请求 `Modified`/`Generated`/`IsDeleted` history,并将其余 member 不变地放回 compound; +session 仅在每个未选 member 与一个 result member 逐个 reciprocal `IsSame` 时记录 +`body_member_preserve`。跨 member selection、edge 归属不唯一、多个 changed member、非等距/face +supported chamfer 和 builder failure 仍回到无 lineage 的既有执行路径。`00789417` F11 现导出其 +target member 的 17 条 kernel relations 与未选 member preservation;其 section edge 仍被 OCC +`IsDeleted`,无 `Modified`/`Generated` result,故不得从物理 result membership 伪造 F10 -> F11 +continuation。这是 operation-wide body/member topology foundation,不增加 general INTERSECT 或 +dress-up selector completion。 +另外四个并非已接受 section contract:它们要么是 deferred `feature_script_query`、要么在 +更早的 selector/lifecycle 或其它 feature capability 处停止。该矩阵结果为 4 +`rebuild_failed`、1 `runtime_ineligible`、1(修复前)`parse_failed`;修复后 `00789417` 为 +`converted_partial` / `rebuild_failed` 并保留 F10 checkpoint。修复只让 +`BLEND_EDGE` 的 `feature_script_query` + `multiplicity:none` preservation state 与已存在的 +`INTERSECT` deferred state 同样可通过 semantic validation;它禁止该 state 声称 +`blend_sources`,runtime 仍在 provenance/geometry fallback 前拒绝。由此保留先前可执行模型, +但不增加 generic `INTERSECT`、explicit boolean、BLEND_EDGE 或 section-edge completion claim。 + +2026-09-13 的 `OFFSET_EDGE` source-form 审计登记了下一条 selector 工作项,但没有开放 +allow-list。全量 parser 扫描得到 111 个 direct `OFFSET_EDGE` occurrence,主要是 1511 的 +shell 后 `fillet`/`chamfer`。必须先区分两种不可互换的 FeatureScript form:(a) 一个 direct +source profile edge,外加同一 direct-prism `CAP_EDGE` role 的 TDD;(b) 无 TDD 的一个或多个 +source-edge OSD pair。`output/offset-edge-shell-audit-20260913` 中,`00000316` 与 +`00059593` 已证明 shell builder 能对部分 direct cap-edge 输入给出 complete/proven 的 final +`shell.wall` edge relation(分别为双向圆筒 inner rim 与矩形 wall edge)。这只能成为 form (a) +的潜在 source witness,不能解释 form (b) 的 vertex/component query;`00127440` 更在一个 +fillet 中混合了两者,`00154938` 则显示 non-immediate mutation 需要独立 lifecycle proof。 +后续实现须同时具备 versioned source contract、same source-edge/role 的 exact shell relation、 +active member、set-valued dress-up consumer 和多历史 runtime matrix;不得用 output role、 +source role、face-boundary traversal、stable ID、geometry、current body 或 source STEP 代替。 + +随后对这个候选复核发现先前将 `OFFSET_EDGE` 误限为 `shell.wall`:一侧 direct-prism 的 +真实 TDD 例子 `00650671`、`00768679` 请求 `CAP_EDGE:start`,而 shell 删除相反的 +`CAP_FACE:end`。它们的正确 witness 不是 deleted-cap 的 inner wall,而是 source edge 到 +`extrude.start` CAP boundary、再到 shell active member 的 complete/proven one-to-one +continuation。现已注册严格的 `direct_prism_shell_offset_edge_tdd`:outer `OFFSET_EDGE` 必须 +恰有 singleton OSD 和 singleton TDD(CAP_EDGE),二者指向同一 direct original source edge; +producer 必须是 1511、independent、undrafted、one-sided blind `new_body` prism;immediate +inward shell 只能依赖该 producer 并删除相反 cap;immediate fillet/chamfer 才能消费。resolver +从 source anchor 开始,以 TDD role 过滤 prism boundary,再要求完整 active shell continuation, +不会选择 `shell.wall`、face boundary、stable ID、geometry、current body 或 source STEP。 +`00650671` F3 因而执行并保留 F3 checkpoint,后续 F4 的 CAP_EDGE ambiguity 独立失败; +`00768679` F8 完整 rebuild,但 fresh `output/offset-edge-tdd-matrix-20260913` 的 RP/strict +comparison 仍 rejected(volume/area mismatch),故这是执行证据而非模型质量通过。`00791920` +的同一 fillet 混合四个 OSD-only 与四个 TDD leaves,整组仍按 set 语义拒绝;two-sided +`00000316`、non-immediate `00027838`/`00377525`、mixed `00127440`、mutated `00154938`、 +其它版本均不在此 TDD contract。 + +OSD-only 的 source form 现另有 `direct_prism_shell_offset_edge_vertex`,但它不是 retained-cap +的替代实现:outer `OFFSET_EDGE` 必须恰有一个 OSD,其中恰有两个不同的 direct original profile +edge,且仅共享一个显式 source endpoint。该 endpoint 的 direct-prism `SWEPT_EDGE` 必须经 immediate +inward shell 的 complete/proven one-to-one continuation 到 active member;producer 仍严格为 1511、 +independent、undrafted、one-sided blind `new_body` prism,shell 只依赖 producer 且删除一个 CAP face, +consumer 只能是紧邻 fillet/chamfer。resolver 从 grouped source-vertex anchor 开始,允许 boundary 和 +continuation,但不把 deleted-cap `shell.wall`、cap edge、face boundary、stable ID、geometry、current +body 或 source STEP 当作证据。`00059593` 的四个 OSD-only OFFSET leaves 与其余 direct prism leaves +均解析,完整 history 在 `output/offset-edge-vertex-matrix-20260913` 中 rebuild;其 comparison 是 +rejected,故仍只是执行证据。`00791920` 的 OSD-only vertex leaves 与 TDD retained-cap leaves 混在同一 +set,lowering 保留 TDD leaves、将 OSD leaves deferred,整组 `rebuild_failed`,绝不将两种 source +semantics 合并。two-sided `00000316`、non-immediate/mutated、partial/IMPRINT/split profile、multiple +removal faces、ADD/CUT、COPY/pattern/transform、OSD component/non-unique vertex、mixed set、其它版本和 +所有 fallback 仍拒绝。`QUERY_SET` parent 不会绕过 leaf lifecycle gate:semantic validation 与 +capability preflight 都递归复核 TDD/vertex leaf 的 shell、producer、source anchor 与 consumer;general +OFFSET_EDGE、inner shell wall 和 general component/vertex 语义仍未完成。 + +后续的 `TDD(SWEPT_EDGE)` 也不能借用上述任一 contract。全量文本扫描仅发现三个同时含 +`OFFSET_EDGE` / `SWEPT_EDGE` 的 history:`00239888` 的 direct-prism form 仍是 IMPRINT profile,且 shell +之前已有 fillet;`00882527` 经 `BLEND_EDGE`,`00902029` 经 revolve/`BLEND_EDGE`。因此它们都没有 +independent direct-profile prism -> immediate shell -> complete active continuation 的证据。`00239888` 的 +真实回归固定为 `converted_partial`,在 F6 保留 `swept edge source endpoint provenance is unsupported`, +绝不将 `TDD(SWEPT_EDGE)` 改写成 CAP-edge retained boundary 或 OSD-only source vertex。此 family 仍需要 +多个符合 source/lifecycle 约束的真实 history、完整 final-snapshot relation 和 dress-up replay evidence; +IMPRINT/materialized profile、pre-shell dress-up、BLEND/revolve successor 及所有 fallback 继续拒绝。 + +2026-09-14 selector semantic-slot reconciliation: a proven `QUERY_SET` located in an +operation's declared `feature.selectors` slot now carries that slot recursively to +its ordered `query_operands`. This permits the already-contractual immediate +`CAP_FACE` output-role leaves of a fillet/chamfer set to be validated as actual +consumers, instead of falsely treating them as arbitrary nested mappings. Every +leaf still receives its own versioned source query, runtime-snapshot evidence, +owner ordering, direct blind-prism/planar-IMPRINT consumer check, and set +cardinality/provenance validation. `00965724` F2's `{extrude.start, extrude.end}` +CAP union now converts completely and rebuilds; a mutated nested owner is rejected. +This does not admit output roles in metadata, non-`query_operands` structures, +other operation slots, generic CAP/SWEPT lifecycle, or geometry/stable-ID/current- +body/source-STEP fallback. + +2026-09-14 semantic-rejection prefix preservation: a semantic-invalid complete +candidate remains diagnostic-only as `candidate.invalid.cdsl.json` and is +never passed to the runtime. The pipeline now separately seeks the longest +contiguous CDSL feature prefix that independently passes semantic validation, +saves it as `candidate.prefix.cdsl.json`, and may rebuild it only into +`prefix.rebuild.*` artifacts. Top-level conversion status remains +`semantic_validation_failed`; a prefix rebuild never converts it into a full +candidate success. The regression forces a two-feature real history to reject +only its complete candidate, verifies the one-feature prefix independently, +and rebuilds that prefix. No source query, selector, operation, geometry, +stable-ID, current-body or STEP fallback is admitted by this recovery path. + +2026-09-14 executable-owner reconciliation: a fillet/chamfer source selector +whose outer FeatureScript owner has not been lowered into the executable CDSL +prefix now causes an attributable `selector_owner_unavailable` lowering defer. +It no longer emits a supported selector with an absent owner and turns a +recoverable partial model into `semantic_validation_failed`. `00508029` F14 is +correctly absent after its non-executable profile, F16 defers with that typed +diagnostic, and the retained CDSL converts partially and rebuilds; its strict +and RP comparison is rejected, so this is prefix-preservation evidence only. This does +not resolve the deferred `INTERSECT` query, infer a producer, or add any +stable-ID, geometry, current-body, or source-STEP fallback. + +2026-09-14 attached planar-IMPRINT runtime foundation: an already proven +runtime sketch attachment may now retain its exact native planar face in live +session state and pass it to the planar-IMPRINT OCC splitter as the support +argument. This avoids replacing an attached support with the existing artificial +finite workplane box. The face never enters CDSL, selector metadata, stable IDs +or a geometry fallback. The contract is only applicable when selected source +curves exactly partition that support; internal unattached curve fragments do +not become a region merely because they lie on the same plane. Unattached +IMPRINT retains its prior unbounded-region rejection. This is foundation only +for generic use: attached CAP-edge anchors, their support-boundary relation, +source-region membership, `CAP_EDGE` profile lowering and all generic attached +IMPRINT/COPY/MERGE forms remain deferred until a complete/proven kernel relation +and multiple real-history evidence exist. The single direct-prism CAP-edge +source tuple documented below is deliberately separate from this generic claim. + +2026-09-14 direct CAP_FACE runtime workplane: a `newSketch` can now attach to +one direct blind independent prism cap under the explicit +`direct_prism_cap_face_workplane` contract. It retains the source CAP query and +requires the immediate feature consumer to resolve the builder's active native +output role; no static plane, record ID, geometry or aggregate-body inference +is serialized. `00126630` F1 -> F2 lowers as this attachment, and an +equivalent immediate consumer resolves by `operation_role`; delayed consumers +are rejected by semantic validation. This supplies the support face for the +bounded attached IMPRINT contract below; CUT/ADD, boolean/COPY/pattern +successors and all generic CAP_FACE sketch-host behavior remain deferred. + +2026-09-14 attached IMPRINT external-boundary contract: `planar_imprint` can +now retain a named external edge selector only under a runtime face attachment; +the fragment selects that external anchor instead of a same-sketch curve. +Execution resolves the edge in the active feature context and accepts it only +when native `IsSame` proves that it is a boundary of the already resolved +support face. The splitter receives that live edge only in session memory; no +edge geometry, record ID or support topology is serialized into materialized +sketch data. Missing, dual, non-edge or non-boundary anchors reject. Generic +use remains runtime/CDSL foundation only. A narrow source admission now exists +for `00126630`'s direct-prism form: one local circle, one direct +same-owner CAP-edge, one `INTERSECT(VERTEX)` fragment witness, and the immediate +`direct_prism_cap_face_workplane` attachment from that same output role. The +lowerer emits an external anchor only after every predicate holds; session +requires the resolved edge to be an exact support boundary, and converts source +IMPRINT face-side orientation only in the live attachment frame. F1 -> F3 +lowers and its prefix rebuilds with CAP face `operation_role` and CAP edge +`kernel_lineage` evidence; mutating the CAP-edge owner preserves the old named +F3 defer. This is one real history and does not complete CAP-edge/attached- +IMPRINT family coverage: multiple anchors/fragments, B-splines/periodic seams, +SWEPT_FACE/COPY/MERGE hosts, CUT/ADD, later lifecycle, pattern/boolean and +generic region cardinality remain deferred pending multiple-corpus evidence. diff --git a/cadfs_to_cdsl/FEATURESCRIPT_QUERY_CAPABILITY_MATRIX.md b/cadfs_to_cdsl/FEATURESCRIPT_QUERY_CAPABILITY_MATRIX.md index 236392d2..adff906c 100644 --- a/cadfs_to_cdsl/FEATURESCRIPT_QUERY_CAPABILITY_MATRIX.md +++ b/cadfs_to_cdsl/FEATURESCRIPT_QUERY_CAPABILITY_MATRIX.md @@ -10,15 +10,211 @@ FeatureScript language version. The lowerer preserves every direct `onshape/std/*` import as `{path, version}` metadata, but an unregistered library revision is not treated as compatible merely because its path matches. +The planned expansion of query algebra, topology lineage and body lifecycle is +defined in [CADFS_FULL_CAPABILITY_TARGET.md](CADFS_FULL_CAPABILITY_TARGET.md), +under “Selector 精度与覆盖目标(2026-09-10 起)”. A row is admitted only after +its explicit source/CDSL contract, exact kernel evidence and consumer semantics +are tested; source STEP comparison remains separately reported and does not +promote a restricted row into general family support. + +`SWEPT_EDGE@1511` direct-prism dress-up continuation update (2026-09-12): a +direct source vertex may retain its existing `boundary + continuation` policy +past the producer's feature position only when the topology registry proves a +complete, one-to-one, active lineage. `00094474` F3 is strict after an +intervening datum feature; `00027017` F3 is rejected as non-unique after a +body mutation and retains its F2 checkpoint. `00370320` F4 proves a direct +`extrude boundary -> subtract continuation` and is RP passing. This changes +neither the direct profile, version, body-lifecycle nor no-fallback +restrictions in the rows below. Source anchors are additionally scoped to the +selector's explicit producer owner: a later operation that materializes the +same source sketch entity or endpoint pair cannot become a second anchor. +`00407468` F3 verifies four `qUnion(SWEPT_EDGE)` operands from F1 through F2; +F1--F3 replay and RP comparison pass in +`output/swept-edge-producer-anchor-00407468-20260912` (strict area/volume +precision checks do not pass). The rule does not admit generic repeated-source, +boolean, IMPRINT, COPY, pattern, or N:M lineage forms. + +`CAP_FACE@1511` symmetric direct-prism shell update (2026-09-12): an immediate +shell may remove either or both physical caps of an independent, undrafted, +`new_body` symmetric blind prism whose profile is exactly one original circle. +Each CAP OSD must name that sole circle source edge. The two `MakePrism` +builders share the source plane, so the executor maps only their far exact +handles to `extrude.end` and `extrude.start`, checks final-snapshot `IsSame`, +and never exposes the source-plane seam as a cap. `00019252` removes both +caps and RP-passes; `00000316` resolves both caps and executes its shell before +an unrelated `OFFSET_EDGE` query stops later replay. This bridge is limited to +the immediate shell consumer: multi-edge/hole/IMPRINT/split profiles, draft, +ADD/CUT, non-blind extents, continuation, other consumers/versions, and all +geometry, stable-ID, current-body, or source-STEP fallback remain deferred. + +`CAP_FACE@1511` initial direct-sweep shell update (2026-09-12): an immediate +shell may consume one `sweep.start` or `sweep.end` only where the producer is +an independent `new_body` direct PipeShell sweep and its CDSL contract retains +one profile source edge plus one source path edge. The CAP OSD must name that +exact profile edge and the matching path `start`/`end` vertex; after any +lowering path reversal, the source endpoint is explicitly mapped to the +physical PipeShell output role before the resolver requires its unique +complete active builder relation. `00330012` F3 resolves `sweep.end` and +rebuilds. `00658358` F4 keeps its F3 checkpoint because its CAP source pair +does not satisfy this contract. No geometry, stable-ID, current-body or source +STEP fallback is introduced; segmented/spatial/hollow/fused/additive sweeps, +copy/pattern successors, later lifecycle, non-shell consumers and generic +sweep CAP queries remain deferred. + +`CAP_EDGE@1511` initial direct-sweep dress-up update (2026-09-12): an +immediate `fillet`/`chamfer` may consume one cap edge from an independent +`new_body` direct PipeShell sweep only when its profile is one retained direct +circle edge and its CAP OSD contains exactly that edge plus the corresponding +source path endpoint. PipeShell has no per-profile-edge `FirstShape`/`LastShape` +overload, so the adapter records the relation only when the builder-proven cap +face has exactly one final-snapshot boundary edge (`IsSame` verified). The +runtime additionally receives an exact source-edge anchor and resolves only +the complete/proven role-qualified boundary relation. A `00330012` CAP_EDGE +source variant resolves by `kernel_lineage` and rebuilds; a contradictory +endpoint variant keeps its F2 checkpoint. Multi-edge/inner-wire/split profile, +segmented/spatial/hollow/fused/additive sweep, continuation, copy/pattern, +later lifecycle, other consumers and generic CAP_EDGE remain deferred. No +geometry, stable-ID, current-body or source STEP fallback is allowed. + +`CAP_EDGE@1511` primary ADD union-continuation update (2026-09-13): an +immediate `fillet`/`chamfer` may follow a direct, undrafted blind +`extrude_add_blind` only when its default ADD/fuse has exactly one old active +solid, one direct-prism tool solid, complete source anchors, and exact +`BRepAlgoAPI_Fuse` history. The tool is registered only as a transient prism +snapshot; its cap edge becomes selectable only through one complete/proven, +one-to-one `extrude -> union` continuation into the active final member. +`00953397` F7 and `00957101` F4 both resolve by `kernel_lineage` and are RP +approximate. `00406939` has a multi-solid ADD tool and remains a deferred +FeatureScript query. Split/merge, partial/missing/fuzzy union history, +IMPRINT/partial profile, non-CAP_EDGE family, later lifecycle, COPY/pattern, +and all geometry, stable-ID, current-body, or source-STEP fallbacks remain +outside this bridge. + +`CAP_FACE@1511` primary ADD shell union-continuation update (2026-09-13): an +immediate shell may consume one cap role from a direct, undrafted blind +`extrude_add_blind` only when its default ADD/fuse has one old active solid, +one direct-prism tool solid, and exact `BRepAlgoAPI_Fuse` history. The tool is +transient; the resolver must establish a complete/proven one-to-one +`extrude -> union` continuation into the active final member before handing +the face to shell. In a 27-history fresh replay scan, seven histories had that +exact relation; `00074047` F4 and `00350698` F7 resolve it by `operation_role`, +then their native shell operation independently fails. `00293014` remains +`selector_output_role_ambiguous`; `00590599` multi-solid ADD remains +`selector_query_unsupported`. This does not authorize extent, dress-up, +multiple removals, split/merge, multi-solid/IMPRINT/partial profile, +missing/partial/fuzzy union history, later lifecycle, COPY/pattern, or any +geometry, stable-ID, current-body, or source-STEP fallback. + +`CAP_FACE@1511` primary ADD up-to-surface union-continuation update (2026-09-13): +a one-sided immediate `up_to_surface` extent may consume one primary-ADD cap only +under the separately tagged `primary_add_up_to_surface_union_continuation` +contract. The producer is an undrafted blind `extrude_add_blind` defaulting to +ADD/fuse; its transient direct-prism cap becomes selectable only after one old +active solid plus one tool solid yield exact complete/proven one-to-one +`extrude -> union` history into the active final member. `00007973` F5 -> F7 +resolves by `operation_role` and freshly rebuilds. `00638700` F3 -> F5 records +an ambiguous union successor and is rejected as `selector_output_role_ambiguous`. +Two-sided extents, non-immediate/later consumers, split/merge/multi-solid, +draft, incomplete/fuzzy history and all geometry, stable-ID, current-body, or +source-STEP fallback remain outside this bridge; it does not complete general +CAP_FACE or `up_to_surface` coverage. + +`CAP_FACE@1511` symmetric two-sided up-to-surface pair update (2026-09-13): +an independent direct `new_body` `extrude_add_two_sided` may consume the two +opposite far caps of its immediately preceding symmetric direct prism only as +one `symmetric_direct_prism_two_sided_up_to_surface_cap_pair`. Both producer +ends must be blind and undrafted; each source CAP query must be a singleton +direct `qUnion(makeQuery(... CAP_FACE ...))` whose OSD names the complete, +unchanged original profile from one source sketch. The pair must have the same +owner and exactly `{extrude.start, extrude.end}`; neither side is legal alone. +The executor's two `MakePrism.LastShape` handles are already final-snapshot +roles, and runtime resolves each through complete/proven `boundary` evidence. +Fresh `00215642` F1 -> F3 resolves both roles and rebuilds its executable +prefix. `00935255` F7 names two different owners and remains deferred. CUT, +ADD/fuse, mixed/non-blind ends, draft, partial/IMPRINT/split profile selection, +same-role or different-owner pairs, non-immediate/later lifecycle, other +versions, and every geometry/stable-ID/current-body/source-STEP fallback remain +rejected; this does not complete generic two-sided `up_to_surface` or +`CAP_FACE` coverage. + +`SWEPT_FACE@1511` initial direct-sweep dress-up update (2026-09-12): an +immediate `fillet`/`chamfer` may consume one side face from the same independent +`new_body` direct PipeShell sweep only when its OSD contains exactly one edge +from a complete direct analytic closed profile and one direct source path edge. +The adapter records only `Generated(profile_edge)` face outputs that remain in +the final solid by `IsSame`; the runtime resolves from the exact profile-edge +anchor, while the path edge remains disambiguation evidence. `00330012` circle +and four-edge source-form variants resolve by `kernel_lineage` and rebuild. A +mismatched source pair is kept as a deferred query without geometry or stable-ID +fallback; `00954785` confirms that a multi-segment qUnion path cannot inherit a +representative source edge. Inner/split, segmented/spatial/hollow/fused/additive, continuation, +copy/pattern/later lifecycle, shell/extent/sketch-host consumers and generic +sweep side-face semantics remain deferred. + +`CAP_FACE@1511` direct-prism workplane update (2026-09-14): a following +`newSketch` may attach to one physical cap of the immediately preceding, +independent, undrafted, blind `extrude_add_blind` `new_body` prism. Lowering +keeps the CAP output-role selector and a `direct_prism_cap_face_workplane` +consumer contract; runtime resolves the live native face before rebuilding the +sketch coordinates. Semantic validation rejects a delayed or multiple consumer. +`00126630` F1 -> F2 supplies real lowering evidence, while an equivalent +direct consumer resolves by `operation_role`. The separately constrained +attached CAP-edge IMPRINT tuple below is its only profile-region consumer. This +admits neither other CAP-edge profile regions, ADD/fuse, CUT, symmetric +or drafted/non-blind producers, later lifecycle, COPY/pattern/boolean +successors, nor static frame, stable-ID, geometry, current-body or STEP +fallback. + +Attached planar-IMPRINT external-boundary foundation (2026-09-14): the CDSL +profile contract can name a session-resolved edge as an external fragment +anchor only when a proven runtime face attachment supplies the splitter support. +Runtime requires that the selector resolve to one edge which is `IsSame` to a +native boundary of that support face. It is generally only a CDSL/runtime +foundation; the one source-qualified CAP-edge form below is the sole current +exception. + +Attached direct-prism CAP-edge IMPRINT profile tuple (2026-09-14): FeatureScript +1511 may extrude one selected local circle from a preceding attached +sketch only when its sole `INTERSECT(VERTEX)` witness pairs that curve with one +direct `CAP_EDGE` from the exact same immediate independent blind `new_body` +prism and exact CAP-face attachment role. The lowerer emits one +`planar_imprint.external_anchors` selector, not boundary geometry; semantic +validation requires its runtime face attachment, and runtime proves both the +CAP edge's kernel lineage and native `IsSame` support-boundary membership before +the splitter consumes it. When native attachment orientation reverses the +source sketch normal, the session converts IMPRINT `face_side` only in its +live materialized frame. `00126630` F1 -> F3 lowers completely and its F1/F3 +prefix rebuilds (`165528 mm3`), resolving the CAP face by `operation_role` and +CAP edge by `kernel_lineage`; its later F4 CAP-edge dress-up remains deferred. +The owner-mutation rejection returns F3 to +`extrude_profile_topology:cap_edge`. This is one real-history source tuple, +not general CAP-edge profiles: multiple anchors/fragments/local curves, +non-analytic or periodic curves, other attachment types, CUT/ADD, delayed or +continued producers, copies/patterns/booleans, and all unproven region +cardinality remain deferred. + | Query family | FeatureScript version | Source evidence | CDSL/runtime contract | Verified boundary | | --- | --- | --- | --- | --- | -| `CAP_FACE` | `1511` | CADFS FeatureScript 1511 exported query history | Direct `extrude.start` / `extrude.end` builder output role; an immediate `up_to_surface` reference or a shell face-removal selector may consume that role | The consumer must immediately follow a direct `extrude_add_blind` `new_body` blind producer, with a complete/proven cap role and no stable-ID, binding-ID, or geometry fallback. `00925274` F3 resolves `extrude.start` for `up_to_surface`; `00212904` F2 and `00789939` F2 resolve immediate shell removals through `extrude.start` and `extrude.end`, respectively. Fresh pipelines preserve the distinction between execution and similarity: `00212904` executes through F5 but is RP-rejected, while `00789939` executes F2/F5 then preserves the F5 checkpoint when F6 remains unsupported. Later mutations, draft-with-holes, fused/multi-region results and generic CAP queries remain unsupported. | -| `CAP_EDGE` | `1511` | CADFS FeatureScript 1511 exported query history; `BRepPrimAPI_MakePrism.FirstShape(source_edge)` / `LastShape(source_edge)` exact cap-edge handles | A direct fillet/chamfer consumer of an independent, undrafted, `new_body` blind prism: one exact direct source-profile edge -> one role-qualified `extrude.start` or `extrude.end` cap edge, followed only by complete/proven one-to-one continuations to the active body | The selector must name one retained direct source entity and explicit CAP side. The adapter accepts the cap handle only after `IsSame` verifies it is present in the final snapshot; resolver follows the source anchor and role through `boundary`, and accepts subsequent `continuation` only when every relation is complete/proven and operation cardinality remains one-to-one. A direct all-circle annulus with one contained circle is included when both source wires retain exact final-face membership; outer/inner roles remain distinct. A direct analytic region with a solver-split circular hole is included only when all four arcs carry the same explicit logical-circle source marker and that source maps uniquely to one unsplit profile circle; the adapter reconstructs one native wire before the same final-face check. Corpus evidence: `00021014` F2 resolves nine non-hole start/end selectors and is strict/RP passing; hole matrix `00479470`, `00501170`, `00526649` is RP passing and `00621329` is strict/RP passing. `00694309` F4 resolves F1's start CAP edge through F3's proven primary-cut continuation and is strict/RP passing. `00566233` resolves both F2 selectors before an OCC chamfer feasibility failure, while `00614954`/`00678961` resolve F2 before later unsupported queries. `00735367` resolves F2 from its line-outer/circular-hole region and executes F3 before an unrelated F4 unsupported query. Incomplete/mixed logical-circle markers do not create a circular-hole anchor; partial/branched continuation, trimmed/split profiles, multi-region-with-hole profiles, draft, multiple/two-sided extents, fused results, and unproven boolean/SPLIT/COPY/pattern continuations are rejected. This is not general CAP_EDGE replay. | +| `qUnion` / `qIntersection` / `qSubtraction` / `QUERY_SET` | `1511` | CADFS exported set-query syntax with exact recursive `query_expr@1.0` nodes; `qIntersection`/`qSubtraction` ordering semantics additionally verified against the local Onshape 1511 standard-library mirror | A `proven_operand_*` parent may be consumed by `fillet` or `chamfer` when every recursive branch ends in a direct, kernel-proven `makeQuery` leaf of one face/edge kind. Each parent declares `active_member` scope, reject-empty/all-multiple policy, and ordered child selectors; nested set nodes retain their own source expression and contract | This remains a set compositor, not a leaf-query allow-list entry. Every branch must have the identical FeatureScript/library tuple, exact matching AST node, runtime-snapshot evidence, an existing executable provenance contract, and complete active results. The resolver recursively evaluates children, preserves operand order, deduplicates exact record IDs for union, and applies exact-record intersection/subtraction. A `QUERY_SET` in the declared `feature.selectors` slot of `fillet`/`chamfer` may pass that slot only to recursive `query_operands`, so an already-contractual immediate direct-prism/planar-IMPRINT `CAP_FACE` output-role leaf receives the normal owner, lifecycle and cardinality checks; `00965724` F2's `{extrude.start, extrude.end}` union converts completely and rebuilds. Output roles in metadata or non-`query_operands` structures, output-role leaves without that existing direct contract, mixed kind/version, geometry/stable-ID/binding evidence, filters, `qAdjacent`/owner/body queries, and shell/extent/hole/transform/reference consumers remain rejected, as do empty or partial/inactive branches. `00000715` F2 and `00354246` F2 remain valid evidence; a nested-union variant of `00000715` now also rebuilds successfully. This does not constitute general query-family completion. | +| `CAP_FACE` | `1511` | CADFS FeatureScript 1511 exported query history; direct prism `BRepPrimAPI_MakePrism` cap handles, direct `BRepOffsetAPI_ThruSections.FirstShape/LastShape` face handles for the loft subset | Direct `extrude.start` / `extrude.end` builder output role; an immediate `up_to_surface` reference or shell face-removal selector may consume the one-sided prism role. An immediate shell may additionally consume either exact far cap of a direct one-circle symmetric blind prism, or `loft.start` / `loft.end` from the initial direct-loft subset. | One-sided prism consumers must immediately follow a direct `extrude_add_blind` `new_body` blind producer. The symmetric shell-only subset requires FeatureScript 1511, one original circle source edge, independent undrafted `extrude_add_two_sided`, both blind ends, exact far-cap final `IsSame` roles and no continuation; `00019252` RP-passes after removing both caps and `00000316` resolves both caps before a later unrelated selector stops. The loft subset is FeatureScript `1511` only: exactly two direct closed IMPRINT sheet profiles, default/`NEW` operation, no prior solid, wire profiles, connections, matching or endpoint derivative options; the CAP OSD must name exactly one of those profile sources. `isStart` is not used to choose the role: source-profile position maps to `loft.start`/`loft.end`, then the resolver requires its unique complete active builder relation. `00023963`, `00051031`, and `00059941` resolve the shell role and execute; their final RP comparisons are rejected. `00157619` and `00139197` resolve before native OCC thick-solid failure. `00925274` F3 resolves prism `extrude.start` for `up_to_surface`; `00212904` F2 and `00789939` F2 resolve immediate prism shell removals through `extrude.start` and `extrude.end`. Multi-edge/hole/IMPRINT/split symmetric profiles, draft, ADD/CUT, non-blind ends, later mutations, other loft versions or forms, non-immediate consumers, and generic CAP queries remain unsupported. No stable-ID, binding-ID, geometry, current-body, or source-STEP fallback is used. | +| `CAP_FACE` primary ADD dress-up | `1511` | `00051494` F3 -> F4 and `00660816` F3 -> F4 direct exported histories; transient direct-prism cap plus exact `BRepAlgoAPI_Fuse` relation | A default ADD/fuse `extrude_add_blind` CAP role may drive its immediately following `fillet` or `chamfer` through `primary_add_dressup_union_continuation` | The producer must be undrafted blind ADD/fuse with one old solid and one transient direct-prism tool. Runtime resolves only a complete/proven, one-to-one `extrude -> union` active-member successor, then expands the resolved physical face to actual body-boundary edges while excluding periodic seams. `00051494` resolves by `operation_role` and rebuilds; `00660816` has a one-to-many successor and remains `selector_output_role_ambiguous`. Shell/extent use separate contracts. Multi-cap sets, split/merge/multi-solid/IMPRINT/partial profiles, draft, non-immediate/later lifecycle, other consumers, stable-ID/binding-ID/geometry/current-body/source-STEP and transient-tool fallback remain rejected. This does not complete CAP_FACE, ADD, dress-up or S1 lifecycle coverage. | +| `CAP_EDGE` | `1511` | CADFS FeatureScript 1511 exported query history; direct-prism `BRepPrimAPI_MakePrism.FirstShape(source_edge)` / `LastShape(source_edge)` exact cap-edge handles, exact single-solid `BRepAlgoAPI_Fuse` history for the primary-ADD subset, plus the one-edge direct-PipeShell cap-face boundary witness | A direct fillet/chamfer consumer of either (a) an independent, undrafted, `new_body` blind prism: one exact direct source-profile edge -> one role-qualified `extrude.start` or `extrude.end` cap edge, followed only by complete/proven one-to-one continuations to the active body; (b) a default primary ADD/fuse of one old active solid plus one direct blind-prism tool solid, through one exact `extrude -> union` continuation; or (c) the immediately preceding independent `new_body` direct PipeShell sweep with one direct circle profile edge and one direct path edge -> one role-qualified `sweep.start` or `sweep.end` cap edge | Prism selectors must name one retained direct source entity and explicit CAP side. The adapter accepts a prism cap handle only after final-snapshot `IsSame`; resolver follows its source anchor and role through `boundary`, and accepts a continuation only when every relation is complete/proven and operation cardinality remains one-to-one. The primary-ADD tool is transient and never directly selectable: lowering permits it only for FeatureScript 1511 `CAP_EDGE`, immediate fillet/chamfer, direct undrafted blind `extrude_add_blind`, default ADD/fuse, singular old/tool solids and exact union history. `00953397` F7 and `00957101` F4 resolve `extrude -> union` and are RP approximate; `00406939` multi-solid ADD remains deferred. The sweep subset has no PipeShell per-edge history overload: it requires exact profile/path-endpoint OSD, a singleton profile/cap boundary edge, immediate fillet/chamfer consumer, and only the initial `boundary` relation. `00330012` CAP_EDGE source variant resolves by `kernel_lineage` and rebuilds; contradictory endpoint source is rejected with an F2 checkpoint. A direct all-circle annulus with one contained circle is included for the prism form when both source wires retain exact final-face membership; outer/inner roles remain distinct. A direct analytic region with a solver-split circular hole is included only when all four arcs carry the same explicit logical-circle source marker and that source maps uniquely to one unsplit profile circle; the adapter reconstructs one native wire before the same final-face check. Corpus evidence: `00021014` F2 resolves nine non-hole start/end selectors and is strict/RP passing; hole matrix `00479470`, `00501170`, `00526649` is RP passing and `00621329` is strict/RP passing. `00694309` F4 resolves F1's start CAP edge through F3's proven primary-cut continuation and is strict/RP passing. `00566233` resolves both F2 selectors before an OCC chamfer feasibility failure, while `00614954`/`00678961` resolve F2 before later unsupported queries. `00735367` resolves F2 from its line-outer/circular-hole region and executes F3 before an unrelated F4 unsupported query. Incomplete/mixed logical-circle markers do not create a circular-hole anchor; partial/branched continuation, trimmed/split profiles, multi-region-with-hole profiles, draft, multiple/two-sided extents, multi-solid/IMPRINT primary ADD, missing/partial/fuzzy union history, later lifecycle, and unproven boolean/SPLIT/COPY/pattern continuations are rejected. The sweep form additionally rejects multi-edge/inner-wire/split profile, segmented/spatial/hollow/fused/additive path, continuation, COPY/pattern/later lifecycle and non-dress-up consumers. This is not general CAP_EDGE replay. | +| `CAP_EDGE` | `2491` | CADFS `00049094` / `00404726` exported `onshape/std/geometry.fs@2491.0` symmetric-extrude histories; two exact `BRepPrimAPI_MakePrism.LastShape(source_edge)` far-cap handles, each checked against the final fused result | An immediate fillet/chamfer may consume an independent, undrafted `new_body` `extrude_add_two_sided` whose primary and reverse ends are both blind and whose lowered profile exactly equals the complete original source profile | The executor records one prism delta per direction. The primary far `LastShape` is `extrude.end`; the reverse far `LastShape` is explicitly remapped to `extrude.start`. Source-plane `FirstShape` results are internal fusion seams and cannot be selected. Each role must retain complete/proven final-snapshot `IsSame` evidence; lowerer emits `boundary` only, with no continuation or fallback evidence. `00049094` F2 resolves four CAP edges as one `QUERY_SET` and the full history rebuilds, but comparison is rejected. `00404726` remains `selector_query_unsupported` at F2 because its selected multi-region/IMPRINT profile is not this complete direct profile. CUT, draft, non-blind/mixed extents, other versions, partial/IMPRINT/split/multi-region profiles, fused/boolean/COPY/pattern/later lifecycle and all geometry/stable-ID/current-body/source-STEP fallback remain rejected. This is not general 2491 CAP_EDGE replay. | +| `CAP_VERTEX` | `1511` | CADFS FeatureScript 1511 direct `makeQuery(..., CAP_VERTEX, VERTEX, {disambiguationData: [OSD([edge, edge])], isStart})`; `BRepPrimAPI_MakePrism.FirstShape(source_vertex)` / `LastShape(source_vertex)` exact cap-vertex handles | A one-sided `up_to_vertex` extent may consume an independent `new_body` undrafted blind prism cap vertex. The OSD must name exactly two distinct retained direct profile edges from one source sketch with exactly one shared original endpoint. Between producer and consumer, only reference planes or independent undrafted `new_body` blind prisms are permitted. | The resolver uses the grouped transient source-vertex anchor and only the role-qualified `generated vertex -> vertex`, `boundary`, complete/proven final-snapshot relation. A separate body may intervene only when the session maps the declared semantic member to exactly one old and one new solid snapshot, and every face/edge/vertex has exactly one reciprocal `IsSame` counterpart; the registry emits `body_member_preserve` only then. Aggregate `body` records are excluded because they are snapshot metadata rather than a member subshape. Vertex records are exported from a final B-rep explorer because build123d's repeated `body.vertices()` wrappers do not retain `IsSame` identity with prism history handles. `00330726` F1 -> F3 -> F5 resolves by `extrude` plus `body_member_preserve` and executes; its fresh full pipeline remains `converted_partial` / `rebuilt_rejected` due to later F7/F8/F9/F10 gaps and a 3 vs 4 solid mismatch. Other versions, changed/replaced/deleted or incomplete/non-unique members, draft, cut/fuse, split/IMPRINT/multi-profile geometry, non-shared or repeated OSD edges, two-sided extent, inactive/non-unique records, backward/non-uniform target projection, and every geometry/stable-ID/current-body/source-STEP fallback remain rejected. Thus this is a narrow runtime contract, not CAP_VERTEX family completion. | +| direct source `sQuery(..., VERTEX, ...)` extent datum | `1511` | A current source audit found 16 `UP_TO_VERTEX` histories: 15 direct source datum targets (`00383982`, `00444951`, `00503730`, `00510558`, `00753006`, `00894150`, `00975649`) plus one each `CAP_VERTEX` and `INTERSECT(VERTEX)` form; each direct datum has an explicit source workplane and named entity endpoint | A one-vertex direct source query may terminate a one-sided or either side of a two-sided `up_to_vertex` extrusion as `{kind: source_vertex, source_sketch_id, source_entity_id, point_mm}` | This is not a topology selector. Schema and semantic validation require one known source sketch, source entity and finite 3D point; selector binding skips it and runtime checks one positive uniform projection across the profile without resolving a topology record. `00444951`'s three extents convert, rebuild and strict/RP-pass in fresh `/private/tmp/cadfs-source-vertex-extent-20260914-r2`. The seven-sample matrix at `/private/tmp/cadfs-source-vertex-extent-matrix-20260914` executes every datum feature in `00510558`, `00753006`, `00894150` and `00975649`, while `00503730` preserves its F5 prefix; their complete comparisons remain rejected or partial for independent downstream modelling gaps. `00383982` stops before its datum feature at a separate unbounded IMPRINT profile failure. `qAdjacent`, set/derived/runtime vertex queries, CAP/COPY/pattern/IMPRINT/INTERSECT vertices, non-1511 source, invalid/non-finite datum and geometry/stable-ID/current-body/source-STEP fallback are rejected. This does not complete `CAP_VERTEX`, `INTERSECT(VERTEX)`, generic vertex selection or general `up_to_vertex`. | +| `BLEND_EDGE` | `1511` | CADFS 1511 direct query history; fillet/chamfer `Generated(source_edge)` patch, `Modified(source_face)` target and final `IsSame` shared-boundary witness | One immediate native fillet/chamfer consumer of a direct independent blind `new_body` prism: exactly one direct `CAP_EDGE` plus either a complete direct `CAP_FACE`, or the same-anchor direct `SWEPT_FACE`, in `blendedFrom`, with an identical single face query in `blendedInto`, resolves to one exact active final patch-boundary edge | `blend_sources` retains the pair independently of outer selector fields. The resolver requires one source-profile edge anchor, its role-qualified cap edge, and either its direct cap face role or its exact `Generated(source_edge -> face)` swept-face relation, followed by one complete/proven `exact_blend_boundary` relation. `00414347` F3 is RP passing but not strict; `00690433` F3 executes but comparison is rejected; `00596552` F5 and `00456146` F4 now lower their same-anchor SWEPT_FACE leaves under this contract, but fresh replays stop respectively at the pre-existing native chamfer feasibility failure and another selector relation ambiguity, so they are contract/lowering evidence only. `00801833` F3 executes before its F4 checkpoint; `00614954` F3 rejects its cap-role mismatch. `MERGE`, `SPLIT`, `COPY`, `BLEND_FACE`, differing source anchors, multi-member sets, non-immediate lifecycle, ambiguous/incomplete/inactive history, angle/two-offset dress-up, other producer/version, and geometry/stable-ID/current-body/source-STEP fallbacks remain rejected. This is not general BLEND_EDGE replay. | +| `BLEND_FACE` | `1511` | CADFS direct dress-up sketch workplanes; final-snapshot native fillet/chamfer `Generated(input_edge)` patch handles | A runtime-attached `newSketch` can host on one immediate native fillet/chamfer patch when one OSD source edge is also its sole role-qualified direct-prism `CAP_EDGE`; producer is an undrafted blind `extrude_add_blind` `new_body` direct profile prism | `blend_face_source` stores no static frame, geometry, stable ID, or record ID. Semantic validation repeats the producer/dress-up lifecycle gate; resolver proves source anchor -> cap edge -> one complete/proven generated active patch, then uses that actual planar B-rep face for the workplane. `00313870` F2 -> F3/F4 and `00436592` F3 -> F4 lower as attached sketches. Fresh replays preserve checkpoints: `00313870` stops at a pre-existing F2 chamfer feasibility failure, while `00436592` executes F3 then stops at later F6 unsupported query; neither is strict/RP evidence. Multi-edge OSD, two-sided/IMPRINT/split/drafted/non-direct profiles, non-immediate/nested dress-up, MERGE/SPLIT/COPY/pattern/boolean successors, non-planar/ambiguous/partial/inactive patches, other versions, and geometry/stable-ID/current-body/source-STEP fallbacks remain rejected. This is not general BLEND_FACE or generic sketch attachment coverage. | +| `COPY` | `1511` | CADFS `00252195` direct `COPY(CAP_EDGE)` plus `00573124`/`00951631` immediate `COPY(CAP_FACE)` workplane histories; exact direct-prism cap and primary-cut `BRepAlgoAPI_Cut` history | An immediate fillet/chamfer may consume one CAP edge, or one following sketch profile/hole location may attach to same-owner CAP face, from an undrafted default blind `extrude_cut_blind` | The edge form requires `primary_cut_cap_edge` and one direct source edge. The face-host form requires `primary_cut_cap_face_workplane`: semantic preflight cross-checks its complete unchanged OSD set against the producer's actual direct profile source sketch/entities, then runtime requires one role-qualified cap -> transient-face boundary and one same-owner complete/proven `subtract` continuation to one active planar face. Only then does runtime derive a native-UV/support-plane workplane and re-resolve local sketch coordinates. Both return `copy_lineage`; transient tools are never selectable. `00252195` resolves then hits native chamfer failure; `00573124`/`00951631` rebuild but fresh strict/RP comparison rejects both, while non-immediate `00252794` F8 remains deferred. COPY `SWEPT_FACE|BODY`, patterns/transforms, draft/two-sided/non-direct or non-immediate lifecycle, opposite-direction attached holes, multiple/nonplanar/partial/split/merge successors, other versions and every geometry/stable/snapshot/current-body/source-STEP fallback remain rejected. This is not general COPY replay. | | `SWEPT_BODY` | `1511` | CADFS FeatureScript 1511 exported singleton `qUnion([makeQuery(..., SWEPT_BODY, EntityType.BODY)])` history | An immediate one-sided `up_to_body` extent may reference exactly one active body record produced by a preceding independent `new_body` blind prism | The source body must be the immediately preceding `extrude_add_blind`, with `result_mode: new_body`, blind undrafted direct-prism semantics, and an active producer body record. Lowering emits only `active_body_member` evidence with `body_member_contract: direct_new_body`; resolver proves the active record and producer identity, then returns `body_member`. `00694309` F3 resolves this reference; its F4 strict/RP pass is separately established by the bounded `CAP_EDGE` continuation contract, not by widening this body-member contract. Later successors, `ADD`, cut/revolve/sweep, `COPY`, boolean/pattern/delete, multiple active bodies, multiple query items, other versions, and all stable-ID, geometry, binding-ID, aggregate/current-body fallbacks are rejected. | -| `OFFSET_FACE` | `1511` | CADFS FeatureScript 1511 exported query history | `shell.offset_face` builder output role plus one TDD source cap | Immediate direct shell owner and one explicit `extrude.start` or `extrude.end` true dependency. | -| `SWEPT_FACE` | `1511` | CADFS FeatureScript 1511 exported query history; direct-prism `BRepPrimAPI_MakePrism.Generated(edge)` relation plus final-snapshot `BRepAlgoAPI_Cut.Modified/Preserved` target history | An immediate fillet/chamfer consumer or shell removal, or a one-sided `up_to_surface` extent: one retained direct source-profile edge -> its generated side face, followed only by complete/proven kernel continuations to one active face | Limited end-to-end contract. The source prism must be independent, undrafted, `new_body`, blind, and retain one exact direct wire/face construction anchor. The extent variant carries no output role, stable ID, binding ID, or geometry hint. For a single-solid primary cut, target-side builder history is registered independently of whether its tool has direct-prism history; transient tool topology remains restricted to the separate source-qualified `INTERSECT` contract. Resolver traverses only the requested result topology kind, measures cardinality over complete/proven final-snapshot relations of that source/result kind, and still rejects any bound partial, split, merge, or inactive branch. Non-final intermediate handles and cross-type section diagnostics remain diagnostic only. `00925274` resolves F1 E0 through F3, F6, F9 and F12 target continuations and is strict/RP passing. The shell variant accepts only original non-construction lines. Direct all-circle construction includes one annular hole; independently constructed direct circle regions retain one delta per source face only when every generated result remains in the final snapshot. IMPRINT/split source profiles, draft, multiple/two-sided extents, fused source results, unproven/split continuations, revolve, sweep, loft, copy and pattern are rejected. Fresh corpus evidence: `00594348` and `00925274` are strict in `output/swept-face-target-continuation-20260910`; `00111611` and `00974931` retain rebuild failures. This is not family completion. | -| `SWEPT_EDGE` | `1511` | CADFS FeatureScript 1511 exported query history; direct-prism `BRepPrimAPI_MakePrism.Generated(vertex)` relation | One immediate downstream consumer of an independent, undrafted, `new_body` blind prism: a source profile vertex, identified by its complete incident source-edge set -> its generated vertical edge | Limited end-to-end contract. The source vertex must retain two exact direct-profile edge anchors and the generated relation must be complete and active. One analytic contour region may contain hole wires: all wires are passed once to `BRepBuilderAPI_MakeFace`, then each retained anchor is checked against the finished face with `IsSame`. A profile with multiple regions and any hole stays on the established `Face.make_holes` path with no anchors, preserving its executable geometry. IMPRINT/split or otherwise mutated profiles, draft, multiple/two-sided extents, fused results, boolean/SPLIT successors, revolve, sweep, loft, copy and pattern are rejected; this is not family completion. Corpus evidence: `00000715` F3; `00007264` F2 resolves four selectors by kernel lineage but is comparison-rejected; `00039669` F2 is strict/RP passing; `00151159` F2 is RP passing. | -| `INTERSECT` | `1511` | CADFS FeatureScript 1511 exported query history; OCC boolean `SectionEdges()` plus exact `Generated(face)` handles from both inputs | One `INTERSECT EDGE` of exactly two source-qualified direct-prism `CAP_FACE`/`SWEPT_FACE` inputs from either one explicit `boolean_bodies` target/tool pair, or one immediate primary `extrude_cut_blind` target plus its transient direct-prism tool snapshot | The adapter creates a relation only when the same section edge is returned by `Generated(face)` for exactly one face in each boolean input and `IsSame` binds all three handles to the input/final snapshots. A primary cut records its tool only as transient historical topology: exactly one active target solid/member, one tool solid, complete direct-prism anchors/history and one immediate consumer are required; the tool itself cannot be selected. Resolver requires one complete relation, the `intersection`/`one` policy and final active member. Unqualified `SectionEdges()`, duplicate output edges, incomplete source snapshots, keep-tools, multiple target/tool members, copied/transformed/patterned/TDD/IMPRINT inputs, non-direct prisms, primary query `disambiguationData` whose FeatureScript semantics are unverified, and unknown versions remain rejected. This is a contract test boundary; `00020311`/`00029250` retain their F3 checkpoints because their `OD(0/1)` selectors are deferred. Real-corpus RP evidence is not yet sufficient to mark general `INTERSECT` replay complete. | +| multi-source transform `COPY(BODY)` | `1540` evidence | A direct `COPY(instanceName=1)` chain from one direct source of a preceding `makeCopy:true` transform with at least two selected body sources | Explicit `booleanBodies` target/tool selection may consume the source-qualified copy through `{transform_feature_id, source_feature_id}` | The owner must be preceding, be `transform_bodies`, preserve at least two direct `source_feature_ids`, and retain the selected source's independently active COPY member. Schema, semantic validation, capability preflight and executor reject an owner/source mismatch, single-source COPY, a producer aggregate, inactive member, targetless COPY boolean and any geometry/stable-ID/current-body fallback. The corpus has no native COPY-to-boolean history: a source-only contract variant extends real 1540 `00699847` F3's two-source COPY with F5 `booleanBodies`; all five features lower and execute, but it is not strict/RP evidence and does not mark generic COPY lifecycle complete. | +| single-source chained transform `COPY(BODY)` | `1511` | CADFS `00184423` and `00322866` direct `COPY(instanceName=1)` histories retain an original `SWEPT_BODY` owner while each transform consumes the preceding explicit member | A `make_copy:true` `transform_bodies` feature may retain `{source_feature_id, active_member_feature_id}` provenance metadata when exactly one direct runtime source is the immediately preceding active COPY member | Lowering derives the semantic source only from the recursive `derivedFrom` chain and emits one alias only when it differs from the selected `source_feature_ids` member. Schema/semantic validation require one single-source `make_copy` transform, one preceding semantic source, and an alias member exactly equal to the direct selected source; aliases never participate in runtime body selection. `00184423` F3--F5 and `00322866` rebuild in fresh RP pipeline as `rebuilt_strict` and `rebuilt_approximate`. Multi-source/aggregate COPY, patterns, boolean/delete/non-copy successors, missing or mismatched members, arbitrary transforms, selector topology propagation, and all geometry/stable-ID/current-body/source-STEP fallback remain rejected. This is body-lifecycle provenance only, not general COPY or transform coverage. | +| `qOwnerBody` / `OWNER_BODY` | `1511` | FeatureScript 1511 query AST plus a synthetic `00694309` history variant that replaces its equivalent direct `SWEPT_BODY` reference with `qOwnerBody(makeQuery(..., SWEPT_FACE, FACE, ...))` | A one-sided `up_to_body` extent may project one direct, kernel-proven `SWEPT_FACE` or direct builder-proven `CAP_FACE` input to its exact active body record | The parent must preserve `filter: owner_body` with one typed nested selector, identical FeatureScript/library tuple, `exact_input_owner`, `active_member`, reject-empty and one-result policy. Runtime first resolves the child with its existing provenance contract, requires exactly one result whose `body_id` equals the active aggregate ID, then returns the unique non-transient body record with that same ID. It does not infer ownership from the current aggregate, feature creator, geometry, stable/snapshot/binding ID, or partial/multi-record lineage. Multi-solid member IDs, nested/query-set inputs, later producer lifecycle, non-direct topology producers and generic `qOwnerBody` consumers remain rejected. The corpus currently has no native `qOwnerBody` call, so this is a synthetic contract boundary, not query-family completion. | +| `OFFSET_FACE` | `1511` | CADFS FeatureScript 1511 exported query history; a complete direct-prism profile OSD set may also name the shell's retained cap | (a) `shell.offset_face` builder output role plus one TDD source cap, or (b) an immediate `extrude_from_face` profile from the one retained cap of a direct-prism shell | (a) requires an immediate direct shell owner and one explicit `extrude.start` or `extrude.end` true dependency. (b) is a separate `shell_retained_direct_prism_cap_offset_face_profile` contract: source and library must be 1511; the immediate shell depends only on an undrafted direct `new_body` blind prism, removes exactly one of its CAP roles, and the OFFSET query has exactly one OSD whose source-edge set equals the complete unchanged direct profile. The lowerer names the opposite, retained CAP role as `output_role_source`; semantic validation and preflight repeat the immediate-owner, single-removal, direct-producer, complete-set and blind additive-new-body checks. Runtime selects only the unique active `shell.offset_face` carrying the exact complete/proven kernel relation from that retained role, never a wall or closing descendant. `00719927` F1--F4 executes and F3 resolves this way in `output/offset-face-retained-cap-20260913`, but its final strict/RP comparison is rejected, so it is execution evidence only. Partial/multi-source OSD, multiple removals, the removed cap, draft/cut/fused/revolve/sweep/COPY/pattern producers, non-immediate shell/lifecycle, non-1511 source and geometry/stable-ID/current-body/source-STEP fallback remain rejected. | +| `OFFSET_EDGE` | `1511` | CADFS 1511 shell history in either (a) singleton OSD plus singleton TDD direct-prism `CAP_EDGE`, or (b) one OSD containing two distinct direct profile edges with one shared original endpoint; exact `MakePrism` and final-snapshot shell history | An immediate fillet/chamfer may consume (a) one retained one-sided direct-prism cap edge through `direct_prism_shell_offset_edge_tdd`, or (b) one direct-prism source vertex's swept edge through `direct_prism_shell_offset_edge_vertex` | TDD requires matching outer/nested source edge and routes only through its requested `extrude.start|end` cap boundary; vertex requires exactly two distinct unchanged source edges, one grouped source-vertex anchor and no cap role. Both require an independent undrafted one-sided blind `new_body` producer, one immediate inward shell depending only on it, a single CAP-face removal, and only complete/proven one-to-one lineage into the active member. `00650671` F3 executes through the retained-cap contract; `00768679` F8 rebuilds but comparison rejects volume/area. `00059593` resolves all four OSD-only vertex leaves and rebuilds in `output/offset-edge-vertex-matrix-20260913`, but comparison is rejected, so it is execution evidence only. A set mixing forms (`00791920`) is rejected as a whole: TDD leaves remain explicit while OSD leaves stay deferred. `TDD(SWEPT_EDGE)` is separate and remains deferred: the three native co-occurrences (`00239888`, `00882527`, `00902029`) respectively involve an IMPRINT/pre-shell-filleted prism, BLEND, and revolve/BLEND, with no valid direct-prism immediate-shell continuation. Semantic validation and capability preflight recursively apply these lifecycle predicates to every `QUERY_SET` leaf, so a parent cannot bypass the producer/shell gate. Neither contract treats generated `shell.wall`, cap/face boundary traversal, stable ID, geometry, current body or source STEP as equivalent evidence. Two-sided, draft/ADD/CUT, partial/IMPRINT/split profile, multiple removal faces, non-immediate/later mutation, COPY/pattern/transform, non-unique component/vertex, other versions and all fallback remain rejected. This does not complete general OFFSET_EDGE or inner shell-wall semantics. | +| `SWEPT_FACE` | `1511` | CADFS FeatureScript 1511 exported query history; direct-prism `BRepPrimAPI_MakePrism.Generated(edge)` relation plus final-snapshot `BRepAlgoAPI_Cut.Modified/Preserved` target history, and direct-PipeShell `Generated(profile_edge)` final-face outputs | An immediate fillet/chamfer consumer or shell removal, a one-sided `up_to_surface` extent, or the separately paired shell successor two-sided extent may use a retained direct-prism source-profile edge -> generated side face, with only complete/proven continuations to one active face. Separately, an immediate fillet/chamfer may use an independent `new_body` direct PipeShell sweep with one selected edge from a complete direct analytic closed profile and one direct path edge -> initial generated side face | The prism contract requires an independent, undrafted, `new_body` blind source and one exact direct wire/face construction anchor. The one-sided extent variant carries no output role, stable ID, binding ID, or geometry hint. `immediate_retained_source_prism_swept_face_up_to_surface` separately admits one immediate 1511 `new_body` blind prism made from an IMPRINT-selected profile only when exactly one OSD edge is unchanged and singleton-identical in both selected and original source profiles; it permits only that next one-sided extent and `boundary`, never continuation. Semantic validation and capability preflight repeat the producer/source-import/edge witness, and runtime requires exact generated edge-to-final-face lineage. `00408613` F1 -> F3 resolves `F0/E0.bottom` by `kernel_lineage`; its later F4 CAP_EDGE failure retains the F3 checkpoint. Tampering the anchor to the excluded `E1` is rejected by both gates. The paired `symmetric_direct_prism_shell_swept_face_up_to_surface_pair` requires exactly two distinct original non-construction line anchors from one prism, immediately followed by a shell depending only on that prism; a target side selected for shell removal is rejected so a `shell.closing_descendant` cannot stand in for `shell.offset_face`. Runtime separately proves each `edge -> prism wall -> shell.offset_face` chain. `00180262` F1 -> F2 -> F4 resolves both targets and executes, but its RP comparison is rejected. For a single-solid primary cut, target-side builder history is registered independently of whether its tool has direct-prism history; transient tool topology remains restricted to the separate source-qualified `INTERSECT` contract. The sweep subset requires an exact two-item profile/path-edge OSD, a complete unique source-edge list for the one profile contour, and a source path query with exactly one `sQuery`; every `Generated(profile_edge)` face is final-snapshot `IsSame` verified, runtime anchors only on that profile edge, and source-pair failure stays a deferred query without geometry/stable-ID fallback. `00330012` circle and four-edge source-form variants resolve and rebuild; real `00954785` multi-segment path remains deferred. Resolver traverses only the requested result topology kind and rejects partial, split, merge or inactive branches. `00925274` resolves F1 E0 through F3, F6, F9 and F12 target continuations and is strict/RP passing. Direct all-circle construction includes one annular hole; independently constructed direct circle regions retain one delta per source face only when every generated result remains in the final snapshot. IMPRINT/split source profiles other than the singleton exact-edge extent tuple, draft, fused source results, unproven/split continuations, revolve, inner profile sweep, multi-segment/spatial/hollow/fused/additive sweep, loft, copy and pattern are rejected. The sweep form also excludes continuation, later lifecycle and shell/extent/sketch-host consumers except the stated pair. This is not family completion. | +| `SWEPT_EDGE` | `1511` | CADFS FeatureScript 1511 exported query history; direct-prism `BRepPrimAPI_MakePrism.Generated(vertex)`, direct full-revolve `BRepPrimAPI_MakeRevol.Generated(vertex)`, and direct-PipeShell `Generated(profile_vertex)` relations, all verified by final-snapshot membership | Either (a) the existing independent, undrafted, `new_body` blind-prism source vertex -> generated vertical edge contract, (b) an independent `FULL` `revolve_add` `new_body` from the original non-IMPRINT source profile and same-sketch line axis -> one active swept circular edge, or (c) an immediate fillet/chamfer on an independent `new_body` direct PipeShell sweep: two adjacent source profile edges -> one source vertex -> one initial swept edge | Every form requires exact direct source-edge anchors with one shared endpoint, complete active final-membership relations, and no geometry/stable-ID/current-body fallback. The prism form retains its analytic-hole boundary; the revolve form registers only exact `Generated(vertex)` edge handles and permits initial `boundary` plus only complete/proven one-to-one `continuation`. The sweep form requires a complete unique direct analytic contour, an exact three-item OSD of two adjacent profile edges plus one single `sQuery` direct path edge, and only initial boundary consumption; its adapter records only final-solid `Generated(vertex)` edges. `00330012` four-edge/one-line source-form resolves and rebuilds; a non-adjacent pair is deferred. A fresh all-corpus scan found 12 native sweep SWEPT_EDGE histories but none meets this strict producer/path/consumer contract, so it is not real-corpus coverage. `00025622` F2 and `00048326` F3 rebuild strict through the full-revolve path; `00407186` F2 resolves initially, while its later F3 continuation is correctly rejected as non-unique. IMPRINT/materialized 1511 revolve profiles, split/merge/deletion, partial/surface/additive/fused revolve, axis/profile from different sources, draft/multiple/two-sided prism, boolean/SPLIT successors, sweep profiles with circle/inner/split forms, segmented/spatial/hollow/fused/additive paths, sweep continuation, loft, copy and pattern are rejected. Existing prism evidence remains `00000715` F3, `00007264` F2 (comparison-rejected), `00039669` F2 (strict/RP), and `00151159` F2 (RP). This is not family completion. | +| `SWEPT_EDGE` | `2491` | CADFS `00404735` F1/F3 exported `onshape/std/geometry.fs@2491.0` history; `BRepPrimAPI_MakeRevol.Generated(vertex)` verified against the final solid snapshot | A full, independent `revolve_add` `new_body` with a direct profile or an explicitly verified complete unchanged materialization, and a direct in-sketch axis: a uniquely identified non-construction source vertex, expressed by its complete incident source-edge set -> one active swept circular edge | The lowerer requires `FULL`, exact source/library tuple, the profile contract above, a producer still present in the history, at least two distinct direct source edges with exactly one shared endpoint, and no geometry/stable-ID/current-body fallback. The adapter registers only `Generated(vertex)` edge handles which pass final-result membership; resolver permits the initial `boundary` and subsequently only complete/proven one-to-one `continuation` relations. In `00404735`, F3 (`E0`/`E5`) lowers with `kernel_history` evidence and rebuilds; F2 remains deferred because its OSD pair does not identify one direct shared endpoint. Partial/surface/additive/fused revolve, changed/incomplete/split materialization, axis/profile from different sources, axis endpoints, single/non-unique source, unsupported library revisions, split/merge/deletion, and all generic revolve/SWEPT_EDGE forms remain rejected. This is a single 2491 producer contract, not general revolve or family completion. | +| `INTERSECT` | `1511` | CADFS FeatureScript 1511 exported query history; OCC boolean `SectionEdges()` plus exact `Generated(face)` handles from both inputs | One `INTERSECT EDGE` of exactly two source-qualified direct-prism `CAP_FACE`/`SWEPT_FACE` inputs from either one explicit `boolean_bodies` target/tool pair, or one immediate primary `extrude_cut_blind` target plus its transient direct-prism tool snapshot | The adapter creates a relation only when the same section edge is returned by `Generated(face)` for exactly one face in each boolean input and `IsSame` binds all three handles to the input/final snapshots. CAP input recovery reads the unique complete/proven builder-role fact from its producer delta, never a cached output role on a later continuation; `00789417` therefore resolves F12's F10-snapshot section edge, while F11's missing active edge continuation correctly rejects its later consumer as `selector_body_member_inactive`. A primary cut records its tool only as transient historical topology: exactly one active target solid/member, one tool solid, complete direct-prism anchors/history and one immediate consumer are required; the tool itself cannot be selected. Resolver requires one complete relation, the `intersection`/`one` policy and final active member. Unqualified `SectionEdges()`, duplicate output edges, incomplete source snapshots, keep-tools, multiple target/tool members, copied/transformed/patterned/TDD/IMPRINT inputs, non-direct prisms, primary query `disambiguationData` whose FeatureScript semantics are unverified, and unknown versions remain rejected. This is a contract test boundary; `00020311`/`00029250` retain their F3 checkpoints because their `OD(0/1)` selectors are deferred. Real-corpus RP evidence is not yet sufficient to mark general `INTERSECT` replay complete. | ## Source Sketch Path Queries (Not Runtime Selector Capabilities) @@ -29,10 +225,29 @@ geometry while lowering a `sweep` path. It therefore creates no | Source query / consumer | FeatureScript version | Source evidence | Lowering and runtime boundary | Evidence | | --- | --- | --- | --- | --- | -| `qUnion([qConstructionFilter(qBodyType(qCreatedBy(sketch, EDGE), WIRE), NO)])` as `sweep.path` | `1511` + direct `onshape/std/geometry.fs@1511.0` | Local Onshape standard-library mirror `query.fs`: `BodyType.WIRE` describes sketch curves (lines or curves), `qBodyType` retains entities owned by that body type, and `qConstructionFilter(..., NO)` retains only non-construction entities. The repository does not vendor the exact 1511 standard-library snapshot, so this is source-semantics evidence rather than a claim that all revisions are compatible. | The owner must resolve to a source sketch with exactly one non-construction entity, whose exact source type is `line` or `bspline`; the outer query must be the exact singleton `qUnion` wrapper. `line` paths retain their endpoints. `bspline` paths retain the exported interpolation points, parameters, and endpoint derivatives; a two-point B-spline is executable only when both derivatives are present. Multiple entities, arcs/circles, construction-only geometry, omitted wrapper/filter, other query composition, other language/library revisions, loft guides, surface/profile consumers, and all runtime-body uses remain rejected or deferred. | The fresh seven-sample RP matrix `output/qbodytype-direct-sketch-wire-matrix-20260910-v1` rebuilds all direct-path cases: `00191739` is strict, `00726304` is RP-only approximate, `00227428`/`00287471`/`00500952`/`00816123` are comparison-rejected, and two-point B-spline `00896761` is `comparison_timeout` at 60 seconds. These are execution classifications, not a claim that rejected/timeout models are similar. `00786708` F2 remains deferred because F0 contains multiple non-construction path entities; its independent F5 single-line path still lowers. | +| `qUnion([qBodyType(qCreatedBy(sketch, EDGE), WIRE)])` with optional exact `qConstructionFilter(..., NO)` as `sweep.path` | `1511` + direct `onshape/std/geometry.fs@1511.0` | Local Onshape standard-library mirror `query.fs`: `BodyType.WIRE` describes sketch curves (lines or curves), `qBodyType` retains entities owned by that body type, and `qConstructionFilter(..., NO)` retains only non-construction entities. `qCreatedBy(..., EDGE)` excludes source sketch points. The repository does not vendor the exact 1511 standard-library snapshot, so this is source-semantics evidence rather than a claim that all revisions are compatible. | A singleton non-construction `line` / `bspline` retains the existing contract. One direct source sketch may provide a multi-curve result, or an outer union may contain two or more exact versioned source-wire operands. All resulting `line` / `arc` / non-periodic `bspline` curves must form one source-ordered, connected, non-branching open wire with no repeated source entity; two-point B-splines require both endpoint derivatives. For cross-sketch operands, each curve is captured through its explicit source workplane to a global self-contained spatial `path.segments` variant, with no common-workplane projection, datum/result/final-STEP/current-body lookup, selector intent, kernel lineage, CAP output-role, or stable-ID fallback. A `NO` filter removes construction curves; without it any construction curve rejects the contract; `skPoint` is not an `EDGE`. Closed/disconnected/branching/repeated/degenerate paths, circles/ellipses/unknown curves, non-exact wrappers/filters, other versions, loft guides, surface/profile consumers, and runtime-body uses remain deferred. | The fresh seven-sample RP matrix `output/qbodytype-direct-sketch-wire-matrix-20260910-v1` rebuilds all singleton direct-path cases: `00191739` is strict, `00726304` is RP-only approximate, `00227428`/`00287471`/`00500952`/`00816123` are comparison-rejected, and two-point B-spline `00896761` is `comparison_timeout` at 60 seconds. `00885126` F2 lowers `E0 -> E2.filletArc -> E1`, executes one solid sweep, and preserves its prefix STEP in `/private/tmp/cadfs-segmented-wire-00885126-20260910`; `00786708` executes F2/F5/F7 then is `rebuilt_rejected`. Cross-sketch `00610979` F4 captures filtered F1/F3 source wires as four global segments and rebuilds one solid with no runtime diagnostic in `output/cadfs-spatial-wire-00610979-20260910`; complete-history comparison is rejected because later CAP_FACE-dependent F5/F9 remain deferred and source has four solids. These are execution classifications, not similarity claims. | +| `qUnion([qConstructionFilter(qBodyType(qCreatedBy(sketch, EDGE), WIRE), NO)])` as `loft` `ToolBodyType.SURFACE` wire profiles | `1511` + direct `onshape/std/geometry.fs@1511.0` | Same source-only query semantics; `00174697` supplies two independent circular source wires on parallel explicit planes. | Exactly two distinct source sketches, each with one direct closed non-construction wire (one circle or a connected non-branching line/arc/B-spline cycle), may lower to `loft_surface`. The adapter uses `BRepOffsetAPI_ThruSections(False, False)` and registers an independent shell; it never fuses it into the active solid or grants topology/provenance selector continuation. Spine, guides, connections, matching, endpoint derivatives, sheet profiles, non-`NEW` operation, mixed/derived/runtime wires, construction ambiguity, open/disconnected/branched/inner wires and other library versions are deferred with `loft_surface_wire_profiles`. | `00174697` lowers and freshly rebuilds to one shell (`surface_count: 1`, `solid_count: 0`). This is one direct surface-loft form, not general `qBodyType`, construction filter, loft, or surface-selector coverage. | +| `qBodyType(qCreatedBy(sourceSketch, EDGE), WIRE)` as a `LINE_ANGLE` datum axis | `1511` + direct `onshape/std/geometry.fs@1511.0` | Same source query semantics, with `cplane.fs::lineAnglePlane` consuming the selected source axis rather than runtime model topology | The exact query result must contain one `line` from one source sketch. The line's explicit source workplane supplies the global axis; construction is valid for datum geometry. The bridge rejects multi-line, arc/B-spline, filters/composition, derived topology, unknown versions, and runtime body selection. It emits a CDSL frame, not a selector intent or lineage claim. | `00506444` F6 selects one construction line from each of F4/F5, lowers one reference plane, and allows F8 to execute in a fresh rebuild. `00474220` F4 selects an arc wire and remains a deterministic `line-angle reference selection is unsupported` defer. This is one real 1511 positive form, so it does not complete `qBodyType`, construction filtering, or general datum-axis coverage. | +| Direct-prism `CAP_VERTEX` as a `THREE_POINT` / `PLANE_POINT` datum point | `1511` + direct `onshape/std/geometry.fs@1511.0` | A direct `makeQuery(..., CAP_VERTEX, VERTEX, {disambiguationData: [OSD([edge, edge])], isStart})` names one source-defined physical point, rather than a live selector result | The owner must be an undrafted independent `new_body` blind prism whose lowered profile exactly equals its original direct source profile. OSD must contain exactly two distinct non-construction profile edges from that source sketch with one shared endpoint. The lowerer maps this endpoint into the selected prism cap frame; it allows datum, independent undrafted `new_body` blind prisms, and one exact producer `CAP_FACE` single-cap shell after the producer because CAP_VERTEX remains a producer-history datum, never a shell-result vertex. It emits an explicit cPlane frame, no selector intent or topology lookup. The common analytic-contour region solver also requires a hole loop to lie strictly inside its outer loop at every sampled boundary point; shared/touching loops remain independent regions instead of creating an invalid self-hole. Hole/boolean/other dress-up/transform/COPY/pattern/delete mutations, draft/ADD/CUT/two-sided, IMPRINT/split/multi-profile, ambiguous/repeated endpoint source, other versions, geometry/current-body/stable-ID and STEP fallback are rejected. | `00243142` and `00245768` F2/F4 complete replay in fresh `output/cplane-cap-vertex-datum-20260912-r2`; both final comparisons are rejected (`00245768` also has a solid-count mismatch). `00212904` F2 direct CAP shell followed by F3 THREE_POINT also lowers; both requested and removed cap positions remain producer-history datum points. `00053942` F4 follows a hole and is rejected as `CAP_VERTEX datum source is unsupported`. This is a source-datum bridge only, not CAP_VERTEX selector, generic cPlane, or query-family completion. | +| `PLANE_POINT` with direct-prism `CAP_FACE` and direct source vertex | `1511` | The FeatureScript query AST retains `FACE` and `VERTEX` kinds independently of whether either query uses `qCreatedBy`; a direct blind-prism CAP frame supplies the physical face frame | The cPlane must have exactly two entities: one face and one vertex. The lowerer takes the face frame and locates the direct source vertex in its explicit source sketch frame; it does not reinterpret either query as a runtime topology selector. Multiple faces, multiple vertices, untyped/extra inputs, and unresolved source frames defer. | `00228556` F3 uses F1 `CAP_FACE` plus F2 `sQuery(..., VERTEX, ...)`; F1/F3/F6 execute in `output/cplane-plane-point-cap-face-20260912`, with RP pass and strict volume/area precision diagnostics. No runtime body, stable-ID, geometric-nearness, or STEP fallback is used. General topology faces/vertices, query composition and generic PLANE_POINT semantics remain deferred. | +| Direct-prism `SWEPT_EDGE` as a `LINE_ANGLE` datum axis | `1511` + direct `onshape/std/geometry.fs@1511.0` | A direct source-profile vertex, identified by exactly two distinct original profile edges with one shared endpoint, plus the one-sided prism span | The producer must be the immediately preceding undrafted `new_body` blind `extrude_add_blind`; its lowered profile must exactly equal the complete original source profile. The datum axis is calculated from the source vertex copied to the prism start cap and the explicit start-to-end span. It is not a runtime selector, topology lookup, stable-ID, geometry, active-body, or STEP fallback. Derived/combined edge queries, non-immediate producers, draft, ADD/CUT/two-sided/IMPRINT or changed profiles, ambiguous/repeated/non-shared OSD edges, other versions, and a derived face/curve as the second LINE_ANGLE reference remain deferred. | `00040198` F2 (single axis) and `00722278` F2 (axis plus Front datum) lower to executable reference planes and complete fresh rebuilds. `00644299` F2 deliberately remains deferred because its second reference is a derived `SWEPT_FACE`; this narrow datum bridge does not implement general derived cPlane semantics. | ## Deferred source queries +### Remaining Deferred Tuples + +The following source families are registered capability work items, not runtime +allow-list entries. They intentionally remain `feature_script_query` with a +non-executable `multiplicity: "none"` policy until their source contract, +complete final-snapshot builder history, consumer semantics, and multi-corpus +evidence are all present. + +| Query family | Observed corpus | Required contract before enabling | Current status | +| --- | --- | --- | --- | +| `BLEND_EDGE` remaining tuples | 382 queries in 183 CADFS histories, including `00407186` F5 and `00614954` F3 | Explicit N:M source-set, producer/lifecycle and final-snapshot incidence contracts for `SWEPT_*`, `MERGE`, `SPLIT`, `COPY`, `BLEND_FACE`, non-immediate consumers and dress-up variants | The direct-prism CAP_EDGE/CAP_FACE tuple is registered above. All other tuples remain non-executable `feature_script_query` selectors; no geometry, stable-ID, current-body or source-STEP fallback is authorized. | +| `MERGE(FACE)` sketch host | 294 direct outer sketch workplanes, including 261 FeatureScript 1511 hosts; 90 have two SWEPT_FACE inputs and 77 have one explicit OSD edge per input | A source-to-body-member merge operation model, complete/proven operation-wide N:M final-face relations, explicit active-member scope and an attached-sketch consumer contract | No MERGE form is executable. An outer MERGE must not inherit a nested CAP_FACE or SWEPT_FACE static frame: `00013930` F7, `00020631` F4 and `00036155` F5 now stop at a named `sketch_deferred` diagnostic while preserving preceding executable features. `NewBodyOperationType.ADD` correctly takes CDSL's fuse path, but its `*.boolean.opBoolean` owner token, aggregate, or generic union history cannot by itself authorize a particular N:M source-face successor. Geometry, face order, stable ID, aggregate/current body and source STEP are forbidden fallbacks. | +| `MID_CAP_EDGE` | 49 queries in 23 CADFS histories: 1511 loft/sweep positions 0--6 and 1549 loft positions 0--1, including `00330207`, `00309311`, `00315819`, and `00703441` | Exact source/library semantics for `capPos`, an operation-wide source-section/edge relation, and a final-snapshot edge handle for every selected section; the consumer must then prove cardinality and active body membership | OCP `BRepOffsetAPI_ThruSections` 7.9.3.1 exposes only no-argument `FirstShape`/`LastShape`; for the default smooth loft used by every observed MID history, `Generated(source_edge)` returns a side face and source/intermediate section edges are absent from the final snapshot. `ruled:true` preserves section edges, but no observed MID history requests that distinct loft semantic. Therefore an index, face-boundary traversal, geometric coincidence, stable ID, current body, or source STEP cannot stand in for `capPos`; all MID queries remain non-executable `feature_script_query` selectors. | + Every CADFS topology selector now retains a `selector_intent`, including selectors for query families and source versions not present in this matrix. Such selectors use `evidence: "feature_script_query"` and a non-executable @@ -42,6 +257,36 @@ can bind. Direct datum planes are the sole exception; they use `query_family: "GEOMETRIC"` with `evidence: "explicit_datum"` and retain the explicit-frame resolution path. +`COPY(FACE)` does not inherit a nested `CAP_FACE` workplane frame. A separate +`primary_cut_cap_face_workplane` contract admits only a 1511 immediate default +primary `extrude_cut_blind`, same-owner direct `COPY(CAP_FACE)`, complete unchanged +source-profile OSD, and one following sketch consumer. The runtime proves one +transient direct-prism cap role and its same-owner complete/proven `subtract` +continuation to one active planar face, then derives the workplane from that exact +face's native UV orientation and support plane before resolving the local profile +or hole locations. `00573124` F3--F5 and `00951631` F3--F5 rebuild by +`copy_lineage`; both fresh strict/RP comparisons are rejected, so this is execution +evidence only. `00252794` F8 remains deferred because F5/F6/F7 mutate the body +after F3. Source frames, stable/snapshot IDs, geometry, current body, source STEP, +nonplanar/ambiguous/partial successors, `COPY(SWEPT_FACE|BODY)`, patterns, +transforms, draft/two-sided/non-direct tools, opposite-direction attached holes, +other versions and non-immediate lifecycle are not fallbacks or covered forms. + +`COPY(SWEPT_FACE)` has a separate `primary_cut_swept_face_workplane` form. It +requires 1511, one same-owner immediate default undrafted blind primary cut, and +one direct original source-profile edge. The runtime follows only the exact +source-edge anchor to one complete/proven transient prism side face, then one +same-owner complete/proven subtract continuation to a single active planar face; +the native face supplies the workplane. `00321940` F3--F5 rebuilds with +`copy_lineage`, but fresh strict/RP comparison is rejected; `00171671` lowers its F4 attachment before an unrelated F5 +selector remains deferred. `00326645` is a real rejection because its side face +has no unique subtract continuation. Two-sided/IMPRINT/partial/non-direct or +multi-edge profiles, non-immediate, nonplanar, split/merge/inactive successors, +body/pattern/transform COPY, other versions, static source frames, stable IDs, +geometry, current body, and source STEP remain outside this contract. A fresh +9,347-history lowering pass materializes 12 such attachments; that count is +contract-shape coverage only, not runtime or comparison acceptance. + This closes the former legacy path where `1793` loft `SWEPT_EDGE` selectors in `00005267` F4 could bind from endpoint bounding boxes despite no registered FeatureScript query contract. The current result is a `selector_query_unsupported` diff --git a/cadfs_to_cdsl/WEEKLY_REPORT_2026-09-11.md b/cadfs_to_cdsl/WEEKLY_REPORT_2026-09-11.md new file mode 100644 index 00000000..05388e70 --- /dev/null +++ b/cadfs_to_cdsl/WEEKLY_REPORT_2026-09-11.md @@ -0,0 +1,156 @@ +# CADFS → CDSL Engine 周报 + +**周期:2026-09-07 ~ 2026-09-11** +**数据集:** `data/cadfs-sample/CADFS_test`,共 9,347 个样本 +**最新全量工件:** `cadfs_to_cdsl/output`,报告生成于 `2026-09-11 18:28:01` +**历史工件:** `cadfs_to_cdsl/output-history/*` +**能力依据:** `cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`、`CADFS_FULL_CAPABILITY_TARGET.md` + +## 1. 本周结论 + +1. **CADFS → CDSL 的转换覆盖继续提升。** 最新全量结果中,`converted_complete` 为 6,626 个(70.89%),`converted_partial` 为 2,325 个(24.87%);无可执行特征的样本降至 366 个(3.92%),解析/降级失败 30 个(0.32%)。 +2. **转换完整不等于可执行,也不等于几何相似。** 最新结果有 8,126 个 STEP 工件,但其中包含失败样本保留的可执行前缀;真正完成一次 rebuild 的样本为 4,597 个,其中 RP 接受 1,781 个、strict 接受 744 个。 +3. **最新快照的 RP/strict 接受数低于 2026-09-09 快照。** 这与本周 selector provenance、single-session replay 和 fallback 收紧同步发生;`selector_query_unsupported` 成为主要 rebuild 失败层。该变化不能只按百分比判定为几何能力退化,应结合失败 feature、前缀 STEP 和诊断逐层分析。 +4. **本周 engine 的主要增量集中在 provenance、拓扑历史和有界 source contract。** 已落地的多数是窄范围、可证明的 contract;没有把单个样本的通过结果升级为通用 CADFS feature 完成。 + +## 2. 当前 CADFS → CDSL 数据转换情况 + +### 2.1 最新全量结果 + +| 层级 | 状态 | 数量 | 占 9,347 样本 | 说明 | +| --- | --- | ---: | ---: | --- | +| 转换 | `converted_complete` | 6,626 | 70.89% | FeatureScript history 全部有 CDSL 表达,仍需单独看 runtime/comparison | +| 转换 | `converted_partial` | 2,325 | 24.87% | 保留可转换前缀,后续 feature 有 deferred/unsupported 等诊断 | +| 转换 | `deferred_no_executable_feature` | 366 | 3.92% | 没有可执行 feature checkpoint | +| 转换 | `parse_failed` | 30 | 0.32% | parser/lowering 层失败 | +| rebuild/比较 | `rebuilt_strict` | 744 | 7.96% | CDSL、STEP rebuild 和 strict comparison 均通过 | +| rebuild/比较 | `rebuilt_approximate` | 1,037 | 11.09% | RP 工程相似通过,strict 未通过 | +| rebuild/比较 | `rebuilt_rejected` | 2,778 | 29.72% | 有 rebuild/comparison,但不满足 RP 验收 | +| 执行 | `rebuild_failed` | 3,932 | 42.07% | runtime/selector/OCC 在完整或增量 replay 中失败 | +| 执行 | `runtime_ineligible` | 408 | 4.37% | CDSL 已产生,但能力预检拒绝完整 runtime execution | +| 基础设施 | `comparison_timeout` | 38 | 0.41% | 比较 worker 超过 60 秒 | +| 基础设施 | `rebuild_timeout` | 14 | 0.15% | rebuild 超时 | + +接受口径:RP 接受 = `rebuilt_strict + rebuilt_approximate` = **1,781(19.05%)**;strict 接受 = **744(7.96%)**。当前 4,559 个样本有 comparison report,其中 strict 占 16.32%,RP 占 39.07%;这个分母与全量样本不同,不能混用。 + +### 2.2 CDSL、STEP、比较工件覆盖 + +| 工件 | 数量 | 占全量 | 位置/说明 | +| --- | ---: | ---: | --- | +| candidate CDSL | 8,951 | 95.76% | `output/samples//candidate.cdsl.json` | +| bound CDSL | 8,485 | 90.78% | 已完成 selector/body binding 的候选 | +| STEP | 8,126 | 86.94% | 包含完整 rebuild 和失败时保留的最佳 executable prefix | +| comparison | 4,559 | 48.77% | `comparison.json` 和 `comparison_summary.csv` | +| GLB | 0(全量目录扫描) | 0% | 最新全量 `output` 未生成 GLB;专项回归工件中的 GLB 不计入全量统计 | + +所有样本的本地 annotation、FeatureScript、image、STEP、STL modality 均存在;JSONL 对齐 fallback 为 0。每个失败样本仍按当前流程保留 `diagnostics.json`、`history.json`、`rebuild.json` 以及可用的 `rebuild.step` 前缀。 + +### 2.3 历史快照对比 + +以下数据按各快照的 `full_run_report.md` 汇总。旧快照的 manifest 状态命名曾使用 `rebuilt`/`rebuilt_approximate` 等不同字段,因此不把旧 manifest 与最新 manifest 直接拼接;比较时以各自报告的 conversion、RP、strict 和工件计数为准。 + +| 报告时间(快照目录) | complete | partial | deferred | RP accepted | strict accepted | STEP | candidate | bound | comparison | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 2026-09-02 (`20260907-185128`) | 2,427 | 4,315 | 2,602 | 1,159(12.40%) | 578(6.18%) | 1,812 | 8,108 | 1,773 | 1,719 | +| 2026-09-07 (`20260907-235209`) | 2,829 | 4,283 | 2,235 | 1,358(14.53%) | 654(7.00%) | 5,554 | 7,112 | 5,554 | 5,250 | +| 2026-09-08 (`20260908-215959`) | 5,367 | 3,266 | 693 | 2,031(21.73%) | 905(9.68%) | 6,134 | 8,633 | 6,134 | 6,090 | +| 2026-09-09 (`20260909-191531`) | 6,232 | 2,710 | 396 | 2,253(24.10%) | 989(10.58%) | 8,176 | 8,942 | 8,364 | 6,007 | +| 2026-09-11(最新 `output`) | **6,626** | **2,325** | **366** | **1,781(19.05%)** | **744(7.96%)** | **8,126** | **8,951** | **8,485** | **4,559** | + +从 2026-09-02 到最新快照,完整转换增加 4,199 个(+44.92 个百分点),deferred 减少 2,236 个(-23.92 个百分点),candidate/bound CDSL 覆盖明显扩大。最新快照相较 2026-09-09 的 strict/RP 和 comparison 数下降,应归因到当前代码的更严格 provenance/selector gate、runtime 分类和本次全量执行边界,不能仅凭接受率得出“本周 engine 几何能力整体下降”的结论。 + +与 2026-09-09 快照逐项比较:`converted_complete` **+394**、`converted_partial` **-385**、deferred **-30**、candidate CDSL **+9**、bound CDSL **+121**;STEP **-50**、comparison **-1,448**、RP accepted **-472**、strict accepted **-245**。后四项反映本次全量执行的 replay/比较覆盖和 provenance gate 变化,需与保留的 prefix STEP、diagnostics 和 comparison 工件一起解读。 + +### 2.4 FeatureScript 操作覆盖与主要缺口 + +最新 source history 中的操作出现次数如下,说明当前回归集已覆盖较宽的 operation 分布: + +| 操作 | 次数 | 操作 | 次数 | +| --- | ---: | --- | ---: | +| `newSketch` | 20,857 | `extrude` | 18,168 | +| `fillet` | 5,210 | `revolve` | 2,087 | +| `chamfer` | 1,818 | `cPlane` | 1,620 | +| `hole` | 1,203 | `shell` | 729 | +| `mirror` | 483 | `transform` | 468 | +| `sweep` | 378 | `loft` | 377 | +| `circularPattern` | 252 | `booleanBodies` | 228 | +| `deleteBodies` | 142 | | | + +按受影响样本统计的主要 capability gaps(一个样本可能同时命中多个 gap)为: + +| Gap | 受影响样本 | Gap | 受影响样本 | +| --- | ---: | --- | ---: | +| `fillet` | 928 | `extrude` | 888 | +| `chamfer` | 264 | `revolve` | 201 | +| `shell_face_selector` | 162 | `mirror` | 139 | +| `extrude_profile_topology:cap_face` | 112 | `extrude_profile_topology:swept_face` | 94 | +| `cPlane` | 92 | `extrude_profile_topology:cap_edge` | 88 | +| `sweep_path` | 71 | `hole_location_vertex` | 70 | +| `loft` | 65 | `circularPattern` | 50 | +| `hole_scope_body_source` | 44 | `shell` | 42 | +| `sweep` | 40 | `booleanBodies` | 36 | + +当前共记录 5,095 条 diagnostic record,分布为 `feature_deferred` 3,449、`unsupported_engine_capability` 1,004、`sketch_deferred` 612、`parse_or_lowering_failed` 30;这些是诊断条数,不是互斥样本数。主要 runtime 失败包括:`selector_query_unsupported`(F2 904、F4 593、F3 380、F6 278、F5 204)、OCC union invalid shape(157)、`BRep_API: command not done`(151)和 planar IMPRINT region 无界(116)。 + +## 3. 本周 engine 新增和完善的能力 + +### 3.1 Selector source preservation 与 query 语义 + +| 能力 | 本周新增/完善 | 已验证边界与证据 | 仍未覆盖 | +| --- | --- | --- | --- | +| 版本化 query AST | `selector_intent` 同时保留带行号的原始 `source_query.ast` 和 `query_expr@1.0`;递归保存 `qUnion`、`qIntersection`、`qSubtraction`、`qAdjacent`、`qOwnerBody`、`qBodyType`、`qConstructionFilter`、`qCreatedBy` 以及未知调用,未知语义保留为 `opaque_call`。schema/semantic validation 校验 expression 与 intent version 一致。 | parser/lowering/selector/runtime 回归已覆盖;未知 query 不再被改写成近似 selector。 | 这是 source-preservation foundation,不等于这些 query family 已可 runtime 执行;通用 filter、created/modified/generated/deleted 关系仍 deferred。 | +| Provenance query set | `fillet/chamfer` 增加窄 `QUERY_SET` contract;direct `qUnion`、`qIntersection`、`qSubtraction` 通过 exact active record ID 做 union/intersection/subtraction,递归 set 在 9 月 11 日补齐;空集、partial、inactive、mixed kind/version、geometry/stable-ID/output-role 混用均拒绝。 | `backend.tests.test_selector_provenance_contract` 35 passed;`cadfs_to_cdsl.tests.test_lowering` 117 passed;`backend.tests.test_engine_runtime_foundation` 140 passed、1 skipped。`00000715` 的变体证明 intersection 空集诊断和 subtraction left-only 语义。 | 全量 corpus 未发现 native `qIntersection`/`qSubtraction` call;仍仅限 FeatureScript 1511、direct provenance child、fillet/chamfer,不能称通用 query-set 能力。shell 对 nested/intersection/subtraction 仍拒绝。 | +| `qOwnerBody` 精确 owner bridge | 为 `UP_TO_BODY` 增加递归 `query_input` 和 `owner_body_contract: exact_input_owner`;先解析唯一 proven topology child,再以完全相同 body ID 投影到唯一 active body record。 | 合成 `00694309` 变体完成 rebuilt;38 项 selector provenance 测试及 lowering 联跑 156 passed。 | 全量 corpus 暂无 native `qOwnerBody`;不支持多 member、multi-solid、later successor、其它 consumer 或 current aggregate/geometry fallback。 | +| fallback 与 replay 收紧 | `selector_intent.version` 成为唯一 canonical version;production `rebuild_candidate` 改为 single-session incremental replay;普通 context selector 只在 active context 中存在唯一同 kind record 时回退,COPY/instance selector 不回退。 | 失败统一保留 selector diagnostic 和 prefix checkpoint;`selector_query_unsupported`、`selector_kernel_history_missing`、`selector_body_member_inactive` 分层可归因。 | 仍缺通用 CAP/SWEPT/OFFSET/COPY/INTERSECT lineage,不能以 stable ID、最终 STEP 或 current body 补齐 source 语义。 | + +### 3.2 OCC topology history、output role 与 body provenance + +| 能力 | 本周新增/完善 | 已验证边界与证据 | 仍未覆盖 | +| --- | --- | --- | --- | +| 多 builder final-history bridge | planar IMPRINT 多区域 blind prism 先分别保留 `BRepPrimAPI_MakePrism` history,再用 `BRepAlgoAPI_Fuse(SetToFillHistory)` 映射到 final snapshot;只登记 `IsSame` 可证明的 source→final relation,deleted/missing branch 保留 `partial/unknown`。 | `00354246` 的 7 个 `SWEPT_FACE` 由 native lineage 解析后在 OCC fillet feasibility 处失败;`00403485` 的 deleted side branch 正确报告 `selector_kernel_history_missing` 并保留 F1 STEP。最终聚合回归为 293 passed、1 skipped。 | 仅限 planar IMPRINT、至少两个 bounded region、无 draft、单向 blind;一般 fuse N:M、draft/trim/multi-body、boolean/COPY/pattern 后继仍未完成。 | +| output-role / all-fragments | direct prism/sweep/loft/shell 的 start/end cap、side、swept edge、offset/closing/wall 等 role 继续以 operation history 保存;`all_fragments` 要求每个 source fragment 都有 complete/proven final relation,不再返回部分集合冒充成功。 | 既有 direct CAP_FACE/CAP_EDGE/SWEPT_FACE/SWEPT_EDGE、shell、loft 回归均按 final snapshot 和 active member 绑定。 | 一般 CAP/SWEPT/OFFSET/MID_CAP、split/merge、复杂 dress-up 后继仍无完整 N:M resolver。 | +| body member / COPY provenance | boolean target/tool 现在接受 `{pattern_feature_id, source_feature_id, instance_index}`;direct surviving `new_body` 的 mirror/circular/transform COPY、single-body successor、fused sole-body circular COPY 和 multi-source transform copy 均增加显式 member/provenance 校验。 | `00000385` F6、`00293508` UNION 及 body graph/runtime 回归证明 target/tool 不再回退到 pattern aggregate;专项 GLB 由同一 rebuild STEP 生成,仅作预览。 | 第二个 `new_body`、aggregate/linear/nested pattern、excluded instance、multi-body boolean/delete/copy 的完整生命周期仍未完成;不能宣称完整 `booleanBodies`/`circularPattern`/`mirror`。 | + +### 3.3 CADFS feature/source contract 扩展 + +| 能力 | 本周新增/完善 | 已验证边界与证据 | 仍未覆盖 | +| --- | --- | --- | --- | +| source-only segmented sweep path | 同一 source sketch 的 ordered line/arc/non-periodic B-spline 多段路径,及跨 source sketch 的 global 3D spatial path;要求 explicit source identity、connected/open/non-branching、无重复/退化段,runtime 用 `BRepBuilderAPI_MakeWire` + pipe-shell。circular pattern 会同步旋转 spatial points/normal/tangent。 | `00376556` 为 executable approximate,`00786708` 的 filtered line/arc/line path 可执行但完整 history rejected;`00610979` 跨 sketch F4 prefix 可执行;`00034285` 的空消息 `AssertionError` 被稳定翻译为 `OCC sweep operation raised while building the native sweep`。 | 仍是 source-only lowering,不是 runtime `qBodyType` selector;closed/branched wire、guide/surface/profile consumer、sweep output lineage、一般 multi-sketch path 未完成。 | +| planar IMPRINT dispatch | 修正多 face `IMPRINT FACE` lowering 的 dispatch,避免把合法多区域 profile 错降成普通 analytic contour;保留 source entities、face side 和 fragment intent。 | `00354246` F1 现在保持 typed `planar_imprint`,F2 的 selector request 不再丢失,失败停在可归因 selector/OCC 层并保留 F1 STEP。 | 不增加 IMPRINT/SWEPT/INTERSECT family 的通用 success coverage;unbounded region、different sketch、trim/copy/pattern successor 仍拒绝。 | +| direct prism topology consumers | 扩展 direct `CAP_FACE` immediate `UP_TO_SURFACE`、`SWEPT_FACE` continuation、`SWEPT_BODY` immediate `UP_TO_BODY`、`CAP_EDGE` fillet/chamfer、CAP_FACE shell removal,以及 direct hole profile 的 `SWEPT_EDGE`/`CAP_EDGE` exact source anchors。 | `00835610` drafted CAP_FACE pipeline RP 通过;`00694309` body target contract 有完整 history;`00039669`、`00151159` 等 direct prism hole lineage matrix 有 strict/RP 证据;未证明的 selector 继续拒绝。 | 复杂 profile、draft 内环、fused/multi-region、boolean/COPY/pattern 后继和通用 CAP/SWEPT selector 仍不在 allow-list。 | +| reference/semantic details | 完善 `LINE_ANGLE` direct `skCircle` axis 与 source gate、`cPlane` `oppositeDirection` signed offset、direct sketch-vertex hole location/sole scoped body、shell `OFFSET_FACE`/`parts` source-qualified contract 和 `INTERSECT EDGE` source-qualified section relation/transient tool history。 | 相关原子、selector/runtime suite 和受控 core/shard 回归已记录;`INTERSECT` 未证明的 SectionEdges 不会直接升级为 lineage。 | 任意曲线/面/connector axis、multi-body hole host、generic OFFSET、复杂 shell、primary/copy/pattern intersection 仍未完成。 | + +### 3.4 诊断和离线工具完善 + +- native sweep 的空 `AssertionError` 现在转成稳定、可归因的 OCC 诊断,不以 geometry fallback 伪造实体。 +- single-session selector replay 和 prefix retention 使失败点、最后可执行 STEP、`history.json`/`rebuild.json`/`diagnostics.json` 保持一致;当前全量报告中的 F2/F4/F3 selector failures 可直接定位到 feature。 +- engine 执行边界继续收敛到显式 schema/capability contract、按 family 的 executor/runtime 路径和 topology evidence export;这使 selector、body lifecycle 和 OCC failure 可以在同一条 replay history 上归因,而不是依赖旧的平行 runtime 路径。 +- 新增 `selector_candidate_demo.py` 的 geometry probe、strict replay、bounded branch search 和 query-group recovery。它只在隔离的 copied CDSL 上实验,并要求唯一 strict branch 后再 fresh replay;**不改变 production resolver、RP 阈值或 capability matrix,因此不计为 engine selector 能力完成。** + +## 4. 本周能力边界与下周重点 + +### 已能对外说明的结果 + +- 转换器可以为大多数样本生成 candidate/bound CDSL,并在失败时保留 prefix STEP;完整转换率已达到 70.89%。 +- selector source AST、版本和 body/provenance policy 不再被静默抹平;一部分 direct prism、IMPRINT、sweep、shell、boolean/pattern/transform 场景能够以 exact kernel history 执行。 +- RP 与 strict comparison 已独立报告;source STEP 精度、拓扑不一致、比较超时和 OCC/runtime failure 不再混为 converter 失败。 + +### 不能宣称已经完成的范围 + +- 不能把 `fillet`、`extrude`、`chamfer`、`revolve`、`shell`、`mirror`、`sweep`、`booleanBodies` 或 `circularPattern` 的单个窄 contract 说成整个 feature family 完成。 +- 不能把 2026-09-10 的 query-group strict winner、专项 GLB 或单个 RP/strict 样本当作通用 selector mapping。 +- 当前最大未覆盖面仍是 selector query unsupported、复杂 profile/拓扑后继、body lifecycle、OCC invalid shape/unbounded IMPRINT 以及 comparison timeout;这些都需要继续保持有界拒绝和可执行前缀。 + +### 建议下周优先级 + +1. 先处理全量 runtime 中占比最高的 `selector_query_unsupported`,按 query family、producer history 和 consumer 分层推进,不用 geometry/current-body fallback 换取通过。 +2. 扩展 planar IMPRINT、CAP/SWEPT/OFFSET 和 boolean/pattern 的多实体 relation component,至少补齐多个真实 source/lifecycle 的 RP 回归后再升级 capability matrix。 +3. 针对 OCC union invalid shape、`BRep_API: command not done`、unbounded IMPRINT 和 comparison timeout 分别建立 kernel/input/infrastructure 证据,继续保留每个失败样本的 STEP/diagnostic/compare 工件。 + +## 5. 证据入口 + +- 最新汇总:`cadfs_to_cdsl/output/full_run_report.md`、`summary.json`、`manifest.jsonl`、`capability_gaps.json`、`comparison_summary.csv` +- 最新样本工件:`cadfs_to_cdsl/output/samples//` +- 历史汇总:`cadfs_to_cdsl/output-history//full_run_report.md` +- 能力进度台账(本地,不提交):`cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md` +- 全量能力目标:`cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md` diff --git a/cadfs_to_cdsl/WEEKLY_REPORT_2026-09-11_SHORT.md b/cadfs_to_cdsl/WEEKLY_REPORT_2026-09-11_SHORT.md new file mode 100644 index 00000000..e125ee56 --- /dev/null +++ b/cadfs_to_cdsl/WEEKLY_REPORT_2026-09-11_SHORT.md @@ -0,0 +1,50 @@ +# CADFS → CDSL 周报(2026-09-11) + +本周完成 9,347 个 CADFS 样本处理,CDSL 完整转换 6,626 个(70.89%),RP/strict 通过 1,781/744 个,失败样本均保留 STEP 前缀。相比 9 月 7 日首次全量归档,候选 CDSL、可执行 STEP 与 RP/strict 接受数均提升;本周新增/完善集中在 selector 血缘、拓扑历史、body/COPY 与受限特征 contract,复杂 selector、多 body 生命周期仍待补齐。 +完善绘图引擎,CADFS 样本处理。 + +## 转换指标 + +| 指标 | 本次 | 首次归档(2026-09-07) | 变化 | +|---|---:|---:|---:| +| 生成候选 CDSL | 8,951 | 7,112 | +1,839 | +| 完整转换 CDSL | 6,626(70.89%) | 2,829(30.27%) | +3,797(+40.62 pp) | +| 生成可执行 STEP | 8,126 | 5,554 | +2,572 | +| RP 通过 | 1,781(19.05%) | 1,358(14.53%) | +423(+4.52 pp) | +| 严格通过 | 744(7.96%) | 654(7.00%) | +90(+0.96 pp) | + +注:几何比对数下降受本次 replay/比较覆盖影响;RP 和 strict 接受数相较首次归档均提升。 + +## 重要改动与新增 + +| 类别 | 能力/指标 | 首次(2026-09-07) | 本次 | 变化 | 结论 | +|---|---|---|---|---|---| +| Selector 源语义 | `selector_intent` / `query_expr@1.0` | 不保留递归 query AST | 保留 set、filter、未知调用 | 新增 contract | 不再将未知 selector 静默近似化 | +| 集合/owner 查询 | `QUERY_SET`、`qOwnerBody` | 无对应 bridge | exact active record-ID 集合、`UP_TO_BODY` owner bridge | 新增 | 仅 FS1511 direct provenance;`qOwnerBody` 仅合成回归 | +| Replay 与回退 | 单次 session、context fallback、前缀保留 | selector 回放/归因较弱 | incremental replay;COPY/instance 不回退 | 收紧 | 失败层与最后 STEP checkpoint 可追溯 | +| 直接 prism 血缘 | CAP/SWEPT/EDGE/BODY consumer | 基础输出 role | extent、fillet/chamfer、shell、hole 等 direct consumer | 扩展 | 仅 direct-prism allow-list,不覆盖复杂后继 | +| IMPRINT 拓扑历史 | 多 face/multi-region | 缺 final relation | 多区域 prism + fuse final-history bridge | 新增/修复 | deleted/missing fragment 保持拒绝 | +| Sweep 路径 | 单一受限路径 | 覆盖有限 | 同/跨草图 line、arc、B-spline spatial wire | 扩展 | 仅 source-only、open/non-branching path | +| Shell contract | face/offset/body source | 主路径有限 | SWEPT_FACE、OFFSET_FACE、parts、方向语义 | 扩展 | 复杂/多 body shell 未完成 | +| Body/COPY/Boolean | aggregate/current body 风险 | 来源校验不足 | pattern instance、sole-body copy、boolean target/tool provenance | 收紧 | 多 body/嵌套 pattern 生命周期未完成 | +| Datum/Hole/Section | 细分 source contract 缺失 | 覆盖有限 | `LINE_ANGLE`、`cPlane`、vertex-hole、`INTERSECT EDGE` | 扩展 | 仅 direct source/sole scoped-body | +| 运行时诊断 | OCC/selector 失败归因 | 信息不稳定 | sweep 异常规范化、strict replay 与 prefix 工件 | 完善 | 离线 query 搜索仅作诊断,不计生产能力 | +| 测试/能力台账 | 回归边界分散 | 证据不完整 | selector/runtime/lowering 与真实 shard 回归补齐 | 完善 | 仍按各能力的受限边界验收 | + +### 主要缺口收敛(同名 capability gap 直接比较) + +| 能力缺口 | 首次 | 本次 | 变化 | +|---|---:|---:|---:| +| `extrude` | 3,600 | 888 | -2,712 | +| `fillet` | 1,904 | 928 | -976 | +| `shell` | 696 | 42 | -654 | +| `revolve` | 662 | 201 | -461 | +| `extrude_profile_topology:intersect` | 383 | 34 | -349 | +| `chamfer` | 588 | 264 | -324 | +| `sweep` | 326 | 40 | -286 | +| `transform` | 252 | 13 | -239 | +| `booleanBodies` | 187 | 36 | -151 | +| `mirror` | 241 | 139 | -102 | +| `cPlane` | 168 | 92 | -76 | + +注:gap 为受影响样本数,同一样本可命中多个 gap;只比较两次快照中同名键。`hole`、`shell_face_selector`、`sweep_path` 等已拆分为新子项,不能直接与首次单项相减。 diff --git a/cadfs_to_cdsl/featurescript_parser.py b/cadfs_to_cdsl/featurescript_parser.py index cffe680a..f2a9d691 100644 --- a/cadfs_to_cdsl/featurescript_parser.py +++ b/cadfs_to_cdsl/featurescript_parser.py @@ -49,6 +49,12 @@ class Parser: def primary(self) -> Any: token = self.pop() + if token.value in {"+", "-"}: + # FeatureScript permits a signed parenthesized scalar such as + # ``-(138.6) / 2 * mm``. Keep it in the existing arithmetic AST + # so every downstream constant/units validator sees the same + # expression shape as a binary subtraction. + return Call("__binary__", [0.0, token.value, self.primary()], token.line) if token.value == "(": value = self.expression(); self.accept(")"); return value if token.kind == "string": return _string(token.value) @@ -151,6 +157,20 @@ def _arg_map(call: Call) -> dict[str, Any]: return next((arg for arg in reversed(call.args) if isinstance(arg, dict)), {}) +def _feature_id(call: Call) -> str | None: + """Return the declared ID for one direct FeatureScript feature call. + + FeatureScript operations share the ``operation(context, id + "F...", + definition)`` shape. Retaining this generic boundary makes an unknown + source operation visible to the capability registry and lowering instead + of silently omitting it because its name is absent from a parser list. + """ + if len(call.args) < 2 or call.args[0] != "context": + return None + feature_id = symbolic_string(call.args[1]) + return feature_id if feature_id.startswith("F") else None + + def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR: parser = Parser(source); calls = parser.statements() version = re.search(r"\bFeatureScript\s+(\d+(?:\.\d+)*)\s*;", source) @@ -175,8 +195,7 @@ def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR: if model.sketches: args = _arg_map(call); eid = str(call.args[1]) if len(call.args) > 1 else f"E{len(model.sketches[-1].entities)}" model.sketches[-1].entities.append(FeatureIR(eid, call.name, args, line_start=call.line, raw_source=call.name)) - elif call.name in {"extrude", "revolve", "fillet", "chamfer", "hole", "linearPattern", "mirror", "cPlane", "referenceAxis", "shell", "loft", "sweep", "circularPattern", "booleanBodies", "deleteBodies", "transform", "draft", "thicken", "split", "moveFace", "replaceFace", "deleteFace", "derive"}: - fid = symbolic_string(call.args[1]) if len(call.args) > 1 else f"feature_{len(model.features)}" + elif (fid := _feature_id(call)) is not None: feature_ir = FeatureIR(fid, call.name, _arg_map(call), line_start=call.line, raw_source=call.name) model.features.append(feature_ir); model.steps.append(feature_ir) return model diff --git a/cadfs_to_cdsl/lowering.py b/cadfs_to_cdsl/lowering.py index 34a12281..004d33cb 100644 --- a/cadfs_to_cdsl/lowering.py +++ b/cadfs_to_cdsl/lowering.py @@ -6,10 +6,20 @@ from dataclasses import dataclass from typing import Any from .featurescript_parser import symbolic_string from .ir import Call, FeatureIR, ModelIR, SketchIR -from .query_parser import parse_query, walk_calls +from .query_parser import parse_query, query_expr, walk_calls UNSUPPORTED = {"draft", "thicken", "split", "moveFace", "replaceFace", "deleteFace", "import", "derive"} + +# The parser preserves every direct ``operation(context, id + "F...", ...)`` +# call. Keep lowering's executable surface explicit so newly observed source +# APIs get a stable unsupported-operation diagnostic rather than disappearing +# from history or falling through to an incidental implementation error. +LOWERABLE_OPERATIONS = { + "assignVariable", "transform", "deleteBodies", "cPoint", "cPlane", "extrude", "loft", "sweep", + "booleanBodies", "revolve", "fillet", "chamfer", "shell", "hole", + "circularPattern", "mirror", +} PLANES = { "Top": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., 0., 1.]}, "Front": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., -1., 0.]}, @@ -71,6 +81,10 @@ def _selector_intent( # like a real FeatureScript version and caused downstream capability # checks to make an unjustified compatibility decision. "source_query": {"ast": query.ast}, + # Keep an executable-independent typed expression as well as the raw + # source AST. This records set boundaries and filters without claiming + # that an unsupported family can be resolved from geometry. + "query_expr": query_expr(value), "derivation_policy": {"allowed": list(allowed), "multiplicity": multiplicity}, "evidence": evidence, } @@ -178,6 +192,52 @@ def _number(value: Any, units: bool = False) -> float: raise ValueError(f"not a constant number: {plain(value)!r}") +def _resolve_source_variables(value: Any, variables: dict[str, Any]) -> Any: + """Resolve exact earlier ``getVariable(context, name)`` source calls. + + CADFS variables are ordered context state, not topology. Lowering captures + the declared expression before it can be consumed by geometry parameters; + a missing or malformed lookup remains a deterministic source error rather + than becoming a guessed numeric default. + """ + if isinstance(value, Call): + if value.name == "getVariable": + if len(value.args) != 2 or value.args[0] != "context" or not isinstance(value.args[1], str): + raise ValueError("getVariable must name one source context variable") + name = value.args[1] + if name not in variables: + raise ValueError(f"source variable is unavailable: {name}") + return deepcopy(variables[name]) + return Call(value.name, [_resolve_source_variables(argument, variables) for argument in value.args], value.line, value.raw) + if isinstance(value, list): + return [_resolve_source_variables(item, variables) for item in value] + if isinstance(value, dict): + return {key: _resolve_source_variables(item, variables) for key, item in value.items()} + return value + + +def _assign_variable_params(params: dict[str, Any]) -> tuple[str, Any, dict[str, Any]]: + """Lower the two exported scalar assignVariable forms without coercion.""" + name = params.get("name") + if not isinstance(name, str) or not name: + raise ValueError("assignVariable requires one non-empty source name") + variants = [(key, params[key]) for key in ("anyValue", "lengthValue") if key in params] + if len(variants) != 1: + raise UnsupportedCapability( + "assign_variable_value", + "assignVariable requires exactly one anyValue or lengthValue source expression", + ) + key, value = variants[0] + numeric_value = _number(value, units=key == "lengthValue") + if not math.isfinite(numeric_value): + raise UnsupportedCapability("assign_variable_value", "assignVariable value must be finite") + return name, value, { + "name": name, + "value": numeric_value, + "value_kind": "length" if key == "lengthValue" else "any", + } + + def _lookup_table_definition(value: Any) -> dict[str, str]: for call in walk_calls(value): if call.name != "lookupTablePath" or not call.args or not isinstance(call.args[0], dict): continue @@ -370,16 +430,40 @@ def _plane_from_query( copied_cap = _pattern_copy_cap_plane(value, feature_frames) if copied_cap is not None: return copied_cap + # Workplane materialization must follow the outer query expression. A + # A derived topology query may contain a nested CAP/SWEPT face, but that + # producer frame is not proof that the outer query has one active face + # successor. ``parse_query`` visits nested calls for diagnostic context, + # so it cannot define this boundary. The explicit mirrored COPY(CAP_FACE) + # contract was handled above; COPY and MERGE workplanes both require a + # dedicated runtime face relation before a sketch can attach to them. + try: + _call, owner, topology, outer_kind, _definition = _direct_make_query(value) + except ValueError: + owner = topology = outer_kind = None query = parse_query(value) - if query.topology_type == "IMPRINT" and sketch_by_source and query.source_sketch in sketch_by_source: + direct_created_by = value + if isinstance(direct_created_by, Call) and direct_created_by.name == "qUnion" and len(direct_created_by.args) == 1 and isinstance(direct_created_by.args[0], list) and len(direct_created_by.args[0]) == 1: + direct_created_by = direct_created_by.args[0][0] + if isinstance(direct_created_by, Call) and direct_created_by.name == "qCreatedBy": + owner = query.owner_feature + if topology == "IMPRINT" and sketch_by_source and query.source_sketch in sketch_by_source: return dict(sketch_by_source[query.source_sketch]["workplane"]) - if query.topology_type == "OFFSET_FACE" and sketch_by_source is not None and entity_by_sketch is not None: + if topology == "OFFSET_FACE" and sketch_by_source is not None and entity_by_sketch is not None: return _offset_face_plane(value, feature_frames, sketch_by_source, entity_by_sketch) - frame = feature_frames.get(query.owner_feature or "") - if frame and query.topology_type == "CAP_FACE": + if topology == "COPY" and outer_kind == "face": + raise ValueError( + "COPY(FACE) workplane requires a dedicated complete/proven runtime face relation" + ) + if topology == "MERGE" and outer_kind == "face": + raise ValueError( + "MERGE(FACE) workplane requires a dedicated complete/proven runtime face relation" + ) + frame = feature_frames.get(owner or "") + if frame and topology == "CAP_FACE": cap = "start" if query.is_start is not False else "end" return dict(frame.get(f"{cap}_attachment") or frame[cap]) - if frame and frame.get("start") == frame.get("end") and "qCreatedBy" in query.calls: + if frame and frame.get("start") == frame.get("end") and isinstance(direct_created_by, Call) and direct_created_by.name == "qCreatedBy": return dict(frame.get("start_attachment") or frame["start"]) raise ValueError("unsupported or unresolved sketch workplane") @@ -389,7 +473,12 @@ def _bound_name(value: Any) -> str: def _is_new_body_operation(value: str) -> bool: - return value.rsplit(".", 1)[-1] == "NEW" + # FeatureScript ``NewBodyOperationType.NEW`` creates an independent + # context body. ``ADD`` is a boolean union into its merge scope, so it + # must keep CDSL's default fusing result mode rather than being rewritten + # as an independent member. + normalized = str(value or "").upper() + return normalized.rsplit(".", 1)[-1] == "NEW" def _has_active_body(features: list[dict[str, Any]]) -> bool: @@ -420,9 +509,26 @@ def _extent_reference( sketches_by_id: dict[str, dict[str, Any]] | None = None, previous: list[str] | None = None, allow_cap_output_role: bool = False, + allow_primary_add_up_to_surface: bool = False, allow_direct_prism_swept_lineage: bool = False, featurescript_version: str | None = None, ) -> dict[str, Any]: + # qOwnerBody projects a selected topology member to its containing active + # body. Its nested makeQuery retains the member kind, so recognize the + # narrow executable bridge before checking the outer extent kind. + if expected_kind == "body": + owner_body = _direct_owner_body_extent_selector( + value, + feature_by_id=feature_by_id or {}, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id or {}, + entity_by_sketch=entity_by_sketch, + previous=previous or [], + featurescript_version=featurescript_version, + ) + if owner_body is not None: + return owner_body query = parse_query(value) if query.kind not in {expected_kind, f"entitytype.{expected_kind}"}: raise ValueError(f"extrude extent target is not a {expected_kind}") @@ -442,6 +548,27 @@ def _extent_reference( and previous[-1:] == [cap_output_role["owner_feature_id"]] ): return cap_output_role + # A primary ADD prism is transient: its cap only becomes selectable + # after the exact union topology delta proves one active successor. + # This is intentionally a separate consumer opt-in from the direct + # new-body cap bridge, and remains limited to the immediately + # following one-sided up-to-surface extent. + primary_add_cap_output_role = ( + _cap_face_output_role_selector( + value, + feature_by_id or {}, + sketches_by_id or {}, + allow_primary_add_up_to_surface=True, + ) + if feature_by_id is not None and sketches_by_id is not None + else None + ) + if ( + allow_primary_add_up_to_surface + and primary_add_cap_output_role is not None + and previous[-1:] == [primary_add_cap_output_role["owner_feature_id"]] + ): + return primary_add_cap_output_role # A direct blind prism side wall is not an output-role shortcut. Its # source edge and every later continuation must be proven by the # topology registry. This bounded consumer only emits the executable @@ -465,6 +592,7 @@ def _extent_reference( previous=previous or [], featurescript_version=featurescript_version, allow_continuation=True, + allow_immediate_retained_source_edge=True, ) if swept_lineage is not None: return swept_lineage @@ -477,18 +605,16 @@ def _extent_reference( ) if swept_body is not None: return swept_body - if not query.owner_feature: - raise ValueError("extrude extent target owner is unresolved") - owner = f"f_{query.owner_feature}" - reference = { - "kind": expected_kind, - "owner_feature_id": owner, - "stable_id": f"cadfs_{owner}_{expected_kind}", - "source": "solidworks", - "confidence": 1.0, - "selector_intent": _deferred_featurescript_selector_intent(value, kind=expected_kind), - } - return reference + # A direct source owner alone is not evidence for an active result + # topology member. In particular, a deferred CAP/SWEPT query cannot be + # converted into an extent reference via a stable ID or a geometric + # fallback: that would authorize an arbitrary later/current body and can + # also leave an owner that was never lowered. Keep the feature deferred + # until a bounded provenance contract above proves the exact reference. + raise UnsupportedCapability( + f"extrude_extent_{expected_kind}_selector", + f"current CDSL {expected_kind} extent requires a complete/proven active selector reference", + ) def _direct_make_query(value: Any) -> tuple[Call, str, str, str, dict[str, Any]]: @@ -537,7 +663,7 @@ def _direct_swept_body_extent_selector( or previous[-1:] != [producer_id] or producer.get("atomic_id") != "extrude_add_blind" or params.get("result_mode") != "new_body" - or (params.get("end_condition") or {}).get("type") != "blind" + or (params.get("end_condition") or {}).get("type") not in {"blind", "through_all"} or params.get("draft") is not None ): return None @@ -558,6 +684,335 @@ def _direct_swept_body_extent_selector( } +def _direct_prism_cap_vertex_extent_selector( + value: Any, + *, + feature_by_id: dict[str, dict[str, 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]]], + previous: list[str], + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Lower one immediate direct-prism CAP_VERTEX target to kernel history. + + A CAP vertex is not identified by its cap-plane coordinates. The two + source profile edges must name exactly one original shared endpoint, and + OCC must carry that source vertex to the requested prism cap. This first + contract deliberately excludes continuation, draft, multi-profile, and + additive/cutting body lifecycles. + """ + if featurescript_version != "1511": + return None + try: + _call, owner, topology, kind, _definition = _direct_make_query(value) + except ValueError: + return None + query = parse_query(value) + producer_id = f"f_{owner}" + producer = feature_by_id.get(producer_id) or {} + params = producer.get("params") or {} + frame = feature_frames.get(owner) or {} + profile_source = frame.get("profile_source") + profile_sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) + source_sketch = sketch_by_source.get(profile_source) if isinstance(profile_source, str) else None + if producer_id not in previous: + return None + producer_index = previous.index(producer_id) + intervening = previous[producer_index + 1:] + # An independent member can remain selectable across datum features and + # later independent new-body creation. Any operation that may mutate, + # fuse, delete, copy, or transform the producer is deliberately excluded; + # runtime still has to prove the exact member-preservation relation. + preserves_member = all( + (feature_by_id.get(feature_id) or {}).get("atomic_id") == "reference_plane" + or ( + (feature_by_id.get(feature_id) or {}).get("atomic_id") == "extrude_add_blind" + and ((feature_by_id.get(feature_id) or {}).get("params") or {}).get("result_mode") == "new_body" + and (((feature_by_id.get(feature_id) or {}).get("params") or {}).get("end_condition") or {}).get("type") == "blind" + and ((feature_by_id.get(feature_id) or {}).get("params") or {}).get("draft") is None + ) + for feature_id in intervening + ) + if ( + topology != "CAP_VERTEX" + or kind not in {"vertex", "entitytype.vertex"} + or query.is_start is None + or not preserves_member + or producer.get("atomic_id") != "extrude_add_blind" + or params.get("result_mode") != "new_body" + or (params.get("end_condition") or {}).get("type") not in {"blind", "through_all"} + or params.get("draft") is not None + or not isinstance(profile_source, str) + or source_sketch is None + or profile_sketch is None + or profile_sketch.get("source_sketch_id") != profile_source + or not _profile_matches_direct_source(profile_sketch, source_sketch) + ): + return None + refs = _source_refs(value) + if len(refs) != 2 or {source for source, _token in refs} != {profile_source}: + return None + source_ids = _direct_profile_source_entity_ids(profile_sketch) + resolved_ids: list[str] = [] + for source, token in refs: + resolved = _source_ref_entity(source, token, entity_by_sketch) + if resolved is None: + return None + entity_id, entity = resolved + if entity.get("construction") or entity_id not in source_ids or entity_id in resolved_ids: + return None + resolved_ids.append(entity_id) + if _shared_source_endpoint(refs, profile_source, entity_by_sketch) is None: + return None + intent = _selector_intent( + value, + query_family="CAP_VERTEX", + kind="vertex", + evidence="kernel_history", + allowed=("boundary", "continuation") if intervening else ("boundary",), + ) + intent.pop("source_entity", None) + intent["source_entities"] = [ + {"sketch_id": profile_source, "entity_id": entity_id} + for entity_id in sorted(resolved_ids) + ] + intent["lineage_role"] = f"extrude.{'start' if query.is_start else 'end'}" + return { + "kind": "vertex", + "owner_feature_id": producer_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": intent, + } + + +def _direct_source_vertex_extent_reference( + value: Any, + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + *, + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Capture one direct source-sketch vertex as an immutable extent datum. + + This is deliberately not a topology selector. A direct ``sQuery`` vertex + is defined by the source sketch's explicit workplane and entity endpoint, + so it can supply an extrusion distance without asking the runtime to find + a similarly placed B-rep vertex. Composed, derived, runtime-topology, and + multi-item query forms stay outside this datum contract. + """ + if featurescript_version != "1511": + return None + queries = _queries(value) + if len(queries) != 1 or not _is_direct_hole_location_query(queries[0]): + return None + source = _direct_hole_location(queries[0], sketch_by_source, entity_by_sketch) + if source is None: + return None + local_point, plane = source + info = parse_query(queries[0]) + if not isinstance(info.source_sketch, str) or not isinstance(info.source_entity, str): + return None + return { + "kind": "source_vertex", + "source_sketch_id": info.source_sketch, + "source_entity_id": info.source_entity, + "point_mm": _global(plane, local_point[:2]), + } + + +def _direct_prism_cap_vertex_datum_point( + value: Any, + *, + feature_by_id: dict[str, dict[str, 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]]], + previous: list[str], + featurescript_version: str | None, +) -> list[float] | None: + """Calculate one source-defined direct-prism cap vertex for a datum. + + This is deliberately separate from runtime selector resolution. A datum + frame needs a physical source point, while an extent needs a live active + B-rep vertex. Both nevertheless require the same direct prism and OSD + proof; no stale frame, transformed body, geometry search, or STEP result + may stand in for a CAP_VERTEX query. + """ + if featurescript_version != "1511": + return None + try: + _call, owner, topology, kind, _definition = _direct_make_query(value) + except ValueError: + return None + query = parse_query(value) + producer_id = f"f_{owner}" + producer = feature_by_id.get(producer_id) or {} + params = producer.get("params") or {} + frame = feature_frames.get(owner) or {} + profile_source = frame.get("profile_source") + profile_sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) + source_sketch = sketch_by_source.get(profile_source) if isinstance(profile_source, str) else None + if producer_id not in previous: + return None + producer_index = previous.index(producer_id) + intervening = previous[producer_index + 1:] + + def preserves_datum_source(feature_id: str) -> bool: + feature = feature_by_id.get(feature_id) or {} + atomic_id = feature.get("atomic_id") + params = feature.get("params") or {} + if atomic_id == "reference_plane": + return True + if ( + atomic_id == "extrude_add_blind" + and params.get("result_mode") == "new_body" + and (params.get("end_condition") or {}).get("type") == "blind" + and params.get("draft") is None + ): + return True + # A direct one-cap shell can intervene for a datum-only CAP_VERTEX: + # makeQuery(owner.opExtrude, CAP_VERTEX) names the source-defined + # producer cap position, not a current shell-result vertex. Restrict + # it to one exact producer cap role; side/set shells lack that proof. + selectors = feature.get("selectors") or () + return ( + atomic_id == "shell" + and len(selectors) == 1 + and isinstance(selectors[0], dict) + and selectors[0].get("kind") == "face" + and selectors[0].get("owner_feature_id") == producer_id + and selectors[0].get("output_role") in {"extrude.start", "extrude.end"} + and (selectors[0].get("selector_intent") or {}).get("query_family") == "CAP_FACE" + and (selectors[0].get("selector_intent") or {}).get("evidence") == "operation_role" + ) + + preserves_source_datum = all( + preserves_datum_source(feature_id) + for feature_id in intervening + ) + if ( + topology != "CAP_VERTEX" + or kind not in {"vertex", "entitytype.vertex"} + or query.is_start is None + or not preserves_source_datum + or producer.get("atomic_id") != "extrude_add_blind" + or params.get("result_mode") != "new_body" + or (params.get("end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None + or not isinstance(profile_source, str) + or source_sketch is None + or profile_sketch is None + or profile_sketch.get("source_sketch_id") != profile_source + or not _profile_matches_direct_source(profile_sketch, source_sketch) + ): + return None + refs = _source_refs(value) + if len(refs) != 2 or {source for source, _token in refs} != {profile_source}: + return None + source_ids = _direct_profile_source_entity_ids(profile_sketch) + resolved_ids: set[str] = set() + for source, token in refs: + resolved = _source_ref_entity(source, token, entity_by_sketch) + if resolved is None: + return None + entity_id, entity = resolved + if entity.get("construction") or entity_id not in source_ids or entity_id in resolved_ids: + return None + resolved_ids.add(entity_id) + local_point = _shared_source_endpoint(refs, profile_source, entity_by_sketch) + cap = frame.get("start" if query.is_start else "end") + profile = frame.get("profile") + if ( + local_point is None + or not isinstance(cap, dict) + or not isinstance(profile, dict) + or not isinstance(cap.get("origin_mm"), list) + ): + return None + return _global({**profile, "origin_mm": list(cap["origin_mm"])}, local_point) + + +def _q_owner_body_input(value: Any) -> Any | None: + """Return the sole source input of a direct ``qOwnerBody`` expression.""" + if not isinstance(value, Call) or value.name != "qOwnerBody" or len(value.args) != 1: + return None + return value.args[0] + + +def _direct_owner_body_extent_selector( + value: Any, + *, + feature_by_id: dict[str, dict[str, 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]]], + previous: list[str], + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Lower one exact topology-to-active-body ``qOwnerBody`` extent. + + This is deliberately an ownership projection over a direct, already + executable topology selector. It does not infer an owner from the + current aggregate, an OCC proximity search, or a body-producing feature. + """ + if featurescript_version != "1511": + return None + query_input = _q_owner_body_input(value) + if query_input is None: + return None + try: + _call, owner, topology, kind, _definition = _direct_make_query(query_input) + except ValueError: + return None + if kind != "face" or topology not in {"CAP_FACE", "SWEPT_FACE"}: + return None + producer_id = f"f_{owner}" + if topology == "SWEPT_FACE": + input_selector = _direct_prism_swept_selector( + query_input, + owner=owner, + selector_kind="face", + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=featurescript_version, + ) + else: + input_selector = _cap_face_output_role_selector( + query_input, feature_by_id, sketches_by_id, + ) if previous[-1:] == [producer_id] else None + if input_selector is None: + return None + intent = _selector_intent( + value, + query_family="OWNER_BODY", + kind="body", + evidence="kernel_history", + allowed=("boundary",), + ) + intent.update({ + "owner_body_contract": "exact_input_owner", + "body_scope": "active_member", + "empty_policy": "reject", + "multiple_policy": "one", + }) + return { + "kind": "body", + "source": "runtime_snapshot", + "confidence": 1.0, + "query_input": input_selector, + "selector_intent": intent, + } + + def _face_reference( value: Any, feature_frames: dict[str, dict[str, Any]], @@ -675,11 +1130,22 @@ def _direct_linear_extrude_swept_face_shell_reference( 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 "")) + immediate_producer = previous[-1:] == [producer_id] + immediate_primary_cut = False + if len(previous) >= 2 and previous[-2] == producer_id: + cut = feature_by_id.get(previous[-1]) or {} + cut_params = cut.get("params") or {} + immediate_primary_cut = ( + cut.get("atomic_id") == "extrude_cut_blind" + and cut.get("depends_on") == [producer_id] + and (cut_params.get("end_condition") or {}).get("type") == "blind" + and cut_params.get("draft") is None + ) 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 (immediate_producer or immediate_primary_cut) or not isinstance(frame.get("profile"), dict) or not isinstance(frame.get("start"), dict) or not isinstance(frame.get("end"), dict) @@ -726,6 +1192,9 @@ def _direct_linear_extrude_swept_face_shell_reference( entity_by_sketch=entity_by_sketch, previous=previous, featurescript_version=featurescript_version, + # The only non-immediate shell tuple keeps one exact target-side + # BRepAlgoAPI_Cut continuation between the prism and shell. + allow_continuation=immediate_primary_cut, ) if lineage_selector is not None: return lineage_selector @@ -815,6 +1284,118 @@ def _shell_offset_face_output_role_selector( } +def _shell_retained_direct_prism_cap_offset_face_profile_selector( + value: Any, + feature_by_id: dict[str, dict[str, Any]], + sketches_by_id: dict[str, dict[str, Any]], + previous: list[str], + *, + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Use a retained shell cap as an extrusion profile only with full source proof. + + This is deliberately distinct from the TDD OFFSET_FACE selector bridge. + The exported query names the entire original direct-prism profile through + one OSD set, while the immediately preceding shell removes the opposite + cap. The runtime still has to prove the retained cap -> offset-face + kernel relation; source-profile membership alone never selects a wall. + """ + if featurescript_version != "1511": + return None + try: + _call, shell_name, topology, kind, definition = _direct_make_query(value) + except ValueError: + return None + shell_id = f"f_{shell_name}" + shell = feature_by_id.get(shell_id) or {} + if ( + topology != "OFFSET_FACE" + or kind not in {"face", "entitytype.face"} + or previous[-1:] != [shell_id] + or shell.get("atomic_id") != "shell" + ): + return None + + # This contract describes one retained cap only. More than one removal + # target can alter the cap's topology and must remain a diagnostic. + shell_selectors = shell.get("selectors") or [] + if len(shell_selectors) != 1 or not isinstance(shell_selectors[0], dict): + return None + removed = shell_selectors[0] + removed_intent = removed.get("selector_intent") or {} + source_id = removed.get("owner_feature_id") + removed_role = removed.get("output_role") + if ( + not isinstance(source_id, str) + or source_id not in feature_by_id + or removed_role not in {"extrude.start", "extrude.end"} + or removed_intent.get("query_family") != "CAP_FACE" + or shell.get("depends_on") != [source_id] + ): + return None + source_feature = feature_by_id[source_id] + source_params = source_feature.get("params") or {} + source_sketch = sketches_by_id.get(str(source_feature.get("sketch_id") or "")) + if ( + 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" + or source_params.get("draft") is not None + or not isinstance(source_sketch, dict) + ): + return None + source_ids = _direct_profile_source_entity_ids(source_sketch) + profile_source = source_sketch.get("source_sketch_id") + query = parse_query(value) + refs = _source_refs(value) + if ( + not source_ids + or not isinstance(profile_source, str) + or query.source_sketch != profile_source + or len(refs) != len(source_ids) + or {sketch_id for sketch_id, _entity_id in refs} != {profile_source} + or {entity_id for _sketch_id, entity_id in refs} != source_ids + ): + return None + disambiguation = definition.get("disambiguationData") + if ( + not isinstance(disambiguation, list) + or len(disambiguation) != 1 + or not isinstance(disambiguation[0], Call) + or disambiguation[0].name not in {"OSD", "originalSetDisambiguation"} + or len(disambiguation[0].args) != 1 + or not isinstance(disambiguation[0].args[0], list) + ): + return None + + retained_role = "extrude.end" if removed_role == "extrude.start" else "extrude.start" + intent = _selector_intent( + value, + query_family="OFFSET_FACE", + kind="face", + evidence="operation_role", + allowed=("boundary", "replacement"), + output_role="shell.offset_face", + disambiguation={ + "source_profile_entity_ids": sorted(source_ids), + "removed_cap_role": removed_role, + }, + ) + intent["consumer_contract"] = "shell_retained_direct_prism_cap_offset_face_profile" + return { + "kind": "face", + "owner_feature_id": shell_id, + "output_role": "shell.offset_face", + "output_role_source": { + "owner_feature_id": source_id, + "output_role": retained_role, + }, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": intent, + } + + def _pattern_copy_face_reference( value: Any, feature_frames: dict[str, dict[str, Any]], @@ -1199,6 +1780,146 @@ def _queries(value: Any) -> list[Any]: return [value] +def _direct_query_set_operands(value: Any) -> tuple[str, str, list[Any]] | None: + """Return a direct source set only when its operand boundary is explicit.""" + if not isinstance(value, Call): + return None + if value.name == "qUnion": + if len(value.args) != 1 or not isinstance(value.args[0], list): + return None + operands = value.args[0] + contract = "proven_operand_union" + operator = "union" + elif value.name == "qIntersection": + operands = value.args[0] if len(value.args) == 1 and isinstance(value.args[0], list) else value.args + contract = "proven_operand_intersection" + operator = "intersection" + elif value.name == "qSubtraction": + operands = value.args + contract = "proven_operand_subtraction" + operator = "subtraction" + else: + return None + if ( + not isinstance(operands, list) + or len(operands) < 2 + or contract == "proven_operand_subtraction" and len(operands) != 2 + ): + return None + return contract, operator, operands + + +def _query_set_leaf_values(value: Any) -> list[Any]: + """Flatten only the leaves of an explicit recursive source set tree. + + The flattened list is an implementation detail of lowering: it lets the + existing direct-provenance constructors handle every ``makeQuery`` leaf. + ``_proven_query_set_selector`` below reconstructs the exact source tree, + so flattening here never turns nested FeatureScript set semantics into an + unstructured selector list. + """ + source_set = _direct_query_set_operands(value) + if source_set is None: + return [value] + return [leaf for operand in source_set[2] for leaf in _query_set_leaf_values(operand)] + + +def _proven_query_set_selector(value: Any, selectors: list[dict[str, Any]]) -> dict[str, Any] | None: + """Preserve a recursive FeatureScript query set with proven operands. + + A selector set is not a list of equivalent single-selector decisions. + Every set node retains its own source expression and runtime composes only + already-proven direct leaf results. Filters and non-``makeQuery`` leaves + stay deferred, but unions/intersections/subtractions can be freely nested + when every branch has the same exact source provenance contract. + """ + if _direct_query_set_operands(value) is None: + return None + + def direct_leaf(selector_value: Any, selector: dict[str, Any]) -> set[str] | None: + """Return the leaf derivations only for an executable provenance leaf.""" + intent = selector.get("selector_intent") + policy = intent.get("derivation_policy") if isinstance(intent, dict) else None + expression = intent.get("query_expr") if isinstance(intent, dict) else None + if ( + not isinstance(selector_value, Call) + or selector_value.name != "makeQuery" + or + selector.get("source") != "runtime_snapshot" + or any(selector.get(key) is not None for key in ( + "stable_id", "snapshot_id", "geometry", "binding_feature_id", + )) + or not isinstance(intent, dict) + or intent.get("query_family") in {None, "GEOMETRIC", "QUERY_SET"} + or not isinstance(policy, dict) + or policy.get("multiplicity") == "none" + or not isinstance(expression, dict) + or expression.get("root") != query_expr(selector_value)["root"] + ): + return None + return {item for item in policy.get("allowed") or () if isinstance(item, str)} + + cursor = 0 + + def build(node: Any) -> tuple[dict[str, Any], set[str]] | None: + nonlocal cursor + source_set = _direct_query_set_operands(node) + if source_set is None: + if cursor >= len(selectors): + return None + selector = selectors[cursor] + cursor += 1 + allowed = direct_leaf(node, selector) + return (selector, allowed) if allowed else None + + contract, _operator, operands = source_set + children: list[dict[str, Any]] = [] + allowed: set[str] = set() + for operand in operands: + child = build(operand) + if child is None: + return None + child_selector, child_allowed = child + children.append(child_selector) + allowed.update(child_allowed) + kinds = {child.get("kind") for child in children} + if len(kinds) != 1 or next(iter(kinds), None) not in {"face", "edge"} or not allowed: + return None + kind = next(iter(kinds)) + intent = _selector_intent( + node, + query_family="QUERY_SET", + kind=kind, + evidence="kernel_history", + allowed=tuple(item for item in ( + "continuation", "fragment", "merge", "intersection", "boundary", "replacement", + ) if item in allowed), + multiplicity="source_qualified", + ) + # A parent set has no singular source anchor. The exact anchors remain + # in its recursively matched child selectors. + intent.pop("source_entity", None) + intent.update({ + "query_set_contract": contract, + "set_kind": kind, + "body_scope": "active_member", + "empty_policy": "reject", + "multiple_policy": "all", + }) + return { + "kind": kind, + "source": "runtime_snapshot", + "confidence": 1.0, + "query_operands": children, + "selector_intent": intent, + }, allowed + + result = build(value) + if result is None or cursor != len(selectors): + return None + return result[0] + + def _source_refs(value: Any) -> list[tuple[str, str]]: refs = [] for call in walk_calls(value): @@ -1455,6 +2176,40 @@ def _direct_profile_source_entity_ids(profile_sketch: dict[str, Any]) -> set[str return source_ids +def _has_one_exact_retained_source_edge( + selected: dict[str, Any], + source: dict[str, Any], + source_entity_id: str, +) -> bool: + """Prove one source curve remains one unchanged selected profile edge. + + A bounded IMPRINT region may deliberately omit other source loops while + retaining an original outer boundary. That is not a complete-direct- + profile contract, but it can still supply one source-qualified prism wall + when the exact curve survives unchanged and the adapter preserves its OCC + handle. Do not use labels alone: transformed, split, duplicate, or + cross-frame curves are not equivalent source edges. + """ + if selected.get("workplane") != source.get("workplane"): + return False + + def segments(sketch: dict[str, Any]) -> list[dict[str, Any]]: + profile = sketch.get("profile") or {} + if profile.get("type") != "analytic_contours": + return [] + return [ + segment + for contour in profile.get("contours") or () + if isinstance(contour, dict) + for segment in contour.get("segments") or () + if isinstance(segment, dict) and segment.get("source_entity_id") == source_entity_id + ] + + source_segments = segments(source) + selected_segments = segments(selected) + return len(source_segments) == len(selected_segments) == 1 and selected_segments[0] == source_segments[0] + + def _direct_prism_swept_selector( value: Any, *, @@ -1468,6 +2223,7 @@ def _direct_prism_swept_selector( previous: list[str], featurescript_version: str | None, allow_continuation: bool = False, + allow_immediate_retained_source_edge: bool = False, ) -> dict[str, Any] | None: """Lower one direct source-profile prism query to kernel lineage intent. @@ -1483,8 +2239,6 @@ def _direct_prism_swept_selector( topology = query.topology_type if topology not in {"CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE"}: return None - if featurescript_version != "1511": - return None producer_id = f"f_{owner}" producer = feature_by_id.get(producer_id) or {} params = producer.get("params") or {} @@ -1492,18 +2246,47 @@ def _direct_prism_swept_selector( profile_source = frame.get("profile_source") profile_sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) source_sketch = sketch_by_source.get(profile_source) if isinstance(profile_source, str) else None + two_sided_cap_contract = ( + topology == "CAP_EDGE" + and featurescript_version == "2491" + and producer.get("atomic_id") == "extrude_add_two_sided" + and (params.get("reverse_end_condition") or {}).get("type") == "blind" + ) + if featurescript_version != "1511" and not two_sided_cap_contract: + return None immediate_producer = previous[-1:] == [producer_id] + result_mode = params.get("result_mode") + # A primary ADD is represented by the default/fuse result mode. Its + # direct prism result may continue into the active body only when the + # runtime's singular OCC union records a complete relation. Lowering + # keeps the source contract identical to an independent prism; it merely + # permits the resolver to demand that extra continuation proof. + is_primary_add = ( + topology == "CAP_EDGE" + and producer.get("atomic_id") == "extrude_add_blind" + and result_mode != "new_body" + ) + direct_profile = _profile_matches_direct_source(profile_sketch or {}, source_sketch or {}) if ( - producer.get("atomic_id") not in {"extrude_add_blind", "extrude_cut_blind"} + producer.get("atomic_id") not in ( + {"extrude_add_two_sided"} + if two_sided_cap_contract + else {"extrude_add_blind", "extrude_cut_blind"} + ) or (not immediate_producer and (not allow_continuation or producer_id not in previous)) - or params.get("result_mode") != "new_body" + or (result_mode != "new_body" and not is_primary_add) or (params.get("end_condition") or {}).get("type") != "blind" or params.get("draft") is not None or not isinstance(profile_source, str) or source_sketch is None or profile_sketch is None or profile_sketch.get("source_sketch_id") != profile_source - or not _profile_matches_direct_source(profile_sketch, source_sketch) + or not direct_profile + and not ( + allow_immediate_retained_source_edge + and immediate_producer + and topology == "SWEPT_FACE" + ) ): return None source_ids = _direct_profile_source_entity_ids(profile_sketch) @@ -1527,7 +2310,9 @@ def _direct_prism_swept_selector( query_family="CAP_EDGE", kind="edge", evidence="kernel_history", - allowed=("boundary", "continuation") if allow_continuation else ("boundary",), + allowed=("boundary",) if two_sided_cap_contract else ( + ("boundary", "continuation") if (allow_continuation or is_primary_add) else ("boundary",) + ), ) intent["lineage_role"] = f"extrude.{'start' if query.is_start else 'end'}" return { @@ -1550,18 +2335,28 @@ def _direct_prism_swept_selector( entity = (entity_by_sketch.get(profile_source) or {}).get(refs[0][1]) if not entity or entity.get("construction"): return None + retained_source_edge = not direct_profile + if retained_source_edge and not _has_one_exact_retained_source_edge( + profile_sketch, source_sketch, refs[0][1], + ): + return None + intent = _selector_intent( + value, + query_family="SWEPT_FACE", + kind="face", + evidence="kernel_history", + allowed=("boundary",) if retained_source_edge else ( + ("boundary", "continuation") if allow_continuation else ("boundary",) + ), + ) + if retained_source_edge: + intent["consumer_contract"] = "immediate_retained_source_prism_swept_face_up_to_surface" return { "kind": "face", "owner_feature_id": producer_id, "source": "runtime_snapshot", "confidence": 1.0, - "selector_intent": _selector_intent( - value, - query_family="SWEPT_FACE", - kind="face", - evidence="kernel_history", - allowed=("boundary", "continuation") if allow_continuation else ("boundary",), - ), + "selector_intent": intent, } if selector_kind != "edge" or len(refs) < 2 or {source for source, _token in refs} != {profile_source}: return None @@ -1582,7 +2377,7 @@ def _direct_prism_swept_selector( query_family="SWEPT_EDGE", kind="edge", evidence="kernel_history", - allowed=("boundary",), + allowed=("boundary", "continuation") if allow_continuation else ("boundary",), ) intent.pop("source_entity", None) intent["source_entities"] = [ @@ -1598,6 +2393,1078 @@ def _direct_prism_swept_selector( } +def _direct_prism_shell_offset_edge_tdd_selector( + value: Any, + *, + owner: str, + feature_by_id: dict[str, dict[str, 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]]], + previous: list[str], + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Lower one retained direct-prism cap edge from an outer shell OFFSET_EDGE. + + The TDD form is not a request for the shell's inner wall. It names a + CAP_EDGE on the cap retained by an immediately following shell, so the + executable witness is the direct-prism cap boundary followed by one exact + shell continuation. OSD-only pairs, generated inner-wall edges, and + delayed shell consumers deliberately remain deferred. + """ + if featurescript_version != "1511": + return None + try: + _call, shell_owner, topology, kind, definition = _direct_make_query(value) + except ValueError: + return None + if shell_owner != owner or topology != "OFFSET_EDGE" or kind not in {"edge", "entitytype.edge"}: + return None + disambiguation = definition.get("disambiguationData") + if not isinstance(disambiguation, list) or len(disambiguation) != 2: + return None + original_set, tdd = disambiguation + if ( + not isinstance(original_set, Call) or original_set.name != "OSD" or len(original_set.args) != 1 + or not isinstance(original_set.args[0], list) or len(original_set.args[0]) != 1 + or not isinstance(tdd, Call) or tdd.name != "TDD" or len(tdd.args) != 1 + or not isinstance(tdd.args[0], list) or len(tdd.args[0]) != 1 + ): + return None + outer_refs = _source_refs(original_set) + nested = tdd.args[0][0] + try: + _nested_call, producer_owner, nested_topology, nested_kind, nested_definition = _direct_make_query(nested) + except ValueError: + return None + if nested_topology != "CAP_EDGE" or nested_kind not in {"edge", "entitytype.edge"}: + return None + nested_disambiguation = nested_definition.get("disambiguationData") + if ( + len(outer_refs) != 1 + or not isinstance(nested_disambiguation, list) or len(nested_disambiguation) != 1 + or not isinstance(nested_disambiguation[0], Call) or nested_disambiguation[0].name != "OSD" + or len(nested_disambiguation[0].args) != 1 + or not isinstance(nested_disambiguation[0].args[0], list) or len(nested_disambiguation[0].args[0]) != 1 + or _source_refs(nested_disambiguation[0]) != outer_refs + ): + return None + cap_selector = _direct_prism_swept_selector( + nested, + owner=producer_owner, + selector_kind="edge", + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=featurescript_version, + allow_continuation=True, + ) + if cap_selector is None: + return None + producer_id = f"f_{producer_owner}" + shell_id = f"f_{shell_owner}" + shell = feature_by_id.get(shell_id) or {} + cap_intent = cap_selector.get("selector_intent") or {} + cap_role = cap_intent.get("lineage_role") + shell_selectors = shell.get("selectors") or [] + if ( + previous[-1:] != [shell_id] + or shell.get("atomic_id") != "shell" + or shell.get("depends_on") != [producer_id] + or (shell.get("params") or {}).get("inward") is not True + or len(shell_selectors) != 1 + or not isinstance(shell_selectors[0], dict) + or shell_selectors[0].get("owner_feature_id") != producer_id + or shell_selectors[0].get("output_role") + != ("extrude.end" if cap_role == "extrude.start" else "extrude.start") + ): + return None + source_entity = cap_intent.get("source_entity") + if not isinstance(source_entity, dict): + return None + intent = _selector_intent( + value, + query_family="OFFSET_EDGE", + kind="edge", + evidence="kernel_history", + allowed=("boundary", "continuation"), + disambiguation={ + "type": "offset_edge_tdd_cap_continuation", + "shell_feature_id": shell_id, + "outer_owner_feature_id": shell_id, + "tdd_cap_owner_feature_id": producer_id, + "tdd_cap_role": cap_role, + "source_entity": dict(source_entity), + }, + ) + intent["source_entity"] = dict(source_entity) + intent["lineage_role"] = cap_role + intent["consumer_contract"] = "direct_prism_shell_offset_edge_tdd" + return { + "kind": "edge", + "owner_feature_id": producer_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": intent, + } + + +def _direct_prism_shell_offset_edge_vertex_selector( + value: Any, + *, + owner: str, + feature_by_id: dict[str, dict[str, 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]]], + previous: list[str], + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Lower an OSD-only shell ``OFFSET_EDGE`` anchored at one profile vertex. + + This is deliberately distinct from the retained-cap TDD form. Two + incident source profile edges name one source vertex, whose direct-prism + swept edge must survive the immediately following shell by a complete + kernel continuation. A shell-wall generated from a removed cap edge is + neither this source vertex nor a substitute for it. + """ + if featurescript_version != "1511": + return None + try: + _call, shell_owner, topology, kind, definition = _direct_make_query(value) + except ValueError: + return None + if shell_owner != owner or topology != "OFFSET_EDGE" or kind not in {"edge", "entitytype.edge"}: + return None + disambiguation = definition.get("disambiguationData") + if ( + not isinstance(disambiguation, list) or len(disambiguation) != 1 + or not isinstance(disambiguation[0], Call) or disambiguation[0].name != "OSD" + or len(disambiguation[0].args) != 1 or not isinstance(disambiguation[0].args[0], list) + ): + return None + refs = _source_refs(disambiguation[0]) + shell_id = f"f_{shell_owner}" + shell = feature_by_id.get(shell_id) or {} + depends_on = shell.get("depends_on") or [] + if previous[-1:] != [shell_id] or len(depends_on) != 1: + return None + producer_id = depends_on[0] + producer = feature_by_id.get(producer_id) or {} + producer_owner = producer_id.removeprefix("f_") + frame = feature_frames.get(producer_owner) or {} + profile_source = frame.get("profile_source") + profile_sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) + source_sketch = sketch_by_source.get(profile_source) if isinstance(profile_source, str) else None + params = producer.get("params") or {} + source_ids = _direct_profile_source_entity_ids(profile_sketch or {}) + if ( + producer.get("atomic_id") != "extrude_add_blind" + or params.get("result_mode") != "new_body" + or (params.get("end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None + or not isinstance(profile_source, str) + or source_sketch is None + or profile_sketch is None + or profile_sketch.get("source_sketch_id") != profile_source + or not _profile_matches_direct_source(profile_sketch, source_sketch) + or len(refs) != 2 + or {source for source, _token in refs} != {profile_source} + or _shared_source_endpoint(refs, profile_source, entity_by_sketch) is None + ): + return None + resolved_ids: list[str] = [] + for source, token in refs: + resolved = _source_ref_entity(source, token, entity_by_sketch) + if resolved is None: + return None + entity_id, entity = resolved + if entity.get("construction") or entity_id not in source_ids or entity_id in resolved_ids: + return None + resolved_ids.append(entity_id) + shell_selectors = shell.get("selectors") or [] + if ( + shell.get("atomic_id") != "shell" + or (shell.get("params") or {}).get("inward") is not True + or len(shell_selectors) != 1 + or not isinstance(shell_selectors[0], dict) + or shell_selectors[0].get("kind") != "face" + or shell_selectors[0].get("owner_feature_id") != producer_id + or shell_selectors[0].get("output_role") not in {"extrude.start", "extrude.end"} + ): + return None + source_entities = [ + {"sketch_id": profile_source, "entity_id": entity_id} + for entity_id in sorted(resolved_ids) + ] + intent = _selector_intent( + value, + query_family="OFFSET_EDGE", + kind="edge", + evidence="kernel_history", + allowed=("boundary", "continuation"), + disambiguation={ + "type": "offset_edge_vertex_continuation", + "shell_feature_id": shell_id, + "outer_owner_feature_id": shell_id, + "prism_owner_feature_id": producer_id, + "removed_cap_role": shell_selectors[0]["output_role"], + "source_entities": source_entities, + }, + ) + intent.pop("source_entity", None) + intent["source_entities"] = source_entities + intent["consumer_contract"] = "direct_prism_shell_offset_edge_vertex" + return { + "kind": "edge", + "owner_feature_id": producer_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": intent, + } + + +def _direct_blend_edge_selector( + value: Any, + *, + owner: str, + feature_by_id: dict[str, dict[str, 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]]], + previous: list[str], + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Lower one exact direct-prism dress-up ``BLEND_EDGE`` source tuple. + + The FeatureScript query names both the original edge and the original face + that the first dress-up blends into. Retain those source semantics rather + than collapsing the result to a nearby patch boundary edge. This accepts + only one source edge, one complete direct-prism CAP_FACE or same-anchor + SWEPT_FACE, and one immediate native fillet/chamfer producer; split, + merge/COPY and mixed query sets stay deferred for a later component + contract. + """ + try: + _call, outer_owner, topology, kind, definition = _direct_make_query(value) + except ValueError: + return None + if topology != "BLEND_EDGE" or kind not in {"edge", "entitytype.edge"} or outer_owner != owner: + return None + blended_from = definition.get("blendedFrom") + blended_into = definition.get("blendedInto") + if not isinstance(blended_from, list) or len(blended_from) != 2 or not isinstance(blended_into, list) or len(blended_into) != 1: + return None + edge_value, source_face_value = blended_from + target_face_value = blended_into[0] + try: + _edge_call, edge_owner, edge_topology, edge_kind, _edge_definition = _direct_make_query(edge_value) + _face_call, face_owner, face_topology, face_kind, _face_definition = _direct_make_query(source_face_value) + _target_call, target_owner, target_topology, target_kind, _target_definition = _direct_make_query(target_face_value) + except ValueError: + return None + face_family = face_topology if face_topology == target_topology else None + if ( + edge_topology != "CAP_EDGE" or edge_kind not in {"edge", "entitytype.edge"} + or face_family not in {"CAP_FACE", "SWEPT_FACE"} + or face_kind not in {"face", "entitytype.face"} + or target_kind not in {"face", "entitytype.face"} + or len({edge_owner, face_owner, target_owner}) != 1 + ): + return None + source_feature_id = f"f_{edge_owner}" + dressup_feature_id = f"f_{owner}" + dressup = feature_by_id.get(dressup_feature_id) or {} + if ( + previous[-1:] != [dressup_feature_id] + or dressup.get("atomic_id") not in {"fillet", "chamfer"} + or source_feature_id not in previous + ): + return None + edge_selector = _direct_prism_swept_selector( + edge_value, + owner=edge_owner, + selector_kind="edge", + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=featurescript_version, + allow_continuation=True, + ) + if face_family == "CAP_FACE": + source_face_selector = _cap_face_output_role_selector( + source_face_value, feature_by_id, sketches_by_id, + ) + target_face_selector = _cap_face_output_role_selector( + target_face_value, feature_by_id, sketches_by_id, + ) + else: + source_face_selector = _direct_prism_swept_selector( + source_face_value, + owner=face_owner, + selector_kind="face", + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=featurescript_version, + allow_continuation=True, + ) + target_face_selector = _direct_prism_swept_selector( + target_face_value, + owner=target_owner, + selector_kind="face", + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=featurescript_version, + allow_continuation=True, + ) + if edge_selector is None or source_face_selector is None or target_face_selector is None: + return None + edge_intent = edge_selector.get("selector_intent") or {} + source_face_intent = source_face_selector.get("selector_intent") or {} + target_face_intent = target_face_selector.get("selector_intent") or {} + if ( + edge_intent.get("query_family") != "CAP_EDGE" + or source_face_intent.get("query_family") != face_family + or target_face_intent.get("query_family") != face_family + or source_face_selector.get("owner_feature_id") != source_feature_id + or target_face_selector.get("owner_feature_id") != source_feature_id + ): + return None + if face_family == "CAP_FACE" and source_face_selector.get("output_role") != target_face_selector.get("output_role"): + return None + source_profile = sketches_by_id.get(str((feature_by_id.get(source_feature_id) or {}).get("sketch_id") or "")) or {} + source_profile_ids = _direct_profile_source_entity_ids(source_profile) + source_sketch_id = (feature_frames.get(edge_owner) or {}).get("profile_source") + if face_family == "CAP_FACE": + source_refs = _source_refs(source_face_value) + target_refs = _source_refs(target_face_value) + required_refs = {(source_sketch_id, entity_id) for entity_id in source_profile_ids} + if ( + not isinstance(source_sketch_id, str) + or not source_profile_ids + or set(source_refs) != required_refs + or set(target_refs) != required_refs + or len(source_refs) != len(source_profile_ids) + or len(target_refs) != len(source_profile_ids) + ): + return None + edge_source = edge_intent.get("source_entity") + edge_role = edge_intent.get("lineage_role") + face_source_entity = source_face_intent.get("source_entity") + target_face_entity = target_face_intent.get("source_entity") + face_role = source_face_selector.get("output_role") + if not isinstance(edge_source, dict) or edge_role not in {"extrude.start", "extrude.end"}: + return None + if face_family == "CAP_FACE": + if face_role not in {"extrude.start", "extrude.end"}: + return None + elif not isinstance(face_source_entity, dict) or face_source_entity != target_face_entity: + return None + intent = _selector_intent( + value, + query_family="BLEND_EDGE", + kind="edge", + evidence="kernel_history", + allowed=("boundary",), + ) + intent["blend_sources"] = { + "edge": { + "query_family": "CAP_EDGE", + "owner_feature_id": source_feature_id, + "source_entity": dict(edge_source), + "lineage_role": edge_role, + }, + "face": ( + { + "query_family": "CAP_FACE", + "owner_feature_id": source_feature_id, + "output_role": face_role, + } + if face_family == "CAP_FACE" else { + "query_family": "SWEPT_FACE", + "owner_feature_id": source_feature_id, + "source_entity": dict(face_source_entity), + } + ), + } + return { + "kind": "edge", + "owner_feature_id": dressup_feature_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": intent, + } + + +def _direct_prism_blend_face_attachment( + value: Any, + *, + feature_by_id: dict[str, dict[str, 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]]], + previous: list[str], + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Attach one immediate direct-prism ``BLEND_FACE`` sketch at runtime. + + ``BLEND_FACE`` is the patch generated by a dress-up input edge, rather + than an arbitrary face adjacent to that edge. Accept it only when the + preceding native dress-up already selected one role-qualified direct-prism + cap edge. The runtime then requires that exact ``Generated(edge -> + patch_face)`` relation in the active snapshot before it materializes the + sketch frame. + """ + if featurescript_version != "1511": + return None + try: + _call, dressup_owner, topology, kind, definition = _direct_make_query(value) + except ValueError: + return None + refs = _source_refs(definition.get("disambiguationData")) + dressup_id = f"f_{dressup_owner}" + dressup = feature_by_id.get(dressup_id) or {} + if ( + topology != "BLEND_FACE" + or kind not in {"face", "entitytype.face"} + or previous[-1:] != [dressup_id] + or dressup.get("atomic_id") not in {"fillet", "chamfer"} + or len(dressup.get("depends_on") or ()) != 1 + or len(refs) != 1 + ): + return None + source_sketch_id, source_token = refs[0] + resolved_source = _source_ref_entity(source_sketch_id, source_token, entity_by_sketch) + if resolved_source is None or resolved_source[0] != source_token: + return None + source_entity_id, source_entity = resolved_source + producer_id = (dressup.get("depends_on") or [None])[0] + producer = feature_by_id.get(str(producer_id)) or {} + params = producer.get("params") or {} + profile = sketches_by_id.get(str(producer.get("sketch_id") or "")) + source_sketch = sketch_by_source.get(source_sketch_id) + if ( + not isinstance(producer_id, str) + or producer.get("atomic_id") != "extrude_add_blind" + or params.get("result_mode") != "new_body" + or (params.get("end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None + or profile is None + or source_sketch is None + or profile.get("source_sketch_id") != source_sketch_id + or not _profile_matches_direct_source(profile, source_sketch) + or source_entity.get("construction") + or source_entity_id not in _direct_profile_source_entity_ids(profile) + ): + return None + + def leaves(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + output: list[dict[str, Any]] = [] + for selector in items: + nested = selector.get("query_operands") + if isinstance(nested, list): + output.extend(leaves([item for item in nested if isinstance(item, dict)])) + else: + output.append(selector) + return output + + matches = [] + for selector in leaves([item for item in dressup.get("selectors") or () if isinstance(item, dict)]): + intent = selector.get("selector_intent") or {} + if ( + selector.get("kind") == "edge" + and selector.get("owner_feature_id") == producer_id + and selector.get("source") == "runtime_snapshot" + and intent.get("query_family") == "CAP_EDGE" + and intent.get("source_entity") == { + "sketch_id": source_sketch_id, + "entity_id": source_entity_id, + } + and intent.get("lineage_role") in {"extrude.start", "extrude.end"} + ): + matches.append(selector) + if len(matches) != 1: + return None + cap_intent = matches[0]["selector_intent"] + intent = _selector_intent( + value, + query_family="BLEND_FACE", + kind="face", + evidence="kernel_history", + allowed=("boundary",), + ) + intent["blend_face_source"] = { + "query_family": "CAP_EDGE", + "owner_feature_id": producer_id, + "source_entity": dict(cap_intent["source_entity"]), + "lineage_role": cap_intent["lineage_role"], + } + return { + "kind": "face", + "owner_feature_id": dressup_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": intent, + } + + +def _direct_full_revolve_swept_edge_selector( + value: Any, + *, + owner: str, + selector_kind: str, + feature_by_id: dict[str, dict[str, 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]]], + previous: list[str], + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Lower one full-revolve source vertex to exact ``MakeRevol`` history. + + This is intentionally separate from the direct-prism contract. A full + independent solid revolve has one usable native witness only: + ``BRepPrimAPI_MakeRevol.Generated(source_vertex)``. The profile vertex is + identified by the complete incident set in the FeatureScript OSD query; + circle centre/radius remains diagnostic geometry and never binds the edge. + """ + query = parse_query(value) + if query.topology_type != "SWEPT_EDGE" or selector_kind != "edge": + return None + # The full-revolve contract is separately registered for each source + # tuple. Do not infer other revisions from a merely similar makeQuery AST. + if featurescript_version not in {"1511", "2491"}: + return None + producer_id = f"f_{owner}" + producer = feature_by_id.get(producer_id) or {} + frame = feature_frames.get(owner) or {} + profile_source = frame.get("profile_source") + profile_sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) + source_sketch = sketch_by_source.get(profile_source) if isinstance(profile_source, str) else None + if ( + producer.get("atomic_id") != "revolve_add" + or (producer.get("params") or {}).get("result_mode") != "new_body" + or not frame.get("revolve_full") + or not isinstance(frame.get("revolve_axis"), dict) + or producer_id not in previous + or not isinstance(profile_source, str) + or source_sketch is None + or profile_sketch is None + or profile_sketch.get("source_sketch_id") != profile_source + or not _profile_matches_direct_source(profile_sketch, source_sketch) + or frame.get("revolve_profile_contract") not in { + "original_source", + "verified_complete_materialization", + } + # The 1511 contract does not let an IMPRINT profile borrow the source + # sketch's vertex identities merely because lowering reuses equal + # profile data. 2491 separately admits its verified complete form. + or ( + featurescript_version == "1511" + and frame.get("revolve_profile_contract") != "original_source" + ) + ): + return None + refs = _source_refs(value) + if len(refs) < 2 or {source for source, _token in refs} != {profile_source}: + return None + source_ids = _direct_profile_source_entity_ids(profile_sketch) + resolved_ids: list[str] = [] + for source, token in refs: + resolved = _source_ref_entity(source, token, entity_by_sketch) + if resolved is None: + return None + entity_id, entity = resolved + if entity.get("construction") or entity_id not in source_ids: + return None + if entity_id not in resolved_ids: + resolved_ids.append(entity_id) + if len(resolved_ids) < 2 or _shared_source_endpoint(refs, profile_source, entity_by_sketch) is None: + return None + intent = _selector_intent( + value, + query_family="SWEPT_EDGE", + kind="edge", + evidence="kernel_history", + # Earlier dress-ups may preserve this edge through exact one-to-one + # OCC history. The resolver rejects any split, merge or missing branch. + allowed=("boundary", "continuation"), + ) + intent.pop("source_entity", None) + intent["source_entities"] = [ + {"sketch_id": profile_source, "entity_id": entity_id} + for entity_id in sorted(resolved_ids) + ] + return { + "kind": "edge", + "owner_feature_id": producer_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": intent, + } + + +def _direct_primary_cut_copy_cap_edge_selector( + value: Any, + *, + feature_by_id: dict[str, dict[str, 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]]], + previous: list[str], + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Lower one primary-cut ``COPY(CAP_EDGE)`` to its proven input lineage. + + FeatureScript's boolean COPY query is not permission to select a similar + final edge. This narrow form keeps its outer COPY AST and delegates only + its explicit CAP_EDGE source to the transient-prism and cut-builder chain + already registered in the same replay. The tool is never active topology. + """ + if featurescript_version != "1511": + return None + try: + _outer_call, owner, topology, kind, definition = _direct_make_query(value) + except ValueError: + return None + derived = definition.get("derivedFrom") if isinstance(definition, dict) else None + if topology != "COPY" or kind not in {"edge", "entitytype.edge"} or derived is None: + return None + try: + _inner_call, inner_owner, inner_topology, inner_kind, _inner_definition = _direct_make_query(derived) + except ValueError: + return None + producer_id = f"f_{owner}" + producer = feature_by_id.get(producer_id) or {} + params = producer.get("params") or {} + frame = feature_frames.get(owner) or {} + profile_source = frame.get("profile_source") + profile_sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) + source_sketch = sketch_by_source.get(profile_source) if isinstance(profile_source, str) else None + inner_query = parse_query(derived) + refs = _source_refs(derived) + if ( + inner_owner != owner + or inner_topology != "CAP_EDGE" + or inner_kind not in {"edge", "entitytype.edge"} + or inner_query.is_start is None + or previous[-1:] != [producer_id] + or producer.get("atomic_id") != "extrude_cut_blind" + or params.get("result_mode") is not None + or (params.get("end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None + or not isinstance(profile_source, str) + or source_sketch is None + or profile_sketch is None + or profile_sketch.get("source_sketch_id") != profile_source + or not _profile_matches_direct_source(profile_sketch, source_sketch) + or len(refs) != 1 + or refs[0][0] != profile_source + or inner_query.source_sketch != profile_source + or inner_query.source_entity != refs[0][1] + or refs[0][1] not in _direct_profile_source_entity_ids(profile_sketch) + ): + return None + entity = (entity_by_sketch.get(profile_source) or {}).get(refs[0][1]) + if not entity or entity.get("construction"): + return None + input_intent = _selector_intent( + derived, + query_family="CAP_EDGE", + kind="edge", + evidence="kernel_history", + allowed=("boundary", "continuation"), + ) + input_intent["lineage_role"] = f"extrude.{'start' if inner_query.is_start else 'end'}" + intent = _selector_intent( + value, + query_family="COPY", + kind="edge", + evidence="kernel_history", + allowed=("boundary", "continuation"), + ) + # The anchor belongs to the nested CAP_EDGE, not the outer boolean COPY. + intent.pop("source_entity", None) + intent["copy_contract"] = "primary_cut_cap_edge" + return { + "kind": "edge", + "owner_feature_id": producer_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "query_input": { + "kind": "edge", + "owner_feature_id": producer_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": input_intent, + }, + "selector_intent": intent, + } + + +def _direct_primary_cut_copy_cap_face_attachment( + value: Any, + *, + feature_by_id: dict[str, dict[str, 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]]], + previous: list[str], + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Lower an immediate primary-cut ``COPY(CAP_FACE)`` sketch attachment. + + The emitted selector names no face by geometry or static cap frame. It + instead requires the runtime to prove the direct tool cap and its exact + same-owner subtract successor before materializing the sketch workplane. + """ + if featurescript_version != "1511": + return None + try: + _outer, owner, topology, kind, definition = _direct_make_query(value) + except ValueError: + return None + derived = definition.get("derivedFrom") if isinstance(definition, dict) else None + if topology != "COPY" or kind not in {"face", "entitytype.face"} or derived is None: + return None + try: + _inner, inner_owner, inner_topology, inner_kind, _inner_definition = _direct_make_query(derived) + except ValueError: + return None + producer_id = f"f_{owner}" + producer = feature_by_id.get(producer_id) or {} + params = producer.get("params") or {} + frame = feature_frames.get(owner) or {} + profile_source = frame.get("profile_source") + profile_sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) + source_sketch = sketch_by_source.get(profile_source) if isinstance(profile_source, str) else None + inner_query = parse_query(derived) + refs = _source_refs(derived) + source_ids = _direct_profile_source_entity_ids(profile_sketch or {}) + if ( + inner_owner != owner + or inner_topology != "CAP_FACE" + or inner_kind not in {"face", "entitytype.face"} + or inner_query.is_start is None + or previous[-1:] != [producer_id] + or producer.get("atomic_id") != "extrude_cut_blind" + or params.get("result_mode") is not None + or (params.get("end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None + or not isinstance(profile_source, str) + or source_sketch is None + or profile_sketch is None + or profile_sketch.get("source_sketch_id") != profile_source + or not _profile_matches_direct_source(profile_sketch, source_sketch) + or not source_ids + or len(refs) != len(source_ids) + or {source for source, _token in refs} != {profile_source} + or {token for _source, token in refs} != source_ids + ): + return None + for entity_id in source_ids: + entity = (entity_by_sketch.get(profile_source) or {}).get(entity_id) + if not entity or entity.get("construction"): + return None + role = f"extrude.{'start' if inner_query.is_start else 'end'}" + input_intent = _selector_intent( + derived, query_family="CAP_FACE", kind="face", evidence="kernel_history", + allowed=("boundary", "continuation"), + ) + input_intent.pop("source_entity", None) + input_intent["source_entities"] = [ + {"sketch_id": profile_source, "entity_id": entity_id} for entity_id in sorted(source_ids) + ] + input_intent["lineage_role"] = role + intent = _selector_intent( + value, query_family="COPY", kind="face", evidence="kernel_history", + allowed=("boundary", "continuation"), + ) + intent.pop("source_entity", None) + intent["copy_contract"] = "primary_cut_cap_face_workplane" + return { + "kind": "face", "owner_feature_id": producer_id, + "source": "runtime_snapshot", "confidence": 1.0, + "query_input": { + "kind": "face", "owner_feature_id": producer_id, + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": input_intent, + }, + "selector_intent": intent, + } + + +def _direct_prism_cap_face_attachment( + value: Any, + *, + feature_by_id: dict[str, dict[str, Any]], + sketches_by_id: dict[str, dict[str, Any]], + previous: list[str], + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Attach a following sketch to one exact direct-prism cap at runtime. + + The direct CAP role is a builder result, not a copied static workplane. + This bridge intentionally applies only while the independent blind prism + remains the immediately preceding active body. It does not interpret + CAP_EDGE, boolean successors, or an attached profile's future splitter + boundary; those need their own source-qualified contracts. + """ + if featurescript_version != "1511": + return None + try: + _call, _owner, topology, kind, _definition = _direct_make_query(value) + except ValueError: + return None + if topology != "CAP_FACE" or kind not in {"face", "entitytype.face"}: + return None + selector = _cap_face_output_role_selector(value, feature_by_id, sketches_by_id) + if selector is None or previous[-1:] != [selector.get("owner_feature_id")]: + return None + intent = selector.get("selector_intent") or {} + if ( + selector.get("kind") != "face" + or selector.get("output_role") not in {"extrude.start", "extrude.end"} + or intent.get("query_family") != "CAP_FACE" + or intent.get("evidence") != "operation_role" + or intent.get("consumer_contract") is not None + ): + return None + intent["consumer_contract"] = "direct_prism_cap_face_workplane" + return selector + + +def _direct_primary_cut_copy_swept_face_attachment( + value: Any, + *, + feature_by_id: dict[str, dict[str, 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]]], + previous: list[str], + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Lower one direct primary-cut ``COPY(SWEPT_FACE)`` sketch attachment. + + The source edge is only an anchor for the cut tool's native prism history. + The eventual workplane is materialized exclusively from the exact active + face reached through that tool face's same-owner subtract continuation. + """ + if featurescript_version != "1511": + return None + try: + _outer, owner, topology, kind, definition = _direct_make_query(value) + except ValueError: + return None + derived = definition.get("derivedFrom") if isinstance(definition, dict) else None + if topology != "COPY" or kind not in {"face", "entitytype.face"} or derived is None: + return None + try: + _inner, inner_owner, inner_topology, inner_kind, _inner_definition = _direct_make_query(derived) + except ValueError: + return None + producer_id = f"f_{owner}" + producer = feature_by_id.get(producer_id) or {} + params = producer.get("params") or {} + frame = feature_frames.get(owner) or {} + profile_source = frame.get("profile_source") + profile_sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) + source_sketch = sketch_by_source.get(profile_source) if isinstance(profile_source, str) else None + refs = _source_refs(derived) + if ( + inner_owner != owner + or inner_topology != "SWEPT_FACE" + or inner_kind not in {"face", "entitytype.face"} + or previous[-1:] != [producer_id] + or producer.get("atomic_id") != "extrude_cut_blind" + or params.get("result_mode") is not None + or (params.get("end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None + or not isinstance(profile_source, str) + or source_sketch is None + or profile_sketch is None + or profile_sketch.get("source_sketch_id") != profile_source + or not _profile_matches_direct_source(profile_sketch, source_sketch) + or len(refs) != 1 + or refs[0][0] != profile_source + or refs[0][1] not in _direct_profile_source_entity_ids(profile_sketch) + ): + return None + entity = (entity_by_sketch.get(profile_source) or {}).get(refs[0][1]) + if not entity or entity.get("construction"): + return None + input_intent = _selector_intent( + derived, query_family="SWEPT_FACE", kind="face", evidence="kernel_history", + allowed=("boundary", "continuation"), + ) + input_intent["source_entity"] = {"sketch_id": refs[0][0], "entity_id": refs[0][1]} + intent = _selector_intent( + value, query_family="COPY", kind="face", evidence="kernel_history", + allowed=("boundary", "continuation"), + ) + intent.pop("source_entity", None) + intent["copy_contract"] = "primary_cut_swept_face_workplane" + return { + "kind": "face", "owner_feature_id": producer_id, + "source": "runtime_snapshot", "confidence": 1.0, + "query_input": { + "kind": "face", "owner_feature_id": producer_id, + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": input_intent, + }, + "selector_intent": intent, + } + + +def _planar_imprint_prism_selector( + value: Any, + *, + owner: str, + selector_kind: str, + feature_by_id: dict[str, dict[str, Any]], + feature_frames: dict[str, dict[str, Any]], + sketches_by_id: dict[str, dict[str, Any]], + previous: list[str], + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Lower a bounded IMPRINT prism query only when its source set is exact. + + An IMPRINT profile may split one FeatureScript curve into several selected + B-rep fragments. The adapter records each exact fragment and the resolver + must return the complete set. This helper therefore does not reuse the + direct-profile one-to-one contract or turn a set-valued query into an + arbitrary geometric candidate. + """ + query = parse_query(value) + topology = query.topology_type + if topology not in {"CAP_FACE", "SWEPT_FACE", "SWEPT_EDGE"} or featurescript_version != "1511": + return None + producer_id = f"f_{owner}" + producer = feature_by_id.get(producer_id) or {} + params = producer.get("params") or {} + frame = feature_frames.get(owner) or {} + profile_source = frame.get("profile_source") + profile_sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) + profile = (profile_sketch or {}).get("profile") or {} + if ( + producer.get("atomic_id") not in {"extrude_add_blind", "extrude_cut_blind"} + or previous[-1:] != [producer_id] + or params.get("result_mode") != "new_body" + or (params.get("end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None + or not isinstance(profile_source, str) + or profile_sketch is None + or profile_sketch.get("source_sketch_id") != profile_source + or profile.get("type") != "planar_imprint" + ): + return None + source_ids = { + str(entry.get("id")) + for entry in profile.get("source_entities") or () + if isinstance(entry, dict) and isinstance(entry.get("id"), str) and entry.get("id") + } + refs = list(dict.fromkeys(_source_refs(value))) + if not source_ids or not refs or {sketch_id for sketch_id, _entity_id in refs} != {profile_source}: + return None + ref_ids = {entity_id for _sketch_id, entity_id in refs} + if not ref_ids.issubset(source_ids): + return None + + if topology == "CAP_FACE": + if selector_kind != "face" or query.is_start is None or ref_ids != source_ids: + return None + intent = _selector_intent( + value, + query_family="CAP_FACE", + kind="face", + evidence="kernel_history", + allowed=("boundary", "fragment"), + multiplicity="all_fragments", + ) + # The complete OSD source set proves the output role. Its last parsed + # source token is diagnostic context, not a single-edge CAP anchor. + intent.pop("source_entity", None) + output_role = f"extrude.{'start' if query.is_start else 'end'}" + intent["output_role"] = output_role + intent["disambiguation"] = { + "type": "complete_imprint_profile_source_set", + "source_entity_ids": sorted(source_ids), + } + return { + "kind": "face", + "owner_feature_id": producer_id, + "output_role": output_role, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": intent, + } + + if topology == "SWEPT_FACE": + if selector_kind != "face" or len(refs) != 1: + return None + _source_sketch, source_entity = refs[0] + intent = _selector_intent( + value, + query_family="SWEPT_FACE", + kind="face", + evidence="kernel_history", + allowed=("boundary", "fragment"), + multiplicity="all_fragments", + ) + intent["source_entity"] = {"sketch_id": profile_source, "entity_id": source_entity} + return { + "kind": "face", + "owner_feature_id": producer_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": intent, + } + + if selector_kind != "edge" or len(refs) != 2 or len(ref_ids) != 2: + return None + intent = _selector_intent( + value, + query_family="SWEPT_EDGE", + kind="edge", + evidence="kernel_history", + allowed=("boundary",), + ) + intent.pop("source_entity", None) + intent["source_entities"] = [ + {"sketch_id": profile_source, "entity_id": entity_id} + for entity_id in sorted(ref_ids) + ] + return { + "kind": "edge", + "owner_feature_id": producer_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": intent, + } + + def _intersection_source_face_descriptor( value: Any, *, @@ -1864,6 +3731,100 @@ def _source_sketch(params: dict[str, Any]) -> str | None: return None +def _same_profile_frame(left: dict[str, Any], right: dict[str, Any]) -> bool: + """Require identical source frames, without inventing a coordinate map.""" + for key in ("origin_mm", "x_dir", "normal"): + first, second = left.get(key), right.get(key) + if ( + not isinstance(first, list) + or not isinstance(second, list) + or len(first) != 3 + or len(second) != 3 + or any(not isinstance(value, (int, float)) or not math.isfinite(float(value)) for value in [*first, *second]) + or any(float(a) != float(b) for a, b in zip(first, second)) + ): + return False + return True + + +def _direct_sketch_region_union_leaves(value: Any) -> list[Call] | None: + """Flatten only associative qUnion wrappers around direct region leaves.""" + if not isinstance(value, Call) or value.name != "qUnion" or len(value.args) != 1 or not isinstance(value.args[0], list): + return None + leaves: list[Call] = [] + for operand in value.args[0]: + if isinstance(operand, Call) and operand.name == "qUnion": + nested = _direct_sketch_region_union_leaves(operand) + if nested is None: + return None + leaves.extend(nested) + elif isinstance(operand, Call) and operand.name == "qSketchRegion" and len(operand.args) == 2: + leaves.append(operand) + else: + return None + return leaves + + +def _multi_source_sketch_region_profile( + value: Any, + sketch_by_source: dict[str, dict[str, Any]], + feature_id: str, +) -> dict[str, Any] | None: + """Materialize a direct same-frame ``qUnion(qSketchRegion(...))`` profile. + + This is profile geometry, not a topology selector: each source region is + resolved independently by the runtime so a contour nested in a different + sketch cannot accidentally become a hole in another sketch's region. + """ + leaves = _direct_sketch_region_union_leaves(value) + if leaves is None or len(leaves) < 2: + return None + sources: list[str] = [] + for leaf in leaves: + if str(leaf.args[1]).lower() != "true": + return None + source = symbolic_string(leaf.args[0]).split(".", 1)[0] + if not source: + return None + sources.append(source) + # qUnion is a set union. Repeating the whole source region cannot create + # another profile region, so preserve first-occurrence source ordering. + sources = list(dict.fromkeys(sources)) + if len(sources) < 2 or any(source not in sketch_by_source for source in sources): + return None + sketches = [sketch_by_source[source] for source in sources] + if any(sketch.get("attachment") is not None or not _profile_executable(sketch) for sketch in sketches): + return None + frame = sketches[0].get("workplane") + if not isinstance(frame, dict) or any(not _same_profile_frame(frame, sketch.get("workplane") or {}) for sketch in sketches[1:]): + return None + profiles = [deepcopy(sketch["profile"]) for sketch in sketches] + if any(profile.get("type") not in {"circle", "polygon", "analytic_contours"} for profile in profiles): + return None + return { + "id": f"sketch_regions_{feature_id}", + "name": f"regions_{feature_id}", + "workplane": deepcopy(frame), + "profile": { + "type": "multi_source_regions", + "source_sketch_ids": sources, + "profiles": profiles, + }, + } + + +def _has_composed_sketch_region_union(value: Any) -> bool: + """Identify composed region input that must not fall back to one source.""" + leaves = _direct_sketch_region_union_leaves(value) + if leaves is None: + return False + sources = { + symbolic_string(leaf.args[0]).split(".", 1)[0] + for leaf in leaves + } + return len(leaves) >= 2 and len(sources - {""}) >= 2 + + def _sketch_region_query(value: Any) -> Any | None: """Return one explicit qSketchRegion when a profile query also carries context faces.""" matches = [ @@ -2128,13 +4089,34 @@ def _planar_imprint_selection(value: Any) -> tuple[str, dict[str, Any]] | None: 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): + intersection_derived = intersections[0].get("derivedFrom") + intersection_sources = list(dict.fromkeys(_source_refs(intersection_derived))) + if fragment_side is None or len(intersection_sources) != 2: 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} + same_sketch_anchor = [ + entity for sketch, entity in intersection_sources + if sketch == owner and entity != source_entity + ] + fragment: dict[str, Any] + if len(same_sketch_anchor) == 1 and all(sketch == owner for sketch, _entity in intersection_sources): + fragment = {"anchor_entity_id": same_sketch_anchor[0], "side": fragment_side} + else: + external_queries: list[Call] = [] + for call in walk_calls(intersection_derived): + if call.name != "makeQuery": + continue + try: + _edge, _edge_owner, edge_topology, edge_kind, _edge_definition = _direct_make_query(call) + except ValueError: + continue + if edge_topology == "CAP_EDGE" and edge_kind in {"edge", "entitytype.edge"}: + external_queries.append(call) + local_sources = [(sketch, entity) for sketch, entity in intersection_sources if sketch == owner] + if len(local_sources) != 1 or local_sources[0][1] != source_entity or len(external_queries) != 1: + return None + # Kept only while lowering this profile. The CDSL profile receives a + # typed selector through the external-anchor contract below. + fragment = {"_external_anchor_query": external_queries[0], "side": fragment_side} order = _definition_order(intersections[0]) if order is not None: fragment["intersection_index"] = order @@ -2147,6 +4129,12 @@ def _planar_imprint_profile_sketch( sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], feature_id: str, + *, + feature_by_id: dict[str, dict[str, Any]], + feature_frames: dict[str, dict[str, Any]], + sketches_by_id: dict[str, dict[str, Any]], + previous: list[str], + featurescript_version: str | None, ) -> dict[str, Any] | None: """Create an exact planar-arrangement profile from IMPRINT face queries. @@ -2156,6 +4144,7 @@ def _planar_imprint_profile_sketch( of a potentially unrelated source contour. """ selections: list[dict[str, Any]] = [] + external_anchors: list[dict[str, Any]] = [] source_sketch: str | None = None for root in _queries(value): parsed = _planar_imprint_selection(root) @@ -2166,6 +4155,43 @@ def _planar_imprint_profile_sketch( source_sketch = owner elif source_sketch != owner: return None + fragment = selection.get("fragment") or {} + external_query = fragment.pop("_external_anchor_query", None) + if external_query is not None: + try: + _call, external_owner, external_topology, external_kind, _definition = _direct_make_query(external_query) + except ValueError: + return None + if external_topology != "CAP_EDGE" or external_kind not in {"edge", "entitytype.edge"}: + return None + external_selector = _direct_prism_swept_selector( + external_query, + owner=external_owner, + selector_kind="edge", + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=featurescript_version, + allow_continuation=False, + ) + attachment = (sketch_by_source.get(owner) or {}).get("attachment") + attachment_intent = attachment.get("selector_intent") if isinstance(attachment, dict) else None + external_intent = external_selector.get("selector_intent") if isinstance(external_selector, dict) else None + if ( + external_selector is None + or not isinstance(attachment, dict) + or attachment.get("owner_feature_id") != external_selector.get("owner_feature_id") + or attachment.get("output_role") != (external_intent or {}).get("lineage_role") + or not isinstance(attachment_intent, dict) + or attachment_intent.get("consumer_contract") != "direct_prism_cap_face_workplane" + ): + return None + anchor_id = f"cap_boundary_{len(external_anchors)}" + fragment["external_anchor_id"] = anchor_id + external_anchors.append({"id": anchor_id, "selector": external_selector}) selections.append(selection) if source_sketch is None or not selections or source_sketch not in sketch_by_source: return None @@ -2178,9 +4204,29 @@ def _planar_imprint_profile_sketch( 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): + if selection["source_entity_id"] not in known or (fragment.get("anchor_entity_id") and fragment.get("anchor_entity_id") not in known): return None - if len(source_entities) < 2: + # A same-sketch fragment needs a second source curve as its splitter + # anchor. The current source admission for an attached boundary is only + # the one-circle / one-edge form: a periodic B-spline has seam ambiguity, + # and multi-curve or multi-anchor arrangements need a set-cardinality + # contract rather than reusing this singleton policy. + single_external_circle_fragment = ( + len(source_entities) == 1 + and source_entities[0].get("curve", {}).get("type") == "circle" + and bool(external_anchors) + and len(external_anchors) == 1 + and len(external_anchors) == len(selections) + and all( + isinstance(selection.get("fragment"), dict) + and isinstance(selection["fragment"].get("external_anchor_id"), str) + and selection["fragment"]["external_anchor_id"] + for selection in selections + ) + ) + if external_anchors and not single_external_circle_fragment: + return None + if len(source_entities) < 2 and not single_external_circle_fragment: return None output = deepcopy(sketch_by_source[source_sketch]) output["id"] = f"{output['id']}__{feature_id}" @@ -2190,6 +4236,8 @@ def _planar_imprint_profile_sketch( "source_entities": source_entities, "selections": selections, } + if external_anchors: + output["profile"]["external_anchors"] = external_anchors return output @@ -2292,12 +4340,43 @@ def _circle_imprint_union_profile( if len(merged) != 1: return None inner, outer = merged[0] + outer_sources = { + segment.get("source_entity_id") + for contour in contours + for segment in contour.get("segments") or [] + if ( + segment.get("type") == "circle" + and _same_point(segment.get("center") or [], center) + and abs(float(segment.get("radius_mm") or 0.0) - outer) <= 1e-5 + and isinstance(segment.get("source_entity_id"), str) + and segment["source_entity_id"] + ) + } + # The union's outer boundary is still one original source circle when the + # source sketch names it uniquely. Preserve that exact source identity so + # a later direct-prism builder can expose its native swept-face history. + # An annulus or a duplicate boundary intentionally carries no inferred + # label: a source query then remains unresolved rather than borrowing a + # geometrically equal circle. + outer_source = next(iter(outer_sources)) if len(outer_sources) == 1 else None if inner <= 1e-5: - return {"type": "circle", "center": list(center), "radius_mm": outer} + return { + "type": "circle", + "center": list(center), + "radius_mm": outer, + **({"source_entity_id": outer_source} if outer_source is not None else {}), + } return { "type": "analytic_contours", "contours": [ - {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": list(center), "radius_mm": outer}]}, + { + "role": "outer", + "closed": True, + "segments": [{ + "type": "circle", "center": list(center), "radius_mm": outer, + **({"source_entity_id": outer_source} if outer_source is not None else {}), + }], + }, {"role": "inner", "closed": True, "segments": [{"type": "circle", "center": list(center), "radius_mm": inner}]}, ], } @@ -2428,6 +4507,14 @@ def _cap_face_output_role_selector( value: Any, feature_by_id: dict[str, dict[str, Any]], sketches_by_id: dict[str, dict[str, Any]], + *, + allow_initial_loft: bool = False, + allow_two_sided_circle_shell: bool = False, + allow_primary_add_shell: bool = False, + allow_primary_add_up_to_surface: bool = False, + allow_primary_add_dressup: bool = False, + allow_two_sided_up_to_surface_pair: bool = False, + source_sketches: dict[str, dict[str, Any]] | None = None, ) -> dict[str, Any] | None: """Reference one direct-builder cap face without reconstructing its sketch. @@ -2450,6 +4537,141 @@ def _cap_face_output_role_selector( params = (producer or {}).get("params") or {} producer_sketch = sketches_by_id.get(str((producer or {}).get("sketch_id") or "")) + # Both far caps of a symmetric direct prism are exact builder outputs, but + # neither is independently valid as a one-sided extent target. The caller + # admits them only as the complete opposite-role pair below. + if allow_two_sided_up_to_surface_pair: + profile_source = (producer_sketch or {}).get("source_sketch_id") + source_sketch = (source_sketches or {}).get(str(profile_source or "")) + source_ids = _direct_profile_source_entity_ids(producer_sketch or {}) + refs = list(dict.fromkeys(_source_refs(value))) + ref_ids = {entity_id for _sketch_id, entity_id in refs} + if ( + producer is not None + and producer.get("atomic_id") == "extrude_add_two_sided" + and params.get("result_mode") == "new_body" + and (params.get("end_condition") or {}).get("type") == "blind" + and (params.get("reverse_end_condition") or {}).get("type") == "blind" + and params.get("draft") is None + and isinstance(profile_source, str) + and source_sketch is not None + and _profile_matches_direct_source(producer_sketch or {}, source_sketch) + and source_ids + and refs + and {sketch_id for sketch_id, _entity_id in refs} == {profile_source} + and ref_ids == source_ids + and query.source_sketch == profile_source + and query.source_entity in source_ids + ): + role = f"extrude.{'start' if query.is_start else 'end'}" + intent = _selector_intent( + value, + query_family="CAP_FACE", + kind="face", + evidence="operation_role", + allowed=("boundary",), + output_role=role, + disambiguation={"source_profile_entity_ids": sorted(source_ids)}, + ) + intent["consumer_contract"] = "symmetric_direct_prism_two_sided_up_to_surface_cap_pair" + return { + "kind": "face", + "owner_feature_id": owner_feature_id, + "output_role": role, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": intent, + } + + # The two builders of a symmetric prism share the source plane, so only + # their far First/LastShape handles identify the physical CAP faces. The + # executor records those final-snapshot roles exactly. Keep this bridge + # confined to a shell removing either cap of one direct circle: unlike a + # general CAP_FACE it has an explicit one-edge source anchor and no + # cross-feature continuation or extent semantics. + if allow_two_sided_circle_shell: + profile = (producer_sketch or {}).get("profile") or {} + source_sketch_id = (producer_sketch or {}).get("source_sketch_id") + source_entity_id = profile.get("source_entity_id") + refs = _source_refs(value) + if ( + producer is not None + and producer.get("atomic_id") == "extrude_add_two_sided" + and params.get("result_mode") == "new_body" + and (params.get("end_condition") or {}).get("type") == "blind" + and (params.get("reverse_end_condition") or {}).get("type") == "blind" + and params.get("draft") is None + and profile.get("type") == "circle" + and isinstance(source_sketch_id, str) + and isinstance(source_entity_id, str) + and refs == [(source_sketch_id, source_entity_id)] + and query.source_sketch == source_sketch_id + and query.source_entity == source_entity_id + ): + role = f"extrude.{'start' if query.is_start else 'end'}" + return { + "kind": "face", + "owner_feature_id": owner_feature_id, + "output_role": role, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": _selector_intent( + value, + query_family="CAP_FACE", + kind="face", + evidence="operation_role", + allowed=("boundary",), + output_role=role, + ), + } + + # A loft CAP is identified by its OSD profile provenance, not by + # ``isStart``. FeatureScript's CAP naming direction is independent of + # the ThruSections wire order. + if producer and producer.get("atomic_id") == "loft_add": + if not allow_initial_loft: + return None + profile_sources = params.get("cap_output_profile_sources") + if ( + params.get("initial_output_roles") is not True + or not isinstance(profile_sources, list) + or len(profile_sources) != 2 + or len(set(profile_sources)) != 2 + ): + return None + imprint_owners = set() + for call in walk_calls(_definition.get("disambiguationData")): + if call.name != "makeQuery": + continue + try: + _direct, direct_owner, direct_topology, direct_kind, _direct_definition = _direct_make_query(call) + except ValueError: + continue + if direct_topology == "IMPRINT" and direct_kind == "face": + imprint_owners.add(direct_owner) + if len(imprint_owners) != 1: + return None + source = next(iter(imprint_owners)) + if source not in profile_sources: + return None + role = ("loft.start", "loft.end")[profile_sources.index(source)] + return { + "kind": "face", + "owner_feature_id": owner_feature_id, + "output_role": role, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": _selector_intent( + value, + query_family="CAP_FACE", + kind="face", + evidence="operation_role", + allowed=("boundary",), + output_role=role, + disambiguation={"type": "loft_profile_source", "source_sketch_id": source}, + ), + } + 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 {} @@ -2467,10 +4689,26 @@ def _cap_face_output_role_selector( # 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. + # A primary ADD has no independently active tool body. It may expose this + # CAP role only to its immediate shell, one-sided up-to-surface, or + # dress-up consumer: + # execution registers the prism as transient and the resolver must prove + # the exact union successor in the active final member. Other CAP_FACE + # consumers keep the existing independent-new-body contract. + primary_add_consumer = ( + ( + allow_primary_add_shell + or allow_primary_add_up_to_surface + or allow_primary_add_dressup + ) + and producer is not None + and producer.get("atomic_id") == "extrude_add_blind" + and params.get("result_mode") != "new_body" + ) if ( producer is None or producer.get("atomic_id") != "extrude_add_blind" - or params.get("result_mode") != "new_body" + or (params.get("result_mode") != "new_body" and not primary_add_consumer) 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 @@ -2481,23 +4719,552 @@ def _cap_face_output_role_selector( role_prefix = { "extrude_add_blind": "extrude", }[str(producer["atomic_id"])] + selector_intent = _selector_intent( + value, + query_family="CAP_FACE", + kind="face", + evidence="operation_role", + allowed=("boundary", "continuation"), + output_role=f"{role_prefix}.{'start' if query.is_start else 'end'}", + ) + if primary_add_consumer: + selector_intent["consumer_contract"] = { + "shell": "primary_add_shell_union_continuation", + "up_to_surface": "primary_add_up_to_surface_union_continuation", + "dressup": "primary_add_dressup_union_continuation", + }[ + "up_to_surface" if allow_primary_add_up_to_surface + else "dressup" if allow_primary_add_dressup + else "shell" + ] 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, + "selector_intent": selector_intent, + } + + +def _two_sided_up_to_surface_cap_pair( + forward_value: Any, + reverse_value: Any, + *, + feature_by_id: dict[str, dict[str, Any]], + sketches_by_id: dict[str, dict[str, Any]], + source_sketches: dict[str, dict[str, Any]], + previous: list[str], + featurescript_version: str | None, +) -> tuple[dict[str, Any], dict[str, Any]] | None: + """Lower opposite direct CAP faces only as one symmetric-extent pair.""" + if featurescript_version != "1511": + return None + forward = _cap_face_output_role_selector( + forward_value, feature_by_id, sketches_by_id, + allow_two_sided_up_to_surface_pair=True, source_sketches=source_sketches, + ) + reverse = _cap_face_output_role_selector( + reverse_value, feature_by_id, sketches_by_id, + allow_two_sided_up_to_surface_pair=True, source_sketches=source_sketches, + ) + if ( + forward is None + or reverse is None + or forward["owner_feature_id"] != reverse["owner_feature_id"] + or previous[-1:] != [forward["owner_feature_id"]] + or {forward["output_role"], reverse["output_role"]} != {"extrude.start", "extrude.end"} + ): + return None + forward_ids = (forward.get("selector_intent") or {}).get("disambiguation", {}).get("source_profile_entity_ids") + reverse_ids = (reverse.get("selector_intent") or {}).get("disambiguation", {}).get("source_profile_entity_ids") + if not isinstance(forward_ids, list) or forward_ids != reverse_ids: + return None + return forward, reverse + + +def _two_sided_up_to_surface_shell_swept_face_pair( + forward_value: Any, + reverse_value: Any, + *, + feature_by_id: dict[str, dict[str, 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]]], + previous: list[str], + featurescript_version: str | None, +) -> tuple[dict[str, Any], dict[str, Any]] | None: + """Lower two retained prism side walls through one immediate shell. + + The source queries remain source-edge anchored ``SWEPT_FACE`` lineage; + this function only admits their paired extent consumer when the shell is + the exact current lifecycle successor. The topology registry must still + prove each source edge -> prism wall -> shell offset-face continuation at + runtime, so this does not select a face by geometry or body position. + """ + if featurescript_version != "1511" or len(previous) < 2: + return None + try: + _forward_call, forward_owner, forward_topology, forward_kind, _forward_definition = _direct_make_query(forward_value) + _reverse_call, reverse_owner, reverse_topology, reverse_kind, _reverse_definition = _direct_make_query(reverse_value) + except ValueError: + return None + if ( + forward_topology != "SWEPT_FACE" + or reverse_topology != "SWEPT_FACE" + or forward_kind not in {"face", "entitytype.face"} + or reverse_kind not in {"face", "entitytype.face"} + or forward_owner != reverse_owner + ): + return None + producer_id = f"f_{forward_owner}" + shell_id = previous[-1] + shell = feature_by_id.get(shell_id) or {} + if ( + previous[-2] != producer_id + or shell.get("atomic_id") != "shell" + or shell.get("depends_on") != [producer_id] + ): + return None + forward = _direct_prism_swept_selector( + forward_value, + owner=forward_owner, + selector_kind="face", + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=featurescript_version, + allow_continuation=True, + ) + reverse = _direct_prism_swept_selector( + reverse_value, + owner=reverse_owner, + selector_kind="face", + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=featurescript_version, + allow_continuation=True, + ) + if forward is None or reverse is None: + return None + forward_source = (forward.get("selector_intent") or {}).get("source_entity") or {} + reverse_source = (reverse.get("selector_intent") or {}).get("source_entity") or {} + if ( + forward_source == reverse_source + or not isinstance(forward_source.get("sketch_id"), str) + or not isinstance(forward_source.get("entity_id"), str) + or not isinstance(reverse_source.get("entity_id"), str) + ): + return None + shell_source_entities = { + (intent.get("source_entity") or {}).get("entity_id") + for selector in shell.get("selectors") or () + if isinstance(selector, dict) + for intent in [selector.get("selector_intent")] + if isinstance(intent, dict) + and intent.get("query_family") == "SWEPT_FACE" + and isinstance((intent.get("source_entity") or {}).get("entity_id"), str) + } + if { + forward_source["entity_id"], + reverse_source["entity_id"], + } & shell_source_entities: + # A shell may retain a planar closing descendant of a removed side. + # It is not the physical offset face requested by this bridge. + return None + source_sketch_id = forward_source["sketch_id"] + forward_entity = (entity_by_sketch.get(source_sketch_id) or {}).get(forward_source["entity_id"]) + reverse_entity = (entity_by_sketch.get(source_sketch_id) or {}).get(reverse_source["entity_id"]) + if ( + forward_entity is None + or reverse_entity is None + or forward_entity.get("type") != "line" + or reverse_entity.get("type") != "line" + or forward_entity.get("construction") + or reverse_entity.get("construction") + ): + return None + for selector in (forward, reverse): + selector["selector_intent"]["consumer_contract"] = "symmetric_direct_prism_shell_swept_face_up_to_surface_pair" + return forward, reverse + + +def _initial_direct_sweep_cap_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: + """Bind a direct PipeShell cap by profile and path-end source anchors. + + The builder's ``FirstShape``/``LastShape`` facts identify the result. A + CADFS CAP query additionally carries the profile edge and path endpoint; + require that exact pair so ``isStart`` cannot select a cap by position + alone. Only the narrow source contract emitted below for an initial, + direct, independent sweep is accepted. + """ + 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 + producer = feature_by_id.get(f"f_{owner}") + params = (producer or {}).get("params") or {} + contract = params.get("cap_output_contract") + if ( + producer is None + or producer.get("atomic_id") != "sweep_add" + or params.get("result_mode") != "new_body" + or params.get("initial_output_roles") is not True + or not isinstance(contract, dict) + ): + return None + required = ("profile_source", "profile_entity", "path_source", "path_entity", "path_reversed") + if ( + any(not isinstance(contract.get(name), str) or not contract[name] for name in required[:-1]) + or not isinstance(contract.get("path_reversed"), bool) + ): + return None + sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) or {} + if sketch.get("source_sketch_id") != contract["profile_source"]: + return None + refs = _source_refs(definition.get("disambiguationData")) + profile_ref = (contract["profile_source"], contract["profile_entity"]) + path_prefix = f"{contract['path_entity']}." + path_refs = [ref for ref in refs if ref[0] == contract["path_source"] and ref[1].startswith(path_prefix)] + if len(refs) != 2 or profile_ref not in refs or len(path_refs) != 1: + return None + suffix = path_refs[0][1][len(path_prefix):] + if suffix not in {"start", "end"}: + return None + expected_endpoint = "start" if query.is_start else "end" + # ``isStart`` is a source statement about the path endpoint. If lowering + # reversed the path to attach the profile, invert it before choosing the + # physical PipeShell output role. + if suffix != expected_endpoint: + return None + role_endpoint = suffix if not contract["path_reversed"] else ("end" if suffix == "start" else "start") + role = f"sweep.{role_endpoint}" + return { + "kind": "face", + "owner_feature_id": f"f_{owner}", + "output_role": role, + "source": "runtime_snapshot", + "confidence": 1.0, "selector_intent": _selector_intent( value, query_family="CAP_FACE", kind="face", evidence="operation_role", - allowed=("boundary", "continuation"), - output_role=f"{role_prefix}.{'start' if query.is_start else 'end'}", + allowed=("boundary",), + output_role=role, + disambiguation={ + "type": "sweep_profile_path_endpoint", + **{name: contract[name] for name in required}, + "path_endpoint": suffix, + }, ), } +def _initial_direct_sweep_cap_edge_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] | None: + """Bind the one-edge boundary of an immediate direct PipeShell cap. + + PipeShell has no per-edge history callback. This contract consequently + admits only the same direct source pair as the cap-face bridge and only a + profile with one retained direct source edge. The adapter independently + proves that the role face has one exact final boundary edge. + """ + try: + _call, owner, topology, kind, definition = _direct_make_query(value) + except ValueError: + return None + if topology != "CAP_EDGE" or kind not in {"edge", "entitytype.edge"}: + return None + query = parse_query(value) + if query.is_start is None: + return None + producer_id = f"f_{owner}" + producer = feature_by_id.get(producer_id) + params = (producer or {}).get("params") or {} + contract = params.get("cap_output_contract") + if ( + producer is None + or previous[-1:] != [producer_id] + or producer.get("atomic_id") != "sweep_add" + or params.get("result_mode") != "new_body" + or params.get("initial_output_roles") is not True + or not isinstance(contract, dict) + ): + return None + required = ("profile_source", "profile_entity", "path_source", "path_entity", "path_reversed") + if ( + any(not isinstance(contract.get(name), str) or not contract[name] for name in required[:-1]) + or not isinstance(contract.get("path_reversed"), bool) + ): + return None + sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) or {} + profile = sketch.get("profile") or {} + if ( + sketch.get("source_sketch_id") != contract["profile_source"] + or profile.get("type") != "circle" + or profile.get("source_entity_id") != contract["profile_entity"] + ): + return None + refs = _source_refs(definition.get("disambiguationData")) + profile_ref = (contract["profile_source"], contract["profile_entity"]) + path_prefix = f"{contract['path_entity']}." + path_refs = [ref for ref in refs if ref[0] == contract["path_source"] and ref[1].startswith(path_prefix)] + if len(refs) != 2 or profile_ref not in refs or len(path_refs) != 1: + return None + suffix = path_refs[0][1][len(path_prefix):] + expected_endpoint = "start" if query.is_start else "end" + if suffix != expected_endpoint: + return None + role_endpoint = suffix if not contract["path_reversed"] else ("end" if suffix == "start" else "start") + role = f"sweep.{role_endpoint}" + intent = _selector_intent( + value, + query_family="CAP_EDGE", + kind="edge", + evidence="kernel_history", + allowed=("boundary",), + disambiguation={ + "type": "sweep_profile_path_endpoint", + **{name: contract[name] for name in required}, + "path_endpoint": suffix, + }, + ) + # ``parse_query`` retains the endpoint as its convenience primary source + # for this two-anchor OSD. CAP_EDGE lineage is anchored on the profile + # edge; the path endpoint remains explicit disambiguation evidence. + intent["source_entity"] = { + "sketch_id": contract["profile_source"], + "entity_id": contract["profile_entity"], + } + intent["lineage_role"] = role + return { + "kind": "edge", + "owner_feature_id": producer_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": intent, + } + + +def _initial_direct_sweep_swept_face_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] | None: + """Bind one direct PipeShell side face through `Generated(profile_edge)`. + + The complete direct profile/path source contract remains mandatory. The + consumer receives only its selected profile edge as its runtime anchor: + the path edge is retained as source disambiguation, not turned into + geometry. Listing every source edge in the contract prevents a partial + or solver-rebuilt profile from looking like a direct builder history. + """ + try: + _call, owner, topology, kind, definition = _direct_make_query(value) + except ValueError: + return None + if topology != "SWEPT_FACE" or kind not in {"face", "entitytype.face"}: + return None + producer_id = f"f_{owner}" + producer = feature_by_id.get(producer_id) + params = (producer or {}).get("params") or {} + contract = params.get("swept_face_contract") + if ( + producer is None + or previous[-1:] != [producer_id] + or producer.get("atomic_id") != "sweep_add" + or params.get("result_mode") != "new_body" + or params.get("initial_output_roles") is not True + or not isinstance(contract, dict) + ): + return None + required = ("profile_source", "profile_entities", "path_source", "path_entity", "path_reversed") + if ( + any(not isinstance(contract.get(name), str) or not contract[name] for name in ("profile_source", "path_source", "path_entity")) + or not isinstance(contract.get("profile_entities"), list) + or not contract["profile_entities"] + or any(not isinstance(entity, str) or not entity for entity in contract["profile_entities"]) + or len(set(contract["profile_entities"])) != len(contract["profile_entities"]) + or not isinstance(contract.get("path_reversed"), bool) + ): + return None + sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) or {} + profile_entities = _direct_sweep_profile_entities(sketch) + if sketch.get("source_sketch_id") != contract["profile_source"] or profile_entities != contract["profile_entities"]: + return None + refs = _source_refs(definition.get("disambiguationData")) + path_ref = (contract["path_source"], contract["path_entity"]) + profile_refs = [ref for ref in refs if ref[0] == contract["profile_source"] and ref[1] in profile_entities] + if len(refs) != 2 or len(profile_refs) != 1 or path_ref not in refs: + return None + selected_profile_entity = profile_refs[0][1] + intent = _selector_intent( + value, + query_family="SWEPT_FACE", + kind="face", + evidence="kernel_history", + allowed=("boundary",), + disambiguation={ + "type": "sweep_profile_path", + **{name: contract[name] for name in required}, + }, + ) + intent["source_entity"] = { + "sketch_id": contract["profile_source"], + "entity_id": selected_profile_entity, + } + return { + "kind": "face", + "owner_feature_id": producer_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": intent, + } + + +def _direct_sweep_profile_entities(sketch: dict[str, Any]) -> list[str] | None: + """Return one unchanged direct closed profile's complete source edge set. + + PipeShell can report `Generated(edge)` for every profile edge, but only if + CDSL retains the full original contour. This deliberately rejects holes, + regions and any unlabeled/split edge rather than guessing a correspondence. + """ + profile = sketch.get("profile") or {} + if profile.get("type") == "circle": + entity = profile.get("source_entity_id") + return [entity] if isinstance(entity, str) and entity else None + contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None + if not isinstance(contours, list) or len(contours) != 1: + return None + contour = contours[0] or {} + segments = contour.get("segments") if isinstance(contour, dict) else None + if not contour.get("closed") or not isinstance(segments, list) or not segments: + return None + entities = [segment.get("source_entity_id") for segment in segments if isinstance(segment, dict)] + if len(entities) != len(segments) or any(not isinstance(entity, str) or not entity for entity in entities): + return None + return entities if len(set(entities)) == len(entities) else None + + +def _direct_sweep_profile_vertex_entity_pairs(sketch: dict[str, Any]) -> set[tuple[str, str]]: + """Return exact adjacent source-edge pairs for a direct closed contour.""" + entities = _direct_sweep_profile_entities(sketch) + if entities is None or len(entities) < 2: + return set() + return { + tuple(sorted((entities[index], entities[(index + 1) % len(entities)]))) + for index in range(len(entities)) + } + + +def _initial_direct_sweep_swept_edge_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] | None: + """Bind a PipeShell swept edge through `Generated(profile_vertex)`. + + CADFS identifies the source vertex by exactly two incident profile edges + and separately supplies the direct path edge. The path remains semantic + disambiguation: the runtime anchor is the exact profile vertex only. + """ + try: + _call, owner, topology, kind, definition = _direct_make_query(value) + except ValueError: + return None + if topology != "SWEPT_EDGE" or kind not in {"edge", "entitytype.edge"}: + return None + producer_id = f"f_{owner}" + producer = feature_by_id.get(producer_id) + params = (producer or {}).get("params") or {} + contract = params.get("swept_edge_contract") + if ( + producer is None + or previous[-1:] != [producer_id] + or producer.get("atomic_id") != "sweep_add" + or params.get("result_mode") != "new_body" + or params.get("initial_output_roles") is not True + or not isinstance(contract, dict) + ): + return None + required = ("profile_source", "profile_entities", "path_source", "path_entity", "path_reversed") + if ( + any(not isinstance(contract.get(name), str) or not contract[name] for name in ("profile_source", "path_source", "path_entity")) + or contract["profile_source"] == contract["path_source"] + or not isinstance(contract.get("profile_entities"), list) + or len(contract["profile_entities"]) < 2 + or any(not isinstance(entity, str) or not entity for entity in contract["profile_entities"]) + or len(set(contract["profile_entities"])) != len(contract["profile_entities"]) + or not isinstance(contract.get("path_reversed"), bool) + ): + return None + sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) or {} + profile_entities = _direct_sweep_profile_entities(sketch) + if sketch.get("source_sketch_id") != contract["profile_source"] or profile_entities != contract["profile_entities"]: + return None + refs = _source_refs(definition.get("disambiguationData")) + path_ref = (contract["path_source"], contract["path_entity"]) + profile_refs = [ref for ref in refs if ref[0] == contract["profile_source"] and ref[1] in profile_entities] + vertex_entities = tuple(sorted(ref[1] for ref in profile_refs)) + if ( + len(refs) != 3 + or len(set(refs)) != 3 + or len(profile_refs) != 2 + or len(set(vertex_entities)) != 2 + or path_ref not in refs + or vertex_entities not in _direct_sweep_profile_vertex_entity_pairs(sketch) + ): + return None + intent = _selector_intent( + value, + query_family="SWEPT_EDGE", + kind="edge", + evidence="kernel_history", + allowed=("boundary",), + disambiguation={ + "type": "sweep_profile_vertex_path", + **{name: contract[name] for name in required}, + "profile_vertex_entities": list(vertex_entities), + }, + ) + intent.pop("source_entity", None) + intent["source_entities"] = [ + {"sketch_id": contract["profile_source"], "entity_id": entity_id} + for entity_id in vertex_entities + ] + return { + "kind": "edge", + "owner_feature_id": producer_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": intent, + } + + def _profile_query_union_leaves(value: Any) -> list[Any]: """Flatten only associative query unions used to select one profile. @@ -2637,6 +5404,69 @@ def _profile_selection_sketch( output["profile"] = union_profile return output + def line_loop(contour: dict[str, Any]) -> list[list[float]] | None: + segments = contour.get("segments") or [] + if not isinstance(segments, list) or len(segments) < 3 or not all( + isinstance(segment, dict) and segment.get("type") == "line" + and isinstance(segment.get("start"), list) and isinstance(segment.get("end"), list) + for segment in segments + ): + return None + points = [list(segment["start"]) for segment in segments] + if any(math.dist(segment["end"], segments[(index + 1) % len(segments)]["start"]) > 1e-6 + for index, segment in enumerate(segments)): + return None + return points + + def strictly_contains(outer: list[list[float]], inner: list[list[float]]) -> bool: + """Return whether a closed line loop contains every inner vertex. + + This is source-profile construction, not a selector fallback: both + loops originate from the exact FeatureScript sketch entities. Boundary + contact remains rejected because it has split/region semantics that + cannot be represented by a single direct analytic profile. + """ + def inside(point: list[float]) -> bool: + crossings = 0 + for index, start in enumerate(outer): + end = outer[(index + 1) % len(outer)] + cross = (end[0] - start[0]) * (point[1] - start[1]) - (end[1] - start[1]) * (point[0] - start[0]) + if abs(cross) <= 1e-8 and min(start[0], end[0]) - 1e-8 <= point[0] <= max(start[0], end[0]) + 1e-8 and min(start[1], end[1]) - 1e-8 <= point[1] <= max(start[1], end[1]) + 1e-8: + return False + if (start[1] > point[1]) != (end[1] > point[1]): + x = start[0] + (point[1] - start[1]) * (end[0] - start[0]) / (end[1] - start[1]) + if abs(x - point[0]) <= 1e-8: + return False + if x > point[0]: + crossings += 1 + return bool(crossings % 2) + return bool(inner) and all(inside(point) for point in inner) + + # A single IMPRINT query over one outer direct line loop denotes the + # source-side region, not merely that loop's curve. If every other source + # line loop lies strictly inside it, preserve those nested boundaries so + # the derived profile retains its holes and their CAP-edge provenance. + # Disjoint, touching, curved, split and multiple-outer arrangements keep + # the existing explicit-region paths below. + if len(selections) == 1: + source_id = max((key for key in entities if selections[0].source_entity and selections[0].source_entity.startswith(key)), key=len, default="") + side = _profile_selection_side(query_values[0]) if query_values else None + if source_id and side is not None: + matches = [ + (contour, next((_matching_profile_segment(segment, entities[source_id]) for segment in contour.get("segments") or [] if _matching_profile_segment(segment, entities[source_id])), 0)) + for contour in contours + if any(_matching_profile_segment(segment, entities[source_id]) for segment in contour.get("segments") or []) + ] + outer_loop = line_loop(matches[0][0]) if len(matches) == 1 and matches[0][1] == (1 if side > 0 else -1) else None + other_loops = [line_loop(contour) for contour in contours if not matches or contour is not matches[0][0]] + if outer_loop is not None and other_loops and all(loop is not None and strictly_contains(outer_loop, loop) for loop in other_loops): + output = deepcopy(sketch) + output["id"] = f"{sketch['id']}__{feature_id}" + output["name"] = f"{sketch['name']}__{feature_id}" + # Keep the source profile roles/segments intact. The solver + # performs the established containment-parity classification. + return output + # qUnion 可以从同一草图选择多个互不相同的 IMPRINT region。此前只取 # parse_query(qUnion(...)) 最后看到的一个 source entity,且在没有正反 # orientation 对时退回整张草图,导致未选圆也被错误拉伸。仅当每个 source @@ -2724,31 +5554,154 @@ def _surface_profile_selection_sketch( query_value: Any, entities: dict[str, dict[str, Any]], feature_id: str, + *, + allow_open_wire: bool = False, ) -> dict[str, Any]: - """Materialize the explicit circular wires selected by surfaceEntities.""" + """Materialize explicitly selected source wires from ``surfaceEntities``. + + Mixed solid/surface extrusion continues to accept one or more circular wires. + A pure ``ToolBodyType.SURFACE`` extrusion may additionally carry one or + more original lines. Each connected, non-branching open chain becomes + one shell wire; it never becomes a solid profile or active-body member. + """ references = _source_refs(query_value) - selected = [] + selected: list[tuple[str, dict[str, Any]]] = [] seen = set() + duplicate_source = False for source, entity_id in references: - if source != sketch["name"] or entity_id in seen: - continue - entity = entities.get(entity_id) - if entity is None or entity.get("type") != "circle": + if source != sketch["name"]: raise UnsupportedCapability( "extrude_surface_profile", - "current CDSL surface extrude requires explicitly selected circular wires", + "surface extrude source wires must come from one source sketch", ) - selected.append(entity); seen.add(entity_id) + if entity_id in seen: + duplicate_source = True + continue + entity = entities.get(entity_id) + if entity is None or entity.get("construction"): + raise UnsupportedCapability( + "extrude_surface_profile", + "current CDSL surface extrude requires one explicit original source wire", + ) + selected.append((entity_id, entity)); seen.add(entity_id) if not selected: raise ValueError("surface extrude profile query is unresolved") output = deepcopy(sketch) output["id"] = f"{sketch['id']}__{feature_id}_surface" output["name"] = f"{sketch['name']}__{feature_id}_surface" + if allow_open_wire and all(entity.get("type") == "line" for _entity_id, entity in selected): + if duplicate_source: + raise UnsupportedCapability( + "extrude_surface_profile", + "pure ToolBodyType.SURFACE open-wire extrusion requires distinct source lines", + ) + records: list[tuple[str, dict[str, Any], list[float], list[float]]] = [] + for entity_id, entity in selected: + start, end = entity.get("start"), entity.get("end") + if ( + not isinstance(start, list) + or not isinstance(end, list) + or len(start) != 2 + or len(end) != 2 + or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in [*start, *end]) + or _same_point(start, end) + ): + raise UnsupportedCapability( + "extrude_surface_profile", + "pure ToolBodyType.SURFACE open-wire extrusion requires finite non-degenerate source lines", + ) + records.append((entity_id, entity, list(start), list(end))) + + # A query set may deliberately contain disconnected wires, but each + # component must itself have one unambiguous pair of terminal points. + # Query encounter order is only a tie-breaker for a proven chain's + # direction; it is never used to join or choose nearby geometry. + remaining_components = set(range(len(records))) + contours: list[dict[str, Any]] = [] + while remaining_components: + seed = min(remaining_components) + component = {seed} + frontier = [seed] + while frontier: + index = frontier.pop() + _entity_id, _entity, start, end = records[index] + for candidate in remaining_components - component: + _candidate_id, _candidate, candidate_start, candidate_end = records[candidate] + if any( + _same_point(point, candidate_point) + for point in (start, end) + for candidate_point in (candidate_start, candidate_end) + ): + component.add(candidate) + frontier.append(candidate) + remaining_components -= component + + def incident_count(point: list[float]) -> int: + return sum( + int(_same_point(point, start)) + int(_same_point(point, end)) + for index in component + for _entity_id, _entity, start, end in [records[index]] + ) + + terminals = [ + endpoint + for index in sorted(component) + for endpoint in records[index][2:] + if incident_count(endpoint) == 1 + ] + if len(terminals) != 2: + raise UnsupportedCapability( + "extrude_surface_profile", + "pure ToolBodyType.SURFACE source lines must form non-branching open chains", + ) + current = terminals[0] + component_remaining = set(component) + segments: list[dict[str, Any]] = [] + while component_remaining: + matches = [ + index for index in component_remaining + if _same_point(current, records[index][2]) or _same_point(current, records[index][3]) + ] + if len(matches) != 1: + raise UnsupportedCapability( + "extrude_surface_profile", + "pure ToolBodyType.SURFACE source lines must form non-branching open chains", + ) + index = matches[0] + entity_id, entity, start, end = records[index] + if _same_point(current, start): + segment = deepcopy(entity) + current = end + else: + segment = _reversed_sweep_path_segment(entity) + current = start + segment.setdefault("source_entity_id", entity_id) + segments.append(segment) + component_remaining.remove(index) + if incident_count(current) != 1: + raise UnsupportedCapability( + "extrude_surface_profile", + "pure ToolBodyType.SURFACE source lines must form non-branching open chains", + ) + contours.append({ + "role": "open", "closed": False, "surface_wire": True, + "segments": segments, + }) + output["profile"] = { + "type": "analytic_contours", + "contours": contours, + } + return output + if any(entity.get("type") != "circle" for _entity_id, entity in selected): + raise UnsupportedCapability( + "extrude_surface_profile", + "current CDSL mixed surface extrude requires explicitly selected circular wires", + ) output["profile"] = { "type": "analytic_contours", "contours": [ {"role": "unknown", "closed": True, "segments": [deepcopy(entity)]} - for entity in selected + for _entity_id, entity in selected ], } return output @@ -2833,10 +5786,15 @@ def _pattern_body_history_sources( def _sweep_cap_frames(profile: dict[str, Any], path: dict[str, Any]) -> dict[str, dict[str, Any]] | None: - """Record physical CAP_FACE frames for one line or B-spline sweep.""" + """Record physical CAP_FACE frames for one direct open sweep path.""" segment = path.get("segment") or {} path_plane = path.get("workplane") or {} profile_plane = profile.get("workplane") or {} + # A cross-sketch spatial path deliberately has no single planar frame. + # Its source capture can execute a sweep, but it has not established the + # planar cap-frame contract used by downstream CAP_FACE lowering. + if not isinstance(path_plane, dict) or not path_plane: + return None points = segment.get("points") if segment.get("type") == "bspline" else [segment.get("start"), segment.get("end")] if not isinstance(points, list) or len(points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in points): return None @@ -2857,8 +5815,21 @@ def _sweep_cap_frames(profile: dict[str, Any], path: dict[str, Any]) -> dict[str return _unit(fallback, "sweep path is degenerate") start, end = point(points[0]), point(points[-1]) - start_direction = direction(segment.get("start_tangent"), _sub(point(points[1]), start)) - end_direction = direction(segment.get("end_tangent"), _sub(end, point(points[-2]))) + if segment.get("type") == "arc": + center = segment.get("center") + if not isinstance(center, list) or len(center) != 2: + return None + start_radial = [float(points[0][0]) - float(center[0]), float(points[0][1]) - float(center[1])] + end_radial = [float(points[-1][0]) - float(center[0]), float(points[-1][1]) - float(center[1])] + if bool(segment.get("clockwise", False)): + start_tangent, end_tangent = [start_radial[1], -start_radial[0]], [end_radial[1], -end_radial[0]] + else: + start_tangent, end_tangent = [-start_radial[1], start_radial[0]], [-end_radial[1], end_radial[0]] + start_direction = direction(start_tangent, _sub(end, start)) + end_direction = direction(end_tangent, _sub(end, start)) + else: + start_direction = direction(segment.get("start_tangent"), _sub(point(points[1]), start)) + end_direction = direction(segment.get("end_tangent"), _sub(end, point(points[-2]))) x_dir = list(profile_plane["x_dir"]) return { # OCC keeps the source profile as the start cap of a solid sweep. Its @@ -2872,11 +5843,16 @@ def _sweep_cap_frames(profile: dict[str, Any], path: dict[str, Any]) -> dict[str def _reversed_sweep_path_segment(segment: dict[str, Any]) -> dict[str, Any]: - """Reverse one CADFS line/B-spline sweep path without changing its curve.""" + """Reverse one CADFS line/arc/B-spline sweep path without changing its curve.""" output = deepcopy(segment) - output["start"], output["end"] = segment["end"], segment["start"] + if output.get("type") in {"line", "arc"}: + output["start"], output["end"] = segment["end"], segment["start"] + if output.get("type") == "arc": + output["clockwise"] = not bool(segment.get("clockwise", False)) if output.get("type") == "bspline": output["points"] = list(reversed(segment.get("points") or [])) + if "start" in segment and "end" in segment: + output["start"], output["end"] = segment["end"], segment["start"] parameters = segment.get("parameters") if isinstance(parameters, list) and parameters: start_parameter, end_parameter = float(parameters[0]), float(parameters[-1]) @@ -2888,23 +5864,112 @@ def _reversed_sweep_path_segment(segment: dict[str, Any]) -> dict[str, Any]: return output +def _reversed_spatial_sweep_path_segment(segment: dict[str, Any]) -> dict[str, Any]: + """Reverse one globally captured source curve without changing its locus.""" + output = deepcopy(segment) + if output.get("type") in {"line", "arc"}: + output["start_mm"], output["end_mm"] = segment["end_mm"], segment["start_mm"] + if output.get("type") == "arc": + output["clockwise"] = not bool(segment.get("clockwise", False)) + if output.get("type") == "bspline": + output["points_mm"] = list(reversed(segment.get("points_mm") or [])) + parameters = segment.get("parameters") + if isinstance(parameters, list) and parameters: + start_parameter, end_parameter = float(parameters[0]), float(parameters[-1]) + output["parameters"] = [start_parameter + end_parameter - float(value) for value in reversed(parameters)] + start_tangent, end_tangent = segment.get("start_tangent_mm"), segment.get("end_tangent_mm") + if isinstance(start_tangent, list) and isinstance(end_tangent, list): + output["start_tangent_mm"] = [-float(value) for value in end_tangent] + output["end_tangent_mm"] = [-float(value) for value in start_tangent] + return output + + +def _sweep_path_endpoint(segment: dict[str, Any], endpoint: str) -> list[float] | None: + if endpoint not in {"start", "end"}: + return None + if segment.get("type") == "bspline": + points = segment.get("points") + value = points[0 if endpoint == "start" else -1] if isinstance(points, list) and points else None + else: + value = segment.get(endpoint) + if ( + not isinstance(value, list) + or len(value) != 2 + or not all(isinstance(component, (int, float)) and math.isfinite(float(component)) for component in value) + ): + return None + return [float(value[0]), float(value[1])] + + +def _same_sweep_path_point(left: list[float], right: list[float]) -> bool: + return math.dist(left, right) <= 1e-8 + + +def _reversed_sweep_path(path: dict[str, Any]) -> dict[str, Any]: + output = deepcopy(path) + segments = output.get("segments") + if isinstance(segments, list): + reverse_segment = ( + _reversed_spatial_sweep_path_segment + if "workplane" not in output + else _reversed_sweep_path_segment + ) + output["segments"] = [reverse_segment(segment) for segment in reversed(segments)] + else: + segment = output.get("segment") + if not isinstance(segment, dict): + raise ValueError("sweep path has no reversible segment") + output["segment"] = _reversed_sweep_path_segment(segment) + return output + + def _sweep_profile_attaches_at_path_end(profile: dict[str, Any], path: dict[str, Any]) -> bool: """Detect a circle profile placed at the terminal point of a CADFS path.""" - segment = path.get("segment") or {} - workplane = path.get("workplane") or {} profile_plane = profile.get("workplane") or {} profile_shape = profile.get("profile") or {} - if profile_shape.get("type") != "circle" or segment.get("type") not in {"line", "bspline"}: + segments = path.get("segments") + if isinstance(segments, list): + sequence = segments + else: + segment = path.get("segment") + sequence = [segment] if isinstance(segment, dict) else [] + if profile_shape.get("type") != "circle" or not sequence: return False - start, end = segment.get("start"), segment.get("end") center = profile_shape.get("center") or [0.0, 0.0] - if not all(isinstance(value, list) and len(value) == 2 for value in (start, end, center)): + if not isinstance(center, list) or len(center) != 2: return False try: - start_point, end_point = _global(workplane, start), _global(workplane, end) profile_center = _global(profile_plane, center) except (KeyError, TypeError, ValueError): return False + if "workplane" not in path: + def spatial_endpoint(segment: dict[str, Any], endpoint: str) -> list[float] | None: + if segment.get("type") == "bspline": + points = segment.get("points_mm") + value = points[0 if endpoint == "start" else -1] if isinstance(points, list) and points else None + else: + value = segment.get(f"{endpoint}_mm") + if ( + not isinstance(value, list) + or len(value) != 3 + or not all(isinstance(component, (int, float)) and math.isfinite(float(component)) for component in value) + ): + return None + return [float(component) for component in value] + + start_point = spatial_endpoint(sequence[0], "start") + end_point = spatial_endpoint(sequence[-1], "end") + if start_point is None or end_point is None: + return False + else: + workplane = path.get("workplane") or {} + start, end = _sweep_path_endpoint(sequence[0], "start"), _sweep_path_endpoint(sequence[-1], "end") + if not all(isinstance(value, list) and len(value) == 2 for value in (start, end)): + return False + try: + start_point, end_point = _global(workplane, start), _global(workplane, end) + except (KeyError, TypeError, ValueError): + return False return math.dist(profile_center, end_point) <= 1e-5 and math.dist(profile_center, start_point) > 1e-5 @@ -2948,24 +6013,70 @@ def _boolean_body_references( 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]]]: +) -> tuple[list[str], list[dict[str, Any]], list[dict[str, str]]]: """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. + A body selected from a mirror, circular pattern, or multi-source transform + COPY is not its producer's aggregate result. Reuse the established + transform-query provenance parser so each selected body stays qualified by + its source member at runtime. """ 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, transform_copy_refs + + +def _ordered_boolean_body_references( + value: Any, + previous: list[str], + feature_by_id: dict[str, dict[str, Any]], + body_aliases: dict[str, str] | None = None, +) -> list[tuple[str, Any]]: + """Resolve targetless body operands without losing FeatureScript order. + + The ordinary helper groups feature, pattern and transform-COPY references + for the CDSL schema. That grouping is correct for an explicit target/tool + pair but cannot select one qualified member from a targetless source set. + Each source query must therefore resolve to exactly one qualified member + here before the caller applies its explicit target-selection policy. + """ + ordered: list[tuple[str, Any]] = [] + seen: set[tuple[Any, ...]] = set() + for query_value in _queries(value): + sources, instance_refs, transform_copy_refs = _boolean_body_references( + query_value, previous, feature_by_id, body_aliases, ) - return sources, instance_refs + candidates = ( + [("feature", source) for source in sources] + + [("pattern", reference) for reference in instance_refs] + + [("transform_copy", reference) for reference in transform_copy_refs] + ) + if len(candidates) != 1: + raise UnsupportedCapability( + "boolean_bodies_targets", + "targetless booleanBodies requires each source operand to resolve one explicit body member", + ) + kind, candidate = candidates[0] + if kind == "feature": + identity = (kind, candidate) + elif kind == "pattern": + identity = ( + kind, + candidate["pattern_feature_id"], + candidate["source_feature_id"], + candidate["instance_index"], + ) + else: + identity = ( + kind, + candidate["transform_feature_id"], + candidate["source_feature_id"], + ) + if identity not in seen: + seen.add(identity) + ordered.append((kind, candidate)) + return ordered def _shell_target_body_source( @@ -3035,14 +6146,33 @@ def _hole_scope_body_source( if source not in previous: raise ValueError("hole scope body source is unresolved") source = _resolved_body_alias(source, body_aliases) - if source not in body_members or len(body_members) != 1: + if source not in body_members: raise UnsupportedCapability( "hole_scope_body_source", - "hole scope body is no longer the sole independently selectable member", + "hole scope body is no longer an independently selectable member", ) return source +def _is_direct_hole_location_query(value: Any) -> bool: + """Return whether ``value`` has Hole's direct original-vertex form. + + This is intentionally narrower than ``parse_query``'s recursive source + extraction. A wrapper can contain an ``sQuery`` leaf, but that does not + make it a direct Hole location or turn a missing sketch frame into its + primary diagnostic. + """ + if not isinstance(value, Call) or value.name not in {"sQuery", "sketchEntityQuery"} or len(value.args) < 3: + return False + info = parse_query(value) + return ( + info.topology_type is None + and info.kind in {"vertex", "entitytype.vertex"} + and isinstance(info.source_sketch, str) + and isinstance(info.source_entity, str) + ) + + def _direct_hole_location( value: Any, sketch_by_source: dict[str, dict[str, Any]], @@ -3050,22 +6180,16 @@ def _direct_hole_location( ) -> tuple[list[float], dict[str, Any]] | None: """Resolve one original sketch vertex accepted by FeatureScript hole. - The Hole API exposes locations as sketch vertices. A circle centre and - direct line/arc endpoints are source vertices too, but derived suffixes, + The Hole API exposes locations as sketch vertices. A circle centre, an + explicitly computed original arc centre, and direct line/arc endpoints + are source vertices too, but derived suffixes, topology output, query combinators, and geometry proximity do not prove a location. Return local plane coordinates because the hole executor uses its host frame to construct the final 3D point. """ - if not isinstance(value, Call) or value.name not in {"sQuery", "sketchEntityQuery"} or len(value.args) < 3: + if not _is_direct_hole_location_query(value): return None info = parse_query(value) - if ( - info.topology_type is not None - or info.kind not in {"vertex", "entitytype.vertex"} - or not isinstance(info.source_sketch, str) - or not isinstance(info.source_entity, str) - ): - return None sketch = sketch_by_source.get(info.source_sketch) entities = entity_by_sketch.get(info.source_sketch) or {} if sketch is None: @@ -3081,6 +6205,11 @@ def _direct_hole_location( point = entity.get("point") elif entity.get("type") == "circle" and suffix == "center": point = entity.get("center") + elif entity.get("type") == "arc" and suffix == "center": + # ``_arc`` derives this exact source datum from skArc's required + # start/mid/end inputs. It remains source-sketch provenance, unlike + # a center inferred from a runtime curve or a trimmed arc offspring. + point = entity.get("center") elif entity.get("type") in {"line", "arc"} and suffix in {"start", "end"}: point = entity.get(suffix) if not isinstance(point, list) or len(point) != 2 or not all(isinstance(component, (int, float)) for component in point): @@ -3088,7 +6217,7 @@ def _direct_hole_location( return [float(point[0]), float(point[1]), 0.0], sketch["workplane"] -def _direct_sketch_wire_path( +def _direct_sketch_wire_selection( value: Any, sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], @@ -3096,14 +6225,20 @@ def _direct_sketch_wire_path( featurescript_version: str | None, standard_library: str | None, standard_library_version: str | None, -) -> tuple[str, str, dict[str, Any], dict[str, Any]] | None: - """Resolve one direct 1511 sketch-wire query used as a sweep path. + permit_construction: bool = False, + allowed_curve_types: frozenset[str] = frozenset({"line", "arc", "bspline"}), +) -> tuple[str, list[tuple[str, dict[str, Any]]], dict[str, Any]] | None: + """Resolve the exact source entities named by one direct sketch-wire query. ``qBodyType(qCreatedBy(sketch, EDGE), WIRE)`` filters the source sketch's reference-wire entities; it does not select runtime body topology. The - construction filter is part of the source query contract. A single-item + optional ``qConstructionFilter(..., NO)`` is evaluated against source + construction metadata. Without it, any construction curve in the source + result makes a sweep-path query ambiguous and remains deferred. The + source-only datum-axis caller may explicitly retain construction curves, + because FeatureScript permits a construction wire as a plane axis. A single-item qUnion is only the set identity here, never permission to flatten or pick - among several path entities. + among several source sketches. """ if ( featurescript_version != "1511" @@ -3116,15 +6251,57 @@ def _direct_sketch_wire_path( or value.name != "qUnion" or len(value.args) != 1 or not isinstance(value.args[0], list) - or len(value.args[0]) != 1 + or not value.args[0] ): return None - current = value.args[0][0] - if not isinstance(current, Call) or current.name != "qConstructionFilter" or len(current.args) != 2: + operands = value.args[0] + # A direct union of sketch edges is already an explicit source set. It + # has different semantics from qCreatedBy(..., WIRE): do not flatten + # nested queries or evaluate filters here. Each leaf must identify one + # original source curve from exactly one sketch. + if all( + isinstance(operand, Call) + and operand.name in {"sQuery", "sketchEntityQuery"} + and len(operand.args) >= 3 + for operand in operands + ): + infos = [parse_query(operand) for operand in operands] + if any( + info.topology_type is not None + or info.kind not in {"edge", "entitytype.edge"} + or not isinstance(info.source_sketch, str) + or not isinstance(info.source_entity, str) + for info in infos + ): + return None + sources = {info.source_sketch for info in infos} + if len(sources) != 1: + return None + source = next(iter(sources)) + sketch = sketch_by_source.get(source) + entities = entity_by_sketch.get(source) or {} + entity_ids = [str(info.source_entity) for info in infos] + if ( + sketch is None + or len(set(entity_ids)) != len(entity_ids) + or any(entity_id not in entities for entity_id in entity_ids) + ): + return None + candidates = [(entity_id, entities[entity_id]) for entity_id in entity_ids] + if ( + any(entity.get("construction") for _entity_id, entity in candidates) + or any(entity.get("type") not in allowed_curve_types for _entity_id, entity in candidates) + ): + return None + return source, candidates, sketch + if len(operands) != 1: return None - if symbolic_string(current.args[1]).rsplit(".", 1)[-1].upper() != "NO": - return None - current = current.args[0] + current = operands[0] + has_construction_filter = isinstance(current, Call) and current.name == "qConstructionFilter" + if has_construction_filter: + if len(current.args) != 2 or symbolic_string(current.args[1]).rsplit(".", 1)[-1].upper() != "NO": + return None + current = current.args[0] if not isinstance(current, Call) or current.name != "qBodyType" or len(current.args) != 2: return None if symbolic_string(current.args[1]).rsplit(".", 1)[-1].upper() != "WIRE": @@ -3139,17 +6316,444 @@ def _direct_sketch_wire_path( entities = entity_by_sketch.get(source or "") or {} if sketch is None: return None - candidates = [ + # ``qCreatedBy(..., EDGE)`` cannot select direct sketch points. They remain + # in the source-entity map for vertex consumers, but do not participate in + # this source wire or its construction-filter semantics. + all_candidates = [ (entity_id, entity) for entity_id, entity in entities.items() - if not entity.get("construction") + if entity.get("type") != "point" ] + if not all_candidates: + return None + if ( + not permit_construction + and not has_construction_filter + and any(entity.get("construction") for _entity_id, entity in all_candidates) + ): + return None + candidates = [ + (entity_id, entity) + for entity_id, entity in all_candidates + if not entity.get("construction") or (permit_construction and not has_construction_filter) + ] + if not candidates or any(entity.get("type") not in allowed_curve_types for _entity_id, entity in candidates): + return None + return str(source), candidates, sketch + + +def _direct_closed_sketch_wire_profile( + value: Any, + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + *, + featurescript_version: str | None, + standard_library: str | None, + standard_library_version: str | None, + feature_id: str, +) -> dict[str, Any] | None: + """Materialize one source-only closed wire for an independent surface loft. + + This is deliberately narrower than general ``qBodyType`` evaluation: the + exact source query must enumerate one sketch's non-construction wire + curves, and those curves must form exactly one closed, non-branching + contour. No runtime topology or geometric matching participates. + """ + if ( + not isinstance(value, Call) + or value.name != "qUnion" + or len(value.args) != 1 + or not isinstance(value.args[0], list) + or len(value.args[0]) != 1 + or not isinstance(value.args[0][0], Call) + or value.args[0][0].name != "qConstructionFilter" + ): + return None + selection = _direct_sketch_wire_selection( + value, sketch_by_source, entity_by_sketch, + featurescript_version=featurescript_version, + standard_library=standard_library, + standard_library_version=standard_library_version, + allowed_curve_types=frozenset({"line", "arc", "bspline", "circle"}), + ) + if selection is None: + return None + source, candidates, sketch = selection + if len(candidates) == 1 and candidates[0][1].get("type") == "circle": + entity_id, entity = candidates[0] + segment = deepcopy(entity) + segment["source_entity_id"] = entity_id + contours = [{"role": "unknown", "closed": True, "segments": [segment]}] + else: + if any(entity.get("type") == "circle" for _entity_id, entity in candidates): + return None + records: list[tuple[str, dict[str, Any], list[float], list[float]]] = [] + for entity_id, entity in candidates: + start = _sweep_path_endpoint(entity, "start") + end = _sweep_path_endpoint(entity, "end") + if start is None or end is None or _same_sweep_path_point(start, end): + return None + records.append((entity_id, entity, start, end)) + if len(records) < 2: + return None + + def incident_count(point: list[float]) -> int: + return sum( + int(_same_sweep_path_point(point, start)) + int(_same_sweep_path_point(point, end)) + for _entity_id, _entity, start, end in records + ) + + if any(incident_count(point) != 2 for _entity_id, _entity, start, end in records for point in (start, end)): + return None + remaining = set(range(len(records))) + first_id, first_entity, first_start, first_end = records[0] + ordered = [{**deepcopy(first_entity), "source_entity_id": first_id}] + remaining.remove(0) + current = first_end + while remaining: + matches = [ + index for index in remaining + if _same_sweep_path_point(current, records[index][2]) or _same_sweep_path_point(current, records[index][3]) + ] + if len(matches) != 1: + return None + index = matches[0] + entity_id, entity, start, end = records[index] + segment = deepcopy(entity) if _same_sweep_path_point(current, start) else _reversed_sweep_path_segment(entity) + segment["source_entity_id"] = entity_id + current = end if _same_sweep_path_point(current, start) else start + ordered.append(segment) + remaining.remove(index) + if not _same_sweep_path_point(current, first_start): + return None + contours = [{"role": "unknown", "closed": True, "segments": ordered}] + output = deepcopy(sketch) + output["id"] = f"{sketch['id']}__{feature_id}" + output["name"] = f"{sketch['name']}__{feature_id}" + output["source_sketch_id"] = source + output["profile"] = {"type": "analytic_contours", "contours": contours} + return output + + +def _direct_sketch_wire_path( + value: Any, + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + *, + featurescript_version: str | None, + standard_library: str | None, + standard_library_version: str | None, +) -> tuple[str, str, dict[str, Any], dict[str, Any]] | None: + """Resolve one direct open line/arc/B-spline source-wire path contract.""" + selection = _direct_sketch_wire_selection( + value, sketch_by_source, entity_by_sketch, + featurescript_version=featurescript_version, + standard_library=standard_library, + standard_library_version=standard_library_version, + ) + if selection is None: + return None + source, candidates, sketch = selection if len(candidates) != 1: return None entity_id, entity = candidates[0] - if entity.get("type") not in {"line", "bspline"}: + # A sketch circle has coincident endpoints and is never an unambiguous + # open sweep spine. A source ``skArc`` carries its exact directed arc + # data, so it is safe to retain under the same singleton query gate. + if entity.get("type") not in {"line", "arc", "bspline"}: return None - return str(source), entity_id, entity, sketch + return source, entity_id, entity, sketch + + +def _direct_sketch_circle_wire_path( + value: Any, + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + *, + featurescript_version: str | None, + standard_library: str | None, + standard_library_version: str | None, +) -> tuple[str, str, dict[str, Any], dict[str, Any]] | None: + """Resolve one closed source-circle wire without using a parsed leaf. + + A direct ``sQuery`` circle follows the legacy singleton path. This helper + is deliberately for the distinct ``qBodyType(qCreatedBy(..., EDGE), + WIRE)`` source-wire form, whose complete query result has already been + checked by ``_direct_sketch_wire_selection``. A circle cannot enter the + open line/arc/B-spline helper: it has no endpoint roles. + """ + selection = _direct_sketch_wire_selection( + value, sketch_by_source, entity_by_sketch, + featurescript_version=featurescript_version, + standard_library=standard_library, + standard_library_version=standard_library_version, + allowed_curve_types=frozenset({"circle"}), + ) + if selection is None: + return None + source, candidates, sketch = selection + if len(candidates) != 1: + return None + entity_id, entity = candidates[0] + if entity.get("type") != "circle": + return None + return source, entity_id, entity, sketch + + +def _direct_segmented_sketch_wire_path( + value: Any, + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + *, + featurescript_version: str | None, + standard_library: str | None, + standard_library_version: str | None, +) -> tuple[str, list[dict[str, Any]], dict[str, Any]] | None: + """Resolve one source-only connected, non-branching, open path wire. + + Ordering uses only exact direct source endpoints. It rejects closed, + disconnected, branching, degenerate, and construction-ambiguous source + sets rather than letting OCC pick a wire or infer an orientation. + """ + selection = _direct_sketch_wire_selection( + value, sketch_by_source, entity_by_sketch, + featurescript_version=featurescript_version, + standard_library=standard_library, + standard_library_version=standard_library_version, + ) + if selection is None: + return None + source, candidates, sketch = selection + if len(candidates) < 2: + return None + records: list[tuple[str, dict[str, Any], list[float], list[float]]] = [] + for entity_id, entity in candidates: + start, end = _sweep_path_endpoint(entity, "start"), _sweep_path_endpoint(entity, "end") + if start is None or end is None or _same_sweep_path_point(start, end): + return None + records.append((entity_id, entity, start, end)) + + def incident_count(point: list[float]) -> int: + return sum( + int(_same_sweep_path_point(point, start)) + int(_same_sweep_path_point(point, end)) + for _entity_id, _entity, start, end in records + ) + + terminals = [ + (index, endpoint) + for index, (_entity_id, _entity, start, end) in enumerate(records) + for endpoint in (start, end) + if incident_count(endpoint) == 1 + ] + if len(terminals) != 2: + return None + _start_index, current = terminals[0] + ordered: list[dict[str, Any]] = [] + remaining = set(range(len(records))) + while remaining: + matching = [ + index for index in remaining + if _same_sweep_path_point(current, records[index][2]) or _same_sweep_path_point(current, records[index][3]) + ] + if len(matching) != 1: + return None + index = matching[0] + entity_id, entity, start, end = records[index] + if _same_sweep_path_point(current, start): + segment = deepcopy(entity) + current = end + elif _same_sweep_path_point(current, end): + segment = _reversed_sweep_path_segment(entity) + current = start + else: # pragma: no cover - guarded by matching above + return None + segment["source_entity_id"] = entity_id + ordered.append(segment) + remaining.remove(index) + if incident_count(current) != 1: + return None + return source, ordered, sketch + + +def _spatial_sweep_path_segment( + entity_id: str, + entity: dict[str, Any], + sketch: dict[str, Any], + source: str, +) -> tuple[dict[str, Any], list[float], list[float]] | None: + """Capture one direct source sketch curve in its explicit global frame.""" + plane = sketch.get("workplane") + if not isinstance(plane, dict): + return None + kind = entity.get("type") + if kind not in {"line", "arc", "bspline"}: + return None + + def point(value: Any) -> list[float] | None: + if not isinstance(value, list) or len(value) != 2 or not all(isinstance(component, (int, float)) and math.isfinite(float(component)) for component in value): + return None + try: + return _global(plane, [float(value[0]), float(value[1])]) + except (KeyError, TypeError, ValueError): + return None + + def vector(value: Any) -> list[float] | None: + if not isinstance(value, list) or len(value) != 2 or not all(isinstance(component, (int, float)) and math.isfinite(float(component)) for component in value): + return None + try: + y_dir = _y_dir(plane) + return [plane["x_dir"][index] * float(value[0]) + y_dir[index] * float(value[1]) for index in range(3)] + except (KeyError, TypeError, ValueError): + return None + + output: dict[str, Any] = { + "type": kind, + "source_sketch_id": source, + "source_entity_id": entity_id, + } + if kind == "bspline": + values = entity.get("points") + if not isinstance(values, list) or len(values) < 2: + return None + points = [point(value) for value in values] + if any(value is None for value in points) or entity.get("periodic"): + return None + output["points_mm"] = points + parameters = entity.get("parameters") + if parameters is not None: + if not isinstance(parameters, list) or len(parameters) != len(points): + return None + try: + output["parameters"] = [float(value) for value in parameters] + except (TypeError, ValueError): + return None + start_tangent = entity.get("start_tangent") + end_tangent = entity.get("end_tangent") + if (start_tangent is None) != (end_tangent is None): + return None + if start_tangent is not None: + start_vector, end_vector = vector(start_tangent), vector(end_tangent) + if start_vector is None or end_vector is None: + return None + output["start_tangent_mm"] = start_vector + output["end_tangent_mm"] = end_vector + if len(points) == 2 and start_tangent is None: + return None + return output, points[0], points[-1] + + start, end = point(entity.get("start")), point(entity.get("end")) + if start is None or end is None: + return None + output["start_mm"] = start + output["end_mm"] = end + if kind == "arc": + center = point(entity.get("center")) + radius = entity.get("radius_mm") + normal = plane.get("normal") + if ( + center is None + or not isinstance(radius, (int, float)) + or not math.isfinite(float(radius)) + or float(radius) <= 0 + or not isinstance(normal, list) + or len(normal) != 3 + or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in normal) + ): + return None + output["center_mm"] = center + output["normal"] = [float(value) for value in normal] + output["radius_mm"] = float(radius) + output["clockwise"] = bool(entity.get("clockwise", False)) + return output, start, end + + +def _direct_spatial_segmented_sketch_wire_path( + value: Any, + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + *, + featurescript_version: str | None, + standard_library: str | None, + standard_library_version: str | None, +) -> list[dict[str, Any]] | None: + """Resolve a direct union of source sketch wires into one global open wire. + + This remains source-only. Each outer operand must be the exact versioned + wire query accepted by ``_direct_sketch_wire_selection``. Curves are + carried through their explicit sketch frames and may join only at exact + global source endpoints; no datum/topology/result geometry is consulted. + """ + if ( + not isinstance(value, Call) + or value.name != "qUnion" + or len(value.args) != 1 + or not isinstance(value.args[0], list) + or len(value.args[0]) < 2 + ): + return None + records: list[tuple[dict[str, Any], list[float], list[float]]] = [] + source_ids: set[tuple[str, str]] = set() + for operand in value.args[0]: + selection = _direct_sketch_wire_selection( + Call("qUnion", [[operand]]), sketch_by_source, entity_by_sketch, + featurescript_version=featurescript_version, + standard_library=standard_library, + standard_library_version=standard_library_version, + ) + if selection is None: + return None + source, candidates, sketch = selection + for entity_id, entity in candidates: + key = (source, entity_id) + if key in source_ids: + return None + source_ids.add(key) + captured = _spatial_sweep_path_segment(entity_id, entity, sketch, source) + if captured is None: + return None + segment, start, end = captured + if _same_point(start, end): + return None + records.append((segment, start, end)) + if len(records) < 2: + return None + + def incident_count(point: list[float]) -> int: + return sum( + int(_same_point(point, start)) + int(_same_point(point, end)) + for _segment, start, end in records + ) + + terminals = [ + endpoint + for _segment, start, end in records + for endpoint in (start, end) + if incident_count(endpoint) == 1 + ] + if len(terminals) != 2: + return None + current = terminals[0] + remaining = set(range(len(records))) + ordered: list[dict[str, Any]] = [] + while remaining: + matching = [ + index for index in remaining + if _same_point(current, records[index][1]) or _same_point(current, records[index][2]) + ] + if len(matching) != 1: + return None + index = matching[0] + segment, start, end = records[index] + if _same_point(current, start): + current = end + elif _same_point(current, end): + segment = _reversed_spatial_sweep_path_segment(segment) + current = start + else: # pragma: no cover - guarded by matching above + return None + ordered.append(segment) + remaining.remove(index) + return ordered if incident_count(current) == 1 else None def _pattern_remove_source(feature: dict[str, Any]) -> None: @@ -3257,7 +6861,21 @@ def _direct_transform_copy_member( 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]: + source_member_aliases = { + str(alias.get("source_feature_id")): str(alias.get("active_member_feature_id")) + for alias in params.get("source_member_aliases") or () + if isinstance(alias, dict) + and isinstance(alias.get("source_feature_id"), str) + and isinstance(alias.get("active_member_feature_id"), str) + } + # A transform COPY preserves the FeatureScript owner of its source body + # even when an earlier proven single-body successor supplies the current + # runtime member. The explicit mapping is source provenance, not an + # instruction to select a different body or a current-aggregate fallback. + if len(source_ids) == 1 and ( + source_ids == [upstream] + or source_member_aliases == {upstream: source_ids[0]} + ): return member_id, None if len(source_ids) > 1 and upstream in source_ids: return member_id, { @@ -3270,6 +6888,48 @@ def _direct_transform_copy_member( ) +def _transform_copy_terminal_source(value: Any) -> str | None: + """Return the original direct body owner at one COPY chain's root.""" + try: + _call, owner, topology, kind, definition = _direct_make_query(value) + except ValueError: + return None + if kind not in {"body", "entitytype.body"}: + return None + if topology == "SWEPT_BODY": + return f"f_{owner}" + if topology != "COPY" or definition.get("derivedFrom") is None: + return None + return _transform_copy_terminal_source(definition["derivedFrom"]) + + +def _transform_source_member_aliases( + value: Any, + sources: list[str], +) -> list[dict[str, str]]: + """Bind original transform query owners to their proved runtime members. + + This is only emitted for one-to-one direct body references. Pattern and + multi-source COPY references already have their own instance-qualified + contracts, so they must not be collapsed into this lifecycle mapping. + """ + query_values = _queries(value) + if len(sources) != 1 or len(query_values) != 1: + return [] + aliases: list[dict[str, str]] = [] + for query_value, active_member in zip(query_values, sources): + semantic_source = _transform_copy_terminal_source(query_value) + if semantic_source is None or semantic_source == active_member: + continue + alias = { + "source_feature_id": semantic_source, + "active_member_feature_id": active_member, + } + if alias not in aliases: + aliases.append(alias) + return aliases + + def _transform_copy_query_provenance( value: Any, previous: list[str], @@ -3650,6 +7310,37 @@ def _loft_profile_sketches(params: dict[str, Any]) -> list[str]: return sources +def _initial_direct_loft_cap_output_roles( + params: dict[str, Any], + sources: list[str], + features: list[dict[str, Any]], + featurescript_version: str | None, +) -> bool: + """Whether an initial direct loft exposes exact endpoint CAP roles.""" + if featurescript_version != "1511" or _has_active_body(features) or len(sources) != 2 or len(set(sources)) != 2: + return False + operation = str(params.get("operationType") or "NEW").rsplit(".", 1)[-1].upper() + if operation != "NEW": + return False + if any(params.get(key) not in (None, [], {}, False) for key in ( + "wireProfilesArray", "connections", "matchConnections", "startCondition", + "endCondition", "startMagnitude", "endMagnitude", + )): + return False + profiles = params.get("sheetProfilesArray") + if not isinstance(profiles, list) or len(profiles) != 2: + return False + for source, profile in zip(sources, profiles): + value = profile.get("sheetProfileEntities") if isinstance(profile, dict) else None + try: + _call, owner, topology, kind, _definition = _direct_make_query(value) + except ValueError: + return False + if owner != source or topology != "IMPRINT" or kind != "face": + return False + return True + + def _profile_query_kind(params: dict[str, Any]) -> str | None: for key in ("entities", "sheetProfilesArray"): if key in params: @@ -3661,6 +7352,11 @@ 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") == "multi_source_regions": + return bool(profile.get("source_sketch_ids")) and all( + _profile_executable({"profile": child}) + for child in profile.get("profiles") or [] + ) if profile.get("type") == "planar_imprint": return bool(profile.get("source_entities") and profile.get("selections")) return bool(profile.get("contours")) @@ -3947,6 +7643,39 @@ def _revolve_swept_face_profile( return {"type": "analytic_contours", "contours": contours} +def _contour_line_interior_normal( + contour: dict[str, Any], + entity: dict[str, Any], + source_plane: dict[str, Any], +) -> list[float] | None: + """Return the source-proven interior side of one closed contour line. + + A vertex average is not an interior witness: on a valid asymmetric + polygon it can lie on the selected boundary line. The ordered contour's + signed area instead gives the exact left/right interior side for the + matched source line, without inspecting any resulting B-rep geometry. + """ + segments = contour.get("segments") or [] + if not contour.get("closed") or entity.get("type") != "line" or not segments: + return None + matches = [segment for segment in segments if _matching_profile_segment(segment, entity)] + if len(matches) != 1: + return None + try: + area = _contour_area(segments) + if not math.isfinite(area) or abs(area) <= 1e-9: + return None + segment = matches[0] + start = _global(source_plane, segment["start"]) + end = _global(source_plane, segment["end"]) + traversal = _unit(_sub(end, start), "swept face source line is degenerate") + left = _cross(source_plane["normal"], traversal) + orientation = 1.0 if area > 0 else -1.0 + return _unit([orientation * value for value in left], "swept face source contour is degenerate") + except (KeyError, TypeError, ValueError): + return None + + def _query_plane( query: Any, feature_frames: dict[str, dict[str, Any]], @@ -3976,22 +7705,28 @@ def _query_plane( item for item in contours if any(_matching_profile_segment(segment, entity) for segment in item.get("segments") or []) ), None) - points = [ - segment["start"] - for segment in (contour or {}).get("segments") or [] - if isinstance(segment.get("start"), list) - ] - if points: - center = _global(source_plane, [ - sum(point[index] for point in points) / len(points) - for index in range(2) - ]) - midpoint = [(start[index] + end[index]) / 2.0 for index in range(3)] - inward = _sub(center, midpoint) - inward = _sub(inward, [direction[index] * _dot(inward, direction) for index in range(3)]) - normal = [-value for value in _unit(inward, "swept face interior is degenerate")] + interior_normal = _contour_line_interior_normal(contour, entity, source_plane) if isinstance(contour, dict) else None + if interior_normal is not None: + normal = [-value for value in interior_normal] else: - normal = _cross(direction, frame["end"]["normal"]) + # Keep the existing fallback for source contours without one + # complete ordered line-loop witness. + points = [ + segment["start"] + for segment in (contour or {}).get("segments") or [] + if isinstance(segment.get("start"), list) + ] + if points: + center = _global(source_plane, [ + sum(point[index] for point in points) / len(points) + for index in range(2) + ]) + midpoint = [(start[index] + end[index]) / 2.0 for index in range(3)] + inward = _sub(center, midpoint) + inward = _sub(inward, [direction[index] * _dot(inward, direction) for index in range(3)]) + normal = [-value for value in _unit(inward, "swept face interior is degenerate")] + else: + normal = _cross(direction, frame["end"]["normal"]) x_dir = direction if _dot(_cross(normal, x_dir), source_plane["normal"]) < 0: x_dir = [-value for value in x_dir] @@ -4743,7 +8478,15 @@ def _record_single_body_successor( state["owner"] = feature_id sources.add(feature_id) else: - _clear_single_body_successor_state(aliases, state) + # A new body does not mutate the preceding member. Existing + # aliases still identify that member exactly (for example a + # later SWEPT_BODY query of a shell's original direct-prism + # owner), even though there is no longer one aggregate body + # through which a future ordinary ADD may advance them. + # Detach the single-body state without erasing those proven + # member aliases. + state["owner"] = None + state["sources"] = set() return if owner is None: state["owner"] = feature_id @@ -4793,6 +8536,15 @@ def _query_point( entity_by_sketch: dict[str, dict[str, dict[str, Any]]], ) -> list[float]: info = parse_query(query) + cpoint_frame = feature_frames.get(info.owner_feature or "") + if ( + info.topology_type is None + and info.kind in {"vertex", "entitytype.vertex"} + and "qCreatedBy" in info.calls + and isinstance(cpoint_frame, dict) + and isinstance(cpoint_frame.get("point_mm"), list) + ): + return list(cpoint_frame["point_mm"]) 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"] @@ -4943,6 +8695,110 @@ def _direct_line_angle_axis( return None +def _direct_prism_line_angle_swept_edge_axis( + 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]]], + *, + previous: list[str], + featurescript_version: str | None, +) -> tuple[list[float], list[float]] | None: + """Return a source-determined datum axis for one direct prism SWEPT_EDGE. + + This is deliberately a datum calculation, not a topology resolver. A + direct blind prism turns one explicitly identified source-profile vertex + into the line connecting its two cap copies. The producer frame records + that its profile was unchanged, so the two source curves and the prism + span determine that line without asking the current body for an edge. + """ + if featurescript_version != "1511": + return None + try: + _call, owner, topology, kind, _definition = _direct_make_query(query) + except ValueError: + return None + producer_id = f"f_{owner}" + frame = feature_frames.get(owner) or {} + profile_source = frame.get("profile_source") + if ( + topology != "SWEPT_EDGE" + or kind not in {"edge", "entitytype.edge"} + or previous[-1:] != [producer_id] + or frame.get("direct_prism_line_angle_axis") is not True + or not isinstance(profile_source, str) + or profile_source not in sketch_by_source + or not isinstance(frame.get("profile"), dict) + or not isinstance(frame.get("start"), dict) + or not isinstance(frame.get("end"), dict) + ): + return None + refs = _source_refs(query) + if len(refs) != 2 or {source for source, _token in refs} != {profile_source}: + return None + source_ids = set(frame.get("direct_prism_source_entity_ids") or ()) + resolved_ids: set[str] = set() + for source, token in refs: + resolved = _source_ref_entity(source, token, entity_by_sketch) + if resolved is None: + return None + entity_id, entity = resolved + if entity.get("construction") or entity_id not in source_ids: + return None + resolved_ids.add(entity_id) + if len(resolved_ids) != 2: + return None + local = _shared_source_endpoint(refs, profile_source, entity_by_sketch) + if local is None: + return None + try: + source_point = _global(sketch_by_source[profile_source]["workplane"], local) + profile_origin = frame["profile"]["origin_mm"] + start_origin = frame["start"]["origin_mm"] + end_origin = frame["end"]["origin_mm"] + start = [source_point[index] + start_origin[index] - profile_origin[index] for index in range(3)] + direction = _unit(_sub(end_origin, start_origin), "line-angle swept-edge axis is degenerate") + except (KeyError, TypeError, ValueError): + return None + if not all(math.isfinite(component) for component in start + direction): + return None + return start, direction + + +def _direct_line_angle_wire_axis( + query: Any, + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + *, + featurescript_version: str | None, + standard_library: str | None, + standard_library_version: str | None, +) -> tuple[list[float], list[float]] | None: + """Return one exact source-wire line axis for ``LINE_ANGLE``. + + This interprets only one direct ``qBodyType(qCreatedBy(sketch, EDGE), + WIRE)`` source query. It does not expose a runtime ``qBodyType`` selector: + one line must be named from one source sketch, with no derived topology or + current-body information. Construction lines are valid datum axes, unlike + sweep paths. + """ + selection = _direct_sketch_wire_selection( + Call("qUnion", [[query]]), sketch_by_source, entity_by_sketch, + featurescript_version=featurescript_version, + standard_library=standard_library, + standard_library_version=standard_library_version, + permit_construction=True, + ) + if selection is None: + return None + _source, candidates, sketch = selection + if len(candidates) != 1 or candidates[0][1].get("type") != "line": + return None + _entity_id, entity = candidates[0] + start, end = _entity_line(entity, sketch["workplane"]) + return start, _unit(_sub(end, start), "line-angle reference axis is degenerate") + + def _direct_line_angle_reference_plane( query: Any, feature_frames: dict[str, dict[str, Any]], @@ -4967,11 +8823,21 @@ def _direct_line_angle_reference_plane( def _direct_line_angle_reference_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]]], ) -> list[float] | None: - """Resolve one direct sketch point or direct line endpoint, and nothing else.""" + """Resolve one explicit datum point or direct sketch point/endpoint.""" info = parse_query(query) + cpoint_frame = feature_frames.get(info.owner_feature or "") + if ( + info.topology_type is None + and info.kind in {"vertex", "entitytype.vertex"} + and "qCreatedBy" in info.calls + and isinstance(cpoint_frame, dict) + and isinstance(cpoint_frame.get("point_mm"), list) + ): + return list(cpoint_frame["point_mm"]) if ( info.topology_type is not None or info.kind not in {"vertex", "entitytype.vertex"} @@ -4998,6 +8864,11 @@ def _direct_line_angle_two_entity_plane( 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]]], + *, + previous: list[str], + featurescript_version: str | None, + standard_library: str | None, + standard_library_version: str | None, ) -> dict[str, Any] | None: """Implement FeatureScript's exact two-entity ``lineAnglePlane`` rule. @@ -5008,8 +8879,32 @@ def _direct_line_angle_two_entity_plane( """ if len(entities) != 2: return None - first_axis = _direct_line_angle_axis(entities[0], sketch_by_source, entity_by_sketch) - second_axis = _direct_line_angle_axis(entities[1], sketch_by_source, entity_by_sketch) + first_axis = ( + _direct_line_angle_axis(entities[0], sketch_by_source, entity_by_sketch) + or _direct_prism_line_angle_swept_edge_axis( + entities[0], feature_frames, sketch_by_source, entity_by_sketch, + previous=previous, featurescript_version=featurescript_version, + ) + or _direct_line_angle_wire_axis( + entities[0], sketch_by_source, entity_by_sketch, + featurescript_version=featurescript_version, + standard_library=standard_library, + standard_library_version=standard_library_version, + ) + ) + second_axis = ( + _direct_line_angle_axis(entities[1], sketch_by_source, entity_by_sketch) + or _direct_prism_line_angle_swept_edge_axis( + entities[1], feature_frames, sketch_by_source, entity_by_sketch, + previous=previous, featurescript_version=featurescript_version, + ) + or _direct_line_angle_wire_axis( + entities[1], sketch_by_source, entity_by_sketch, + featurescript_version=featurescript_version, + standard_library=standard_library, + standard_library_version=standard_library_version, + ) + ) if first_axis is None: if second_axis is None: return None @@ -5033,7 +8928,7 @@ def _direct_line_angle_two_entity_plane( second_in_plane_direction = _cross(axis_direction, reference_plane["normal"]) else: reference_point = _direct_line_angle_reference_point( - reference, sketch_by_source, entity_by_sketch, + reference, feature_frames, sketch_by_source, entity_by_sketch, ) if reference_point is None: return None @@ -5051,14 +8946,254 @@ def _direct_line_angle_two_entity_plane( ) +def _cpoint_parameter(params: dict[str, Any]) -> float: + """Read the finite source-line parameter shared by every datum form.""" + parameter = _number(params.get("parameter")) + if not math.isfinite(parameter) or not 0.0 <= parameter <= 1.0: + raise UnsupportedCapability("reference_point", "cPoint parameter must be a finite value in [0, 1]") + return parameter + + +def _direct_source_line_cpoint( + params: dict[str, Any], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> list[float]: + """Evaluate one `cPoint` on a direct source-sketch line. + + This is source datum geometry, not a runtime edge selector. FeatureScript + parameterizes a line segment affinely, so the source endpoint coordinates + and a finite unit-interval parameter completely determine the point. + Derived CAP/SWEPT edges and curved source entities need their own contracts. + """ + entities = _queries(params.get("entities")) + if len(entities) != 1: + raise UnsupportedCapability("reference_point", "cPoint requires exactly one direct source line") + query = parse_query(entities[0]) + if ( + query.topology_type is not None + or query.kind not in {"edge", "entitytype.edge"} + or not isinstance(query.source_sketch, str) + or not isinstance(query.source_entity, str) + ): + raise UnsupportedCapability("reference_point", "cPoint source must be one direct sketch line") + resolved = _source_ref_entity(query.source_sketch, query.source_entity, entity_by_sketch) + sketch = sketch_by_source.get(query.source_sketch) + if resolved is None or sketch is None: + raise ValueError("cPoint source line is unresolved") + entity_id, entity = resolved + if entity_id != query.source_entity or entity.get("type") != "line": + raise UnsupportedCapability("reference_point", "cPoint only supports an original unsuffixed source line") + parameter = _cpoint_parameter(params) + start, end = _entity_line(entity, sketch["workplane"]) + return [start[index] + parameter * (end[index] - start[index]) for index in range(3)] + + +def _direct_prism_swept_edge_cpoint( + value: Any, + 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]]], + *, + previous: list[str], + featurescript_version: str | None, +) -> list[float] | None: + """Evaluate a source-defined direct-prism SWEPT_EDGE datum point. + + The LINE_ANGLE datum bridge proves the source vertex and prism span. + cPoint interpolates that source-defined span; it never resolves a runtime + edge or inspects the resulting body. + """ + try: + _call, owner, topology, _kind, _definition = _direct_make_query(value) + except ValueError: + return None + if topology != "SWEPT_EDGE": + return None + axis = _direct_prism_line_angle_swept_edge_axis( + value, feature_frames, sketch_by_source, entity_by_sketch, + previous=previous, featurescript_version=featurescript_version, + ) + frame = feature_frames.get(owner) or {} + start_frame, end_frame = frame.get("start"), frame.get("end") + if ( + axis is None + or not isinstance(start_frame, dict) + or not isinstance(end_frame, dict) + or not isinstance(start_frame.get("origin_mm"), list) + or not isinstance(end_frame.get("origin_mm"), list) + ): + return None + parameter = _cpoint_parameter(params) + start, _direction = axis + span = _sub(end_frame["origin_mm"], start_frame["origin_mm"]) + return [start[index] + parameter * span[index] for index in range(3)] + + +def _direct_prism_cap_edge_cpoint( + value: Any, + params: dict[str, Any], + *, + feature_by_id: dict[str, dict[str, 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]]], + previous: list[str], + featurescript_version: str | None, +) -> list[float] | None: + """Evaluate one direct-prism CAP_EDGE datum from its source line and role. + + Source datum points remain valid through earlier cPoint/reference-plane + features, but no body-mutating feature may intervene. The cap is not + looked up from the current B-rep. + """ + if featurescript_version != "1511": + return None + try: + _call, owner, topology, kind, _definition = _direct_make_query(value) + except ValueError: + return None + query = parse_query(value) + producer_id = f"f_{owner}" + producer = feature_by_id.get(producer_id) or {} + producer_params = producer.get("params") or {} + frame = feature_frames.get(owner) or {} + profile_source = frame.get("profile_source") + profile_sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) + source_sketch = sketch_by_source.get(profile_source) if isinstance(profile_source, str) else None + if producer_id not in previous: + return None + producer_index = previous.index(producer_id) + intervening = previous[producer_index + 1:] + if ( + topology != "CAP_EDGE" + or kind not in {"edge", "entitytype.edge"} + or query.is_start is None + or any((feature_by_id.get(feature_id) or {}).get("atomic_id") not in {"reference_plane", "reference_point"} for feature_id in intervening) + or producer.get("atomic_id") != "extrude_add_blind" + or producer_params.get("result_mode") != "new_body" + or (producer_params.get("end_condition") or {}).get("type") != "blind" + or producer_params.get("draft") is not None + or not isinstance(profile_source, str) + or source_sketch is None + or profile_sketch is None + or profile_sketch.get("source_sketch_id") != profile_source + or not _profile_matches_direct_source(profile_sketch, source_sketch) + ): + return None + refs = _source_refs(value) + if len(refs) != 1 or refs[0][0] != profile_source or query.source_entity != refs[0][1]: + return None + resolved = _source_ref_entity(profile_source, refs[0][1], entity_by_sketch) + source_ids = _direct_profile_source_entity_ids(profile_sketch) + if resolved is None: + return None + entity_id, entity = resolved + if ( + entity_id != refs[0][1] + or entity_id not in source_ids + or entity.get("construction") + or entity.get("type") != "line" + ): + return None + cap = frame.get("start" if query.is_start else "end") + profile = frame.get("profile") + if ( + not isinstance(cap, dict) + or not isinstance(profile, dict) + or not isinstance(cap.get("origin_mm"), list) + or not isinstance(profile.get("origin_mm"), list) + ): + return None + parameter = _cpoint_parameter(params) + start, end = _entity_line(entity, source_sketch["workplane"]) + source_point = [start[index] + parameter * (end[index] - start[index]) for index in range(3)] + return [source_point[index] + cap["origin_mm"][index] - profile["origin_mm"][index] for index in range(3)] + + +def _cpoint_datum( + params: dict[str, Any], + *, + feature_by_id: dict[str, dict[str, 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]]], + previous: list[str], + featurescript_version: str | None, +) -> list[float]: + """Select the only source-datum cPoint forms presently proven by source.""" + entities = _queries(params.get("entities")) + if len(entities) != 1: + raise UnsupportedCapability("reference_point", "cPoint requires exactly one direct source line") + query = entities[0] + topology = parse_query(query).topology_type + if topology is None: + return _direct_source_line_cpoint(params, sketch_by_source, entity_by_sketch) + point = ( + _direct_prism_swept_edge_cpoint( + query, params, feature_frames, sketch_by_source, entity_by_sketch, + previous=previous, featurescript_version=featurescript_version, + ) + if topology == "SWEPT_EDGE" else _direct_prism_cap_edge_cpoint( + query, params, + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=featurescript_version, + ) if topology == "CAP_EDGE" else None + ) + if point is None: + raise UnsupportedCapability("reference_point", "cPoint datum source is unsupported") + return point + + def _cplane( 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]], + sketches_by_id: dict[str, dict[str, Any]], + previous: list[str], + featurescript_version: str | None = None, + standard_library: str | None = None, + standard_library_version: str | None = None, ) -> dict[str, Any]: plane_type = str(params.get("cplaneType") or "OFFSET").split(".")[-1].upper() entities = _queries(params.get("entities")) + + def datum_point(query: Any) -> list[float]: + # The origin point is an explicit system datum. A CAP_VERTEX is not: + # it must satisfy the direct source-prism contract above rather than + # falling through to the legacy frame-based point reconstruction. + if any( + call.name == "qCreatedBy" and call.args + and "Origin.pointOp" in symbolic_string(call.args[0]) + for call in walk_calls(query) + ): + return [0.0, 0.0, 0.0] + if parse_query(query).topology_type == "CAP_VERTEX": + point = _direct_prism_cap_vertex_datum_point( + query, + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=featurescript_version, + ) + if point is None: + raise ValueError("CAP_VERTEX datum source is unsupported") + return point + return _query_point(query, feature_frames, sketch_by_source, entity_by_sketch) if plane_type == "OFFSET": offset = _number(params.get("offset", 0), True) # CPlane OFFSET preserves the source plane's local frame. CADFS uses @@ -5074,6 +9209,14 @@ def _cplane( if len(entities) == 1: direct_axis = _direct_line_angle_axis( entities[0], sketch_by_source, entity_by_sketch, + ) or _direct_prism_line_angle_swept_edge_axis( + entities[0], feature_frames, sketch_by_source, entity_by_sketch, + previous=previous, featurescript_version=featurescript_version, + ) or _direct_line_angle_wire_axis( + entities[0], sketch_by_source, entity_by_sketch, + featurescript_version=featurescript_version, + standard_library=standard_library, + standard_library_version=standard_library_version, ) if direct_axis is not None: origin, axis = direct_axis @@ -5086,6 +9229,10 @@ def _cplane( if len(entities) == 2: direct_plane = _direct_line_angle_two_entity_plane( entities, params, feature_frames, sketch_by_source, entity_by_sketch, + previous=previous, + featurescript_version=featurescript_version, + standard_library=standard_library, + standard_library_version=standard_library_version, ) if direct_plane is not None: return direct_plane @@ -5124,11 +9271,23 @@ def _cplane( start = [start[index] + _unit(radial, "line-angle cylinder radial direction is degenerate")[index] * entity["radius_mm"] for index in range(3)] return _frame(start, x_dir, normal) if plane_type == "PLANE_POINT": - base_query = next((item for item in entities if _default_plane(item) or "qCreatedBy" in parse_query(item).calls), None) - point_query = next((item for item in entities if item is not base_query), None) - if base_query is None or point_query is None: raise ValueError("plane-point references are unresolved") + # A PLANE_POINT definition is typed source data: one FACE plane and + # one VERTEX point. Do not infer their roles from qCreatedBy text; + # a direct CAP_FACE is equally a valid plane query and must retain + # that source topology rather than falling back to a runtime face. + face_queries = [ + item for item in entities + if (parse_query(item).kind or "").lower() in {"face", "entitytype.face"} + ] + point_queries = [ + item for item in entities + if (parse_query(item).kind or "").lower() in {"vertex", "entitytype.vertex"} + ] + if len(entities) != 2 or len(face_queries) != 1 or len(point_queries) != 1: + raise ValueError("plane-point requires exactly one face and one vertex") + base_query, point_query = face_queries[0], point_queries[0] base = _query_plane(base_query, feature_frames, sketch_by_source, entity_by_sketch) - return _frame(_query_point(point_query, feature_frames, sketch_by_source, entity_by_sketch), base["x_dir"], base["normal"]) + return _frame(datum_point(point_query), base["x_dir"], base["normal"]) if plane_type == "CURVE_POINT": point_query = next((item for item in entities if parse_query(item).kind and "vertex" in parse_query(item).kind), None) curve_query = next((item for item in entities if item is not point_query), None) @@ -5145,7 +9304,7 @@ def _cplane( return _frame(point, source_plane["normal"], tangent) if plane_type == "THREE_POINT": if len(entities) != 3: raise ValueError("three-point plane requires exactly three points") - first, second, third = [_query_point(item, feature_frames, sketch_by_source, entity_by_sketch) for item in entities] + first, second, third = [datum_point(item) for item in entities] normal = _cross(_sub(second, first), _sub(third, first)) if _bool(params.get("oppositeDirection")): normal = [-value for value in normal] return _frame(first, _sub(second, first), normal) @@ -5200,32 +9359,76 @@ def _mirror_plane_from_query( 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]], + sketches_by_id: dict[str, dict[str, Any]], + previous: list[str], + mirror_source_ids: list[str], + featurescript_version: str | None, ) -> 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. + planar annular face. A direct independent blind prism similarly has a + physical cap or one source-line side plane while it is still the immediate + mirror source. Those faces can materialize a CDSL datum plane without + resolving a runtime topology selector. Curved, partial, derived, mutated, + or unrelated faces remain unresolved instead of becoming guessed planes. """ 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: + try: + _call, owner, topology, kind, _definition = _direct_make_query(value) + except ValueError: return None - producer = feature_by_id.get(f"f_{info.owner_feature}") or {} - frame = feature_frames.get(info.owner_feature) or {} + if kind not in {"face", "entitytype.face"}: + return None + producer_id = f"f_{owner}" + producer = feature_by_id.get(producer_id) or {} + frame = feature_frames.get(owner) or {} + + if topology == "SWEPT_FACE" and ( + producer.get("atomic_id") == "revolve_add" + and frame.get("revolve_full") + and isinstance(frame.get("revolve_axis"), dict) + ): + try: + return _query_plane(value, feature_frames, sketch_by_source, entity_by_sketch) + except (UnsupportedCapability, ValueError): + return None + + # A CAP/SWEPT source can only be a static mirror datum while its physical + # direct-prism boundary is current and the mirror is operating on that + # same independently selectable body. Do not reuse a frame after a + # dress-up, Boolean, copy, or any other lifecycle transition. + params = producer.get("params") or {} if ( - producer.get("atomic_id") != "revolve_add" - or not frame.get("revolve_full") - or not isinstance(frame.get("revolve_axis"), dict) + featurescript_version != "1511" + or topology not in {"CAP_FACE", "SWEPT_FACE"} + or producer_id not in mirror_source_ids + or previous[-1:] != [producer_id] + or producer.get("atomic_id") != "extrude_add_blind" + or params.get("result_mode") != "new_body" + or (params.get("end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None ): return None try: + if topology == "CAP_FACE": + if _cap_face_output_role_selector(value, feature_by_id, sketches_by_id) is None: + return None + elif _direct_prism_swept_selector( + value, + owner=owner, + selector_kind="face", + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=featurescript_version, + ) is None: + return None return _query_plane(value, feature_frames, sketch_by_source, entity_by_sketch) except (UnsupportedCapability, ValueError): return None @@ -5234,9 +9437,10 @@ def _mirror_plane_from_query( 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]] = {}; surface_profiles: list[dict[str, Any]] = []; swept_face_sketches: set[str] = set() + feature_frames: dict[str, dict[str, Any]] = {}; source_variables: 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] = {} + sketch_workplanes = {step.feature_id: step.workplane for step in model.sketches} # 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. @@ -5254,7 +9458,83 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: if isinstance(step, SketchIR): history.append({"feature_id": step.feature_id, "operation": "newSketch", "parameters": {"sketchPlane": plain(step.workplane)}, "entities": [{"entity_id": e.feature_id, "operation": e.operation, "parameters": plain(e.params)} for e in step.entities]}) try: - swept_face = parse_query(step.workplane).topology_type == "SWEPT_FACE" + attachment = _direct_prism_cap_face_attachment( + step.workplane, + feature_by_id=feature_by_id, + sketches_by_id=sketches_by_id, + previous=previous, + featurescript_version=model.featurescript_version, + ) + if attachment is not None: + # CAP faces are native builder results. The exact plane is + # resolved only when the consuming feature executes. + lowered, entities = _lower_sketch(step, PLANES["Top"]) + lowered["attachment"] = attachment + sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities + continue + attachment = _direct_primary_cut_copy_cap_face_attachment( + step.workplane, + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=model.featurescript_version, + ) + if attachment is not None: + # This placeholder must never be consumed: attached + # sketches remain local through preflight and are + # materialized from the exact active face at execution. + lowered, entities = _lower_sketch(step, PLANES["Top"]) + lowered["attachment"] = attachment + sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities + continue + attachment = _direct_primary_cut_copy_swept_face_attachment( + step.workplane, + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=model.featurescript_version, + ) + if attachment is not None: + # The swept tool face has no static source frame. It is + # resolved through the native prism and subtract history + # only when the immediate consumer executes. + lowered, entities = _lower_sketch(step, PLANES["Top"]) + lowered["attachment"] = attachment + sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities + continue + attachment = _direct_prism_blend_face_attachment( + step.workplane, + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=model.featurescript_version, + ) + if attachment is not None: + # BLEND_FACE is a dress-up patch. Its physical plane and + # native orientation are available only from the exact + # runtime ``Generated(edge -> face)`` relation. + lowered, entities = _lower_sketch(step, PLANES["Top"]) + lowered["attachment"] = attachment + sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities + continue + # The outer query, rather than recursively parsed diagnostics, + # determines whether a static SWEPT_FACE frame is admissible. + # In particular MERGE(FACE) often contains SWEPT_FACE inputs + # but is a distinct result that must be resolved at runtime. + try: + _outer, _owner, outer_topology, _outer_kind, _definition = _direct_make_query(step.workplane) + except ValueError: + outer_topology = None + swept_face = outer_topology == "SWEPT_FACE" plane = _query_plane(step.workplane, feature_frames, sketch_by_source, entity_by_sketch) if swept_face else _plane_from_query(step.workplane, feature_frames, sketch_by_source, entity_by_sketch) profile = _revolve_swept_face_profile(step.workplane, plane, feature_frames, sketch_by_source, entity_by_sketch) if swept_face and not step.entities else None if profile is None: lowered, entities = _lower_sketch(step, plane) @@ -5277,11 +9557,27 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: continue item = step history.append({"feature_id": item.feature_id, "operation": item.operation, "source_span": {"line_start": item.line_start, "line_end": item.line_end or item.line_start}, "parameters": plain(item.params), "raw_source": item.raw_source}) - if item.operation in UNSUPPORTED: + if item.operation in UNSUPPORTED or item.operation not in LOWERABLE_OPERATIONS: diagnostics.append({"code": "unsupported_operation", "feature_id": item.feature_id, "operation": item.operation}); complete = False; continue try: - fid = f"f_{item.feature_id}"; depends = list(previous[-1:]); p = item.params; feature: dict[str, Any] - if item.operation == "transform": + fid = f"f_{item.feature_id}"; depends = list(previous[-1:]); p = _resolve_source_variables(item.params, source_variables); feature: dict[str, Any] + if item.operation == "assignVariable": + variable_name, source_value, variable_params = _assign_variable_params(p) + if variable_name in source_variables: + raise UnsupportedCapability( + "assign_variable_redeclaration", + f"assignVariable redeclares source variable {variable_name}", + ) + source_variables[variable_name] = deepcopy(source_value) + feature = { + "id": fid, + "name": item.feature_id, + "atomic_id": "assign_variable", + "depends_on": depends, + "params": variable_params, + "execution_status": "supported", + } + elif item.operation == "transform": # 单一直接 source 可以烘焙回原始几何。多 body 与 COPY instance # 必须保留为显式 body graph transform,不能移动聚合主体。 sources, pattern_instance_refs, transform_copy_refs = _transform_body_references( @@ -5345,6 +9641,9 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: transform_params["pattern_instance_refs"] = pattern_instance_refs if transform_copy_refs: transform_params["transform_copy_refs"] = transform_copy_refs + source_member_aliases = _transform_source_member_aliases(p.get("entities"), sources) + if source_member_aliases: + transform_params["source_member_aliases"] = source_member_aliases feature = { "id": fid, "name": item.feature_id, @@ -5390,8 +9689,36 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: "params": {"target_feature_ids": targets}, "execution_status": "supported", } + elif item.operation == "cPoint": + point = _cpoint_datum( + p, + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=model.featurescript_version, + ) + feature_frames[item.feature_id] = {"point_mm": point} + feature = { + "id": fid, + "name": item.feature_id, + "atomic_id": "reference_point", + "depends_on": depends, + "params": {"point_mm": point}, + "execution_status": "supported", + } elif item.operation == "cPlane": - plane = _cplane(p, feature_frames, sketch_by_source, entity_by_sketch) + plane = _cplane( + p, feature_frames, sketch_by_source, entity_by_sketch, + feature_by_id=feature_by_id, + sketches_by_id=sketches_by_id, + previous=previous, + featurescript_version=model.featurescript_version, + standard_library=model.standard_library, + standard_library_version=model.standard_library_version, + ) feature = {"id": fid, "name": item.feature_id, "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane}, "execution_status": "supported"} attachment = _attachment_plane(plane) feature_frames[item.feature_id] = { @@ -5402,6 +9729,29 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: } elif item.operation == "extrude": surface_profile_sketch = None + pure_surface_operation = ( + str(p.get("bodyType") or "").rsplit(".", 1)[-1].upper() == "SURFACE" + ) + if pure_surface_operation: + if p.get("surfaceOperationType") is not None: + raise UnsupportedCapability( + "extrude_surface_operation", + "pure ToolBodyType.SURFACE extrusion cannot also declare a surface operation", + ) + surface_body_operation = str(p.get("operationType") or "").rsplit(".", 1)[-1].upper() + if surface_body_operation not in {"", "ADD", "NEW"}: + raise UnsupportedCapability( + "extrude_surface_operation", + "pure ToolBodyType.SURFACE extrusion supports only an independent ADD surface operation", + ) + surface_source = _source_sketch({"surfaceEntities": p.get("surfaceEntities")}) + if not surface_source or surface_source not in sketch_by_source: + raise ValueError("surface extrude sketch query is unresolved") + surface_profile_sketch = _surface_profile_selection_sketch( + sketch_by_source[surface_source], p.get("surfaceEntities"), + entity_by_sketch[surface_source], fid, allow_open_wire=True, + ) + sketches.append(surface_profile_sketch); sketches_by_id[surface_profile_sketch["id"]] = surface_profile_sketch if p.get("surfaceOperationType") is not None: surface_operation = str(p.get("surfaceOperationType") or "").rsplit(".", 1)[-1].upper() if surface_operation != "ADD": @@ -5413,13 +9763,37 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: sketch_by_source[surface_source], p.get("surfaceEntities"), entity_by_sketch[surface_source], fid, ) sketches.append(surface_profile_sketch); sketches_by_id[surface_profile_sketch["id"]] = surface_profile_sketch - profile_value = _sketch_region_query(p.get("entities")) or p.get("entities") + profile_value = ( + p.get("surfaceEntities") if pure_surface_operation + else _sketch_region_query(p.get("entities")) or p.get("entities") + ) + profile_operation = str(p.get("operationType") or "NEW").upper() profile_kind = parse_query(profile_value).topology_type - source = parse_query(profile_value).source_sketch or _source_sketch(p) + multi_source_region_profile = ( + _multi_source_sketch_region_profile(p.get("entities"), sketch_by_source, fid) + if not pure_surface_operation else None + ) + if ( + not pure_surface_operation + and multi_source_region_profile is None + and _has_composed_sketch_region_union(p.get("entities")) + ): + raise UnsupportedCapability( + "extrude_multi_source_sketch_region", + "extrude qSketchRegion union requires direct executable source profiles on one identical unattached frame", + ) + source = ( + None if multi_source_region_profile is not None + else parse_query(profile_value).source_sketch or _source_sketch(p) + ) imprint = _imprint_sketch(profile_value) cap_face_output_selector = _cap_face_output_role_selector( profile_value, feature_by_id, sketches_by_id, ) + retained_offset_cap_selector = _shell_retained_direct_prism_cap_offset_face_profile_selector( + profile_value, feature_by_id, sketches_by_id, previous, + featurescript_version=model.featurescript_version, + ) if profile_kind == "OFFSET_FACE" else None cap_edge_hole = _cap_edge_hole_profile_sketch( profile_value, sketch_by_source, entity_by_sketch, feature_frames, fid, ) @@ -5437,10 +9811,36 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: if profile_kind == "IMPRINT" and source in sketch_by_source else None ) + # ``parse_query`` retains nested-query information and its + # last nested makeQuery can be a CAP_EDGE/INTERSECT leaf. + # Only the external-boundary form needs direct qUnion-root + # inspection here; ordinary single IMPRINT profiles keep + # their established materialization path. + planar_imprint_selections = [ + selection + for root in _queries(profile_value) + if (selection := _planar_imprint_selection(root)) is not None + ] + has_external_planar_imprint_root = any( + isinstance((selection[1].get("fragment") or {}).get("_external_anchor_query"), Call) + for selection in planar_imprint_selections + ) planar_imprint_profile = _planar_imprint_profile_sketch( - profile_value, sketch_by_source, entity_by_sketch, fid, + profile_value, + sketch_by_source, + entity_by_sketch, + fid, + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketches_by_id=sketches_by_id, + previous=previous, + featurescript_version=model.featurescript_version, ) if ( profile_kind == "INTERSECT" + or ( + has_external_planar_imprint_root + and _is_new_body_operation(profile_operation) + ) or ( profile_kind == "IMPRINT" and len(_queries(profile_value)) > 1 @@ -5455,10 +9855,13 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: profile_value, feature_frames, sketches_by_id, sketch_by_source, entity_by_sketch, feature_by_id, fid, ) if profile_kind == "OFFSET_FACE" else None profile_sketch: dict[str, Any] | None = None - if cap_face_output_selector is not None: + if cap_face_output_selector is not None or retained_offset_cap_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 multi_source_region_profile is not None: + profile_sketch = multi_source_region_profile + sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch elif cap_edge_hole is not None: profile_sketch, hole_selector = cap_edge_hole if profile_sketch["id"] not in sketches_by_id: @@ -5477,18 +9880,30 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: elif offset_face_profile is not None: profile_sketch = offset_face_profile sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch + elif pure_surface_operation: + profile_sketch = surface_profile_sketch elif profile_kind not in {None, "IMPRINT"}: partitioned = _partitioned_imprint_sketch(p.get("entities"), entity_by_sketch) if partitioned is not None: source = partitioned; profile_kind = "IMPRINT" else: raise UnsupportedCapability(f"extrude_profile_topology:{profile_kind.lower()}", f"current CDSL engine cannot exactly replay an extrude profile selected from {profile_kind}") - if cap_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 pure_surface_operation + and cap_face_output_selector is None + and retained_offset_cap_selector is None + and multi_source_region_profile 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() + operation = profile_operation cutting = any(x in operation for x in ("REMOVE", "CUT")) 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) @@ -5498,7 +9913,12 @@ 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 profile_sketch is not None and not _profile_executable(profile_sketch): raise ValueError("extrude sketch has no closed profile") + if ( + profile_sketch is not None + and not pure_surface_operation + and not _profile_executable(profile_sketch) + ): + raise ValueError("extrude sketch has no closed profile") second = _bool(p.get("hasSecondDirection")) end = _end_condition("SYMMETRIC" if _bool(p.get("symmetric")) else p.get("endBound")) if end["type"] == "up_to_body": @@ -5508,16 +9928,34 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: featurescript_version=model.featurescript_version, ) elif end["type"] == "up_to_surface": - end["reference"] = _extent_reference( - p.get("endBoundEntityFace"), "face", feature_frames, sketch_by_source, entity_by_sketch, - feature_by_id=feature_by_id, sketches_by_id=sketches_by_id, previous=previous, - allow_cap_output_role=not second, - allow_direct_prism_swept_lineage=not second, - featurescript_version=model.featurescript_version, - ) + # A two-sided face extent is one paired provenance + # request. Defer resolving either side until the reverse + # query is available below, where the exact CAP/SWEPT + # pair contracts can prove both members together. + if not second: + end["reference"] = _extent_reference( + p.get("endBoundEntityFace"), "face", feature_frames, sketch_by_source, entity_by_sketch, + feature_by_id=feature_by_id, sketches_by_id=sketches_by_id, previous=previous, + allow_cap_output_role=True, + allow_primary_add_up_to_surface=True, + allow_direct_prism_swept_lineage=True, + featurescript_version=model.featurescript_version, + ) elif end["type"] == "up_to_vertex": - end["reference"] = _intersection_vertex_reference( - p.get("endBoundEntityVertex"), feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, item.feature_id, + end["reference"] = ( + _direct_source_vertex_extent_reference( + p.get("endBoundEntityVertex"), sketch_by_source, entity_by_sketch, + featurescript_version=model.featurescript_version, + ) + or _direct_prism_cap_vertex_extent_selector( + p.get("endBoundEntityVertex"), feature_by_id=feature_by_id, + feature_frames=feature_frames, sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, entity_by_sketch=entity_by_sketch, + previous=previous, featurescript_version=model.featurescript_version, + ) + or _intersection_vertex_reference( + p.get("endBoundEntityVertex"), feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, item.feature_id, + ) ) if _bool(p.get("hasOffset")): offset = _number(p.get("offsetDistance"), True) @@ -5541,16 +9979,64 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: reverse_end = _end_condition(p.get("secondDirectionBound")) if reverse_end["type"] == "up_to_body": reverse_end["reference"] = _extent_reference(p.get("secondDirectionBoundEntityBody"), "body", feature_frames, sketch_by_source, entity_by_sketch) + elif reverse_end["type"] == "up_to_vertex": + reverse_end["reference"] = ( + _direct_source_vertex_extent_reference( + p.get("secondDirectionBoundEntityVertex"), sketch_by_source, entity_by_sketch, + featurescript_version=model.featurescript_version, + ) + or _direct_prism_cap_vertex_extent_selector( + p.get("secondDirectionBoundEntityVertex"), feature_by_id=feature_by_id, + feature_frames=feature_frames, sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, entity_by_sketch=entity_by_sketch, + previous=previous, featurescript_version=model.featurescript_version, + ) + or _intersection_vertex_reference( + p.get("secondDirectionBoundEntityVertex"), feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, item.feature_id, + ) + ) + params.update({"reverse_distance_mm": reverse_depth, "reverse_end_condition": reverse_end}) + if end["type"] == "up_to_surface" and reverse_end["type"] == "up_to_surface": + cap_pair = _two_sided_up_to_surface_cap_pair( + p.get("endBoundEntityFace"), p.get("secondDirectionBoundEntityFace"), + feature_by_id=feature_by_id, sketches_by_id=sketches_by_id, + source_sketches=sketch_by_source, previous=previous, + featurescript_version=model.featurescript_version, + ) + if cap_pair is not None: + end["reference"], reverse_end["reference"] = cap_pair + else: + swept_pair = _two_sided_up_to_surface_shell_swept_face_pair( + p.get("endBoundEntityFace"), p.get("secondDirectionBoundEntityFace"), + feature_by_id=feature_by_id, feature_frames=feature_frames, + sketch_by_source=sketch_by_source, sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, previous=previous, + featurescript_version=model.featurescript_version, + ) + if swept_pair is not None: + end["reference"], reverse_end["reference"] = swept_pair + else: + end["reference"] = _extent_reference( + p.get("endBoundEntityFace"), "face", feature_frames, sketch_by_source, entity_by_sketch, + feature_by_id=feature_by_id, sketches_by_id=sketches_by_id, previous=previous, + featurescript_version=model.featurescript_version, + ) + reverse_end["reference"] = _extent_reference( + p.get("secondDirectionBoundEntityFace"), "face", feature_frames, sketch_by_source, entity_by_sketch, + feature_by_id=feature_by_id, sketches_by_id=sketches_by_id, previous=previous, + featurescript_version=model.featurescript_version, + ) elif reverse_end["type"] == "up_to_surface": + end["reference"] = _extent_reference( + p.get("endBoundEntityFace"), "face", feature_frames, sketch_by_source, entity_by_sketch, + feature_by_id=feature_by_id, sketches_by_id=sketches_by_id, previous=previous, + featurescript_version=model.featurescript_version, + ) reverse_end["reference"] = _extent_reference( p.get("secondDirectionBoundEntityFace"), "face", feature_frames, sketch_by_source, entity_by_sketch, feature_by_id=feature_by_id, sketches_by_id=sketches_by_id, previous=previous, + featurescript_version=model.featurescript_version, ) - elif reverse_end["type"] == "up_to_vertex": - reverse_end["reference"] = _intersection_vertex_reference( - p.get("secondDirectionBoundEntityVertex"), feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, item.feature_id, - ) - params.update({"reverse_distance_mm": reverse_depth, "reverse_end_condition": reverse_end}) elif end["type"] == "mid_plane": blind = _end_condition("BLIND") atomic = "extrude_cut_two_sided" if cutting else "extrude_add_two_sided" @@ -5558,6 +10044,12 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: else: atomic = "extrude_cut_blind" if cutting else "extrude_add_blind" params = {"distance_mm": depth, "reverse": reverse, "end_condition": end} + if pure_surface_operation: + if second or end["type"] not in {"blind", "mid_plane"}: + raise UnsupportedCapability( + "extrude_surface_extent", + "pure ToolBodyType.SURFACE extrusion supports blind and symmetric extents only", + ) if _bool(p.get("hasDraft")): if second or end["type"] != "blind": raise UnsupportedCapability("extrude_draft_extent", "current CDSL draft supports only one-sided blind extrusions") @@ -5570,7 +10062,8 @@ 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" - if cap_face_output_selector is not None: + derived_face_selector = cap_face_output_selector or retained_offset_cap_selector + if derived_face_selector is not None: params["operation"] = "cut" if cutting else "add" if second or end["type"] == "mid_plane": params["two_sided"] = True @@ -5580,15 +10073,21 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: "atomic_id": "extrude_from_face", "depends_on": depends, "params": params, - "selectors": [cap_face_output_selector], + "selectors": [derived_face_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 pure_surface_operation: + surface_params = {"distance_mm": params["distance_mm"], "reverse": bool(params.get("reverse"))} + if "reverse_distance_mm" in params: + surface_params["reverse_distance_mm"] = params["reverse_distance_mm"] + feature = {"id": fid, "name": item.feature_id, "atomic_id": "extrude_surface", "depends_on": depends, "sketch_id": profile_sketch["id"], "params": surface_params, "execution_status": "supported"} + else: + 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 surface_profile_sketch is not None and not pure_surface_operation: if end["type"] not in {"blind", "mid_plane"}: raise UnsupportedCapability("extrude_surface_extent", "current CDSL surface extrude supports blind and symmetric extents only") surface_params = {"distance_mm": params["distance_mm"], "reverse": bool(params.get("reverse"))} @@ -5603,7 +10102,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: "params": surface_params, "execution_status": "supported", } - 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"] + plane = _query_plane(profile_value, feature_frames, sketch_by_source, entity_by_sketch) if derived_face_selector is not None else profile_sketch["workplane"] if end["type"] == "blind" and not second: direction = -1 if reverse else 1 # FeatureScript 的 CAP_FACE 是实体端盖,而不是原草图平面。 @@ -5642,6 +10141,28 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: "profile": dict(plane), "profile_source": source, } + # This source-only datum contract is intentionally narrower + # than the runtime SWEPT_EDGE selector: a LINE_ANGLE axis can + # be computed from the original profile vertex and the prism + # span only while the whole profile was retained unchanged. + frame = feature_frames.get(item.feature_id) + if ( + atomic == "extrude_add_blind" + and params.get("result_mode") == "new_body" + and end["type"] == "blind" + and not second + and params.get("draft") is None + and isinstance(source, str) + and profile_sketch is not None + and source in sketch_by_source + and profile_sketch.get("source_sketch_id") == source + and _profile_matches_direct_source(profile_sketch, sketch_by_source[source]) + and isinstance(frame, dict) + ): + source_entity_ids = _direct_profile_source_entity_ids(profile_sketch) + if source_entity_ids: + frame["direct_prism_line_angle_axis"] = True + frame["direct_prism_source_entity_ids"] = sorted(source_entity_ids) elif item.operation == "loft": cap_face_loft = _loft_cap_face_profile(p, sketch_by_source, entity_by_sketch, feature_frames, fid) if cap_face_loft is not None: @@ -5660,24 +10181,84 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: } frames = _loft_cap_frames(cap_plane, profile_sketch["workplane"]) else: - sources = _loft_profile_sketches(p) - missing = [source for source in sources if source not in sketch_by_source] - if missing: - raise ValueError("loft profile sketches are unresolved: " + ", ".join(missing)) - non_closed = [source for source in sources if not _profile_executable(sketch_by_source[source])] - if non_closed: - raise ValueError("loft profile sketches have no closed profile: " + ", ".join(non_closed)) - feature = { - "id": fid, - "name": item.feature_id, - "atomic_id": "loft_add", - "depends_on": depends, - "params": {"profile_sketch_ids": [sketch_by_source[source]["id"] for source in sources]}, - "execution_status": "supported", - } - frames = _loft_cap_frames( - sketch_by_source[sources[0]]["workplane"], sketch_by_source[sources[-1]]["workplane"], + wire_profiles = p.get("wireProfilesArray") + body_type = str(p.get("bodyType") or "").rsplit(".", 1)[-1].upper() + is_surface_wire_loft = body_type == "SURFACE" and isinstance(wire_profiles, list) + surface_loft = ( + is_surface_wire_loft + and len(wire_profiles) == 2 + and not p.get("sheetProfilesArray") + and not p.get("guidesArray") + and p.get("spine") is None + and str(p.get("operationType") or "NEW").rsplit(".", 1)[-1].upper() == "NEW" + and all(p.get(key) in (None, False, [], {}) for key in ( + "connections", "matchConnections", "startCondition", "endCondition", + "startMagnitude", "endMagnitude", + )) ) + if surface_loft: + profiles = [ + _direct_closed_sketch_wire_profile( + profile.get("wireProfileEntities") if isinstance(profile, dict) else None, + sketch_by_source, entity_by_sketch, + featurescript_version=model.featurescript_version, + standard_library=model.standard_library, + standard_library_version=model.standard_library_version, + feature_id=f"{fid}_{index}", + ) + for index, profile in enumerate(wire_profiles) + ] + if any(profile is None for profile in profiles): + raise UnsupportedCapability( + "loft_surface_wire_profiles", + "current CDSL surface loft requires two direct closed source-wire profiles", + ) + profile_sketches = [profile for profile in profiles if profile is not None] + if len({profile["source_sketch_id"] for profile in profile_sketches}) != len(profile_sketches): + raise UnsupportedCapability( + "loft_surface_wire_profiles", + "current CDSL surface loft requires profiles from distinct source sketches", + ) + for profile in profile_sketches: + sketches.append(profile); sketches_by_id[profile["id"]] = profile + feature = { + "id": fid, + "name": item.feature_id, + "atomic_id": "loft_surface", + "depends_on": depends, + "params": {"profile_sketch_ids": [profile["id"] for profile in profile_sketches]}, + "execution_status": "supported", + } + frames = None + elif is_surface_wire_loft: + raise UnsupportedCapability( + "loft_surface_wire_profiles", + "current CDSL surface loft requires two direct closed source-wire profiles", + ) + else: + sources = _loft_profile_sketches(p) + missing = [source for source in sources if source not in sketch_by_source] + if missing: + raise ValueError("loft profile sketches are unresolved: " + ", ".join(missing)) + non_closed = [source for source in sources if not _profile_executable(sketch_by_source[source])] + if non_closed: + raise ValueError("loft profile sketches have no closed profile: " + ", ".join(non_closed)) + feature = { + "id": fid, + "name": item.feature_id, + "atomic_id": "loft_add", + "depends_on": depends, + "params": {"profile_sketch_ids": [sketch_by_source[source]["id"] for source in sources]}, + "execution_status": "supported", + } + if _initial_direct_loft_cap_output_roles(p, sources, features, model.featurescript_version): + feature["params"].update({ + "initial_output_roles": True, + "cap_output_profile_sources": list(sources), + }) + frames = _loft_cap_frames( + sketch_by_source[sources[0]]["workplane"], sketch_by_source[sources[-1]]["workplane"], + ) if frames is not None: # This lowering-only provenance permits an exact source # endpoint pair for a two-section direct loft. It is not @@ -5700,7 +10281,43 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: path_source = path_query.source_sketch path_entity = (entity_by_sketch.get(path_source or "") or {}).get(path_query.source_entity or "") path_sketch = sketch_by_source.get(path_source or "") - if path_entity is None or path_sketch is None: + # A raw arc/circle leaf must be named by exactly one source + # query: the query parser retains a representative qUnion + # leaf, and accepting it would discard the other curves. A + # single original curve selected by the versioned source-wire + # contract is different: its complete source set is already + # proven to contain precisely that one line/arc/B-spline. + direct_single_path_source = path_query.calls.count("sQuery") == 1 + path: dict[str, Any] | None = None + # A multi-leaf query is a source wire request, never an + # authorization to use the parser's representative leaf. + # Resolve the complete same-sketch or spatial wire before + # considering the singleton segment path. In particular, + # parse_query retains the last leaf of qUnion for context; + # treating that arc or line as the path would discard every + # preceding curve in the FeatureScript query. + if not direct_single_path_source: + segmented_wire_path = _direct_segmented_sketch_wire_path( + p.get("path"), sketch_by_source, entity_by_sketch, + featurescript_version=model.featurescript_version, + standard_library=model.standard_library, + standard_library_version=model.standard_library_version, + ) + if segmented_wire_path is not None: + path_source, path_segments, path_sketch = segmented_wire_path + path = {"workplane": path_sketch["workplane"], "segments": path_segments} + else: + spatial_segments = _direct_spatial_segmented_sketch_wire_path( + p.get("path"), sketch_by_source, entity_by_sketch, + featurescript_version=model.featurescript_version, + standard_library=model.standard_library, + standard_library_version=model.standard_library_version, + ) + if spatial_segments is not None: + path = {"segments": spatial_segments} + # Do not fall back to the representative direct leaf. + path_entity = path_sketch = None + if path is None and (path_entity is None or path_sketch is None): direct_wire_path = _direct_sketch_wire_path( p.get("path"), sketch_by_source, entity_by_sketch, featurescript_version=model.featurescript_version, @@ -5709,33 +10326,138 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: ) if direct_wire_path is not None: path_source, _path_entity_id, path_entity, path_sketch = direct_wire_path + elif not direct_single_path_source: + # Unlike a raw direct circle leaf, a qBodyType source + # wire has no parser entity that may stand in for the + # query result. Admit it only after this helper proves + # its complete selected source set is exactly one + # original circle. + circle_wire_path = _direct_sketch_circle_wire_path( + p.get("path"), sketch_by_source, entity_by_sketch, + featurescript_version=model.featurescript_version, + standard_library=model.standard_library, + standard_library_version=model.standard_library_version, + ) + if circle_wire_path is not None: + path_source, _path_entity_id, path_entity, path_sketch = circle_wire_path + elif direct_single_path_source: + segmented_wire_path = _direct_segmented_sketch_wire_path( + p.get("path"), sketch_by_source, entity_by_sketch, + featurescript_version=model.featurescript_version, + standard_library=model.standard_library, + standard_library_version=model.standard_library_version, + ) + if segmented_wire_path is not None: + path_source, path_segments, path_sketch = segmented_wire_path + path = {"workplane": path_sketch["workplane"], "segments": path_segments} + else: + spatial_segments = _direct_spatial_segmented_sketch_wire_path( + p.get("path"), sketch_by_source, entity_by_sketch, + featurescript_version=model.featurescript_version, + standard_library=model.standard_library, + standard_library_version=model.standard_library_version, + ) + if spatial_segments is not None: + path = {"segments": spatial_segments} if path_entity is None or path_sketch is None: - raise UnsupportedCapability( - "sweep_path_query", - "current CDSL sweep requires one direct sketch line or B-spline path", - ) - if path_entity.get("type") not in {"line", "bspline"}: - raise UnsupportedCapability("sweep_path", "current CDSL sweep requires one line or B-spline path") + if path is None: + raise UnsupportedCapability( + "sweep_path_query", + "current CDSL sweep requires one direct source sketch line/arc/B-spline, a connected source-sketch wire, or a connected union of source wires", + ) + if path is None and path_entity.get("type") not in {"line", "arc", "circle", "bspline"}: + raise UnsupportedCapability("sweep_path", "current CDSL sweep requires one line, arc, circle, or B-spline path") operation = str(p.get("operationType") or "NEW").upper() - if any(value in operation for value in ("REMOVE", "CUT")): - raise UnsupportedCapability("sweep_remove", "current CDSL sweep supports additive solid results only") - path_segment = deepcopy(path_entity) - path = {"workplane": path_sketch["workplane"], "segment": path_segment} - if _sweep_profile_attaches_at_path_end(profile_sketch, path): - path_segment = _reversed_sweep_path_segment(path_segment) + is_cut_operation = any(value in operation for value in ("REMOVE", "CUT")) + if "INTERSECT" in operation: + raise UnsupportedCapability("sweep_intersect", "current CDSL sweep does not yet support intersection body semantics") + if path is None: + path = {"workplane": path_sketch["workplane"], "segment": deepcopy(path_entity)} + path_reversed = _sweep_profile_attaches_at_path_end(profile_sketch, path) + if path_reversed: + path = _reversed_sweep_path(path) feature = { "id": fid, "name": item.feature_id, - "atomic_id": "sweep_add", + "atomic_id": "sweep_cut" if is_cut_operation else "sweep_add", "depends_on": depends, "sketch_id": profile_sketch["id"], - "params": {"path": {"workplane": path_sketch["workplane"], "segment": path_segment}}, + "params": {"path": path}, "execution_status": "supported", } if _is_new_body_operation(operation): feature["params"]["result_mode"] = "new_body" frames = _sweep_cap_frames(profile_sketch, feature["params"]["path"]) if frames is not None: feature_frames[item.feature_id] = frames + # PipeShell FirstShape/LastShape can prove two physical cap + # faces only for one direct profile and one direct source + # path. Preserve that singleton pair for CAP_FACE. Its + # Generated(profile_edge) history is distinct and can retain + # a complete direct analytic contour for SWEPT_FACE. + path_segment = feature["params"]["path"].get("segment") or {} + closed_circle_path = path_segment.get("type") == "circle" + profile_shape = profile_sketch.get("profile") or {} + if ( + feature["params"].get("result_mode") == "new_body" + and profile_sketch.get("source_sketch_id") == profile_source + and profile_shape.get("source_entity_id") + and isinstance(path_segment.get("source_entity_id"), str) + and path_segment["source_entity_id"] + and direct_single_path_source + and isinstance(feature["params"]["path"].get("workplane"), dict) + and not feature["params"]["path"].get("segments") + and not closed_circle_path + and frames is not None + ): + feature["params"]["initial_output_roles"] = True + feature["params"]["cap_output_contract"] = { + "profile_source": profile_source, + "profile_entity": str(profile_shape["source_entity_id"]), + "path_source": path_source, + "path_entity": path_segment["source_entity_id"], + "path_reversed": path_reversed, + } + direct_profile_entities = _direct_sweep_profile_entities(profile_sketch) + if ( + feature["params"].get("result_mode") == "new_body" + and profile_sketch.get("source_sketch_id") == profile_source + and direct_profile_entities is not None + and isinstance(path_segment.get("source_entity_id"), str) + and path_segment["source_entity_id"] + and direct_single_path_source + and isinstance(feature["params"]["path"].get("workplane"), dict) + and not feature["params"]["path"].get("segments") + and not closed_circle_path + ): + feature["params"]["initial_output_roles"] = True + feature["params"]["swept_face_contract"] = { + "profile_source": profile_source, + "profile_entities": direct_profile_entities, + "path_source": path_source, + "path_entity": path_segment["source_entity_id"], + "path_reversed": path_reversed, + } + if ( + feature["params"].get("result_mode") == "new_body" + and profile_source != path_source + and profile_sketch.get("source_sketch_id") == profile_source + and direct_profile_entities is not None + and len(direct_profile_entities) >= 2 + and isinstance(path_segment.get("source_entity_id"), str) + and path_segment["source_entity_id"] + and direct_single_path_source + and isinstance(feature["params"]["path"].get("workplane"), dict) + and not feature["params"]["path"].get("segments") + and not closed_circle_path + ): + feature["params"]["initial_output_roles"] = True + feature["params"]["swept_edge_contract"] = { + "profile_source": profile_source, + "profile_entities": direct_profile_entities, + "path_source": path_source, + "path_entity": path_segment["source_entity_id"], + "path_reversed": path_reversed, + } elif item.operation == "booleanBodies": operation = str(p.get("operationType") or "").split(".")[-1].upper() operation_map = { @@ -5750,45 +10472,68 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: # 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 = ( + targets, target_instance_refs, target_transform_copy_refs = ( _boolean_body_references( p.get("targets"), previous, feature_by_id, body_transform_aliases, ) - if has_targets else ([], []) + if has_targets else ([], [], []) ) - tools, tool_instance_refs = _boolean_body_references( + tools, tool_instance_refs, tool_transform_copy_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")): + # A targetless FeatureScript boolean denotes an ordered + # set of body operands. For UNION and INTERSECTION, the + # first explicit source member can be made CDSL's left + # operand and every following member its right set. This + # preserves the operation while keeping the source body + # set explicit; SUBTRACTION has no such source-proven + # targetless ordering contract. + if operation not in {"UNION", "INTERSECTION"}: raise UnsupportedCapability( "boolean_bodies_targets", - "targetless booleanBodies is currently supported only for UNION with keepTools:false", + "targetless booleanBodies currently supports only UNION or INTERSECTION", ) - selections = [("feature", value) for value in tools] + [("pattern", value) for value in tool_instance_refs] + selections = _ordered_boolean_body_references( + p.get("tools"), previous, feature_by_id, body_transform_aliases, + ) 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"] + raise ValueError("targetless booleanBodies requires at least two explicit bodies") + # FeatureScript supplies a set here, not a directional + # target/tool pair. Preserve the existing deterministic + # lifecycle policy: select an explicit direct member when + # the set has one; otherwise choose its first qualified + # COPY member. UNION/INTERSECTION are commutative, so the + # choice does not alter their shape, while it avoids + # rewriting a direct source as a pattern aggregate. + target_index = next( + (index for index, (kind, _value) in enumerate(selections) if kind == "feature"), + 0, + ) + target_kind, target = selections.pop(target_index) + right_operands = selections + targets = [target] if target_kind == "feature" else [] + target_instance_refs = [target] if target_kind == "pattern" else [] + target_transform_copy_refs = [target] if target_kind == "transform_copy" else [] + tools = [value for kind, value in right_operands if kind == "feature"] + tool_instance_refs = [value for kind, value in right_operands if kind == "pattern"] + tool_transform_copy_refs = [value for kind, value in right_operands if kind == "transform_copy"] + targetless_body_set = True + else: + targetless_body_set = False 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 + } | { + ("transform_copy", reference["transform_feature_id"], reference["source_feature_id"]) + for reference in target_transform_copy_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 + } | { + ("transform_copy", reference["transform_feature_id"], reference["source_feature_id"]) + for reference in tool_transform_copy_refs } if target_keys & tool_keys: raise ValueError("booleanBodies targets and tools must be disjoint") @@ -5799,17 +10544,23 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: *(reference["pattern_feature_id"] for reference in target_instance_refs), *(reference["pattern_feature_id"] for reference in tool_instance_refs), ])) + transform_copy_dependencies = list(dict.fromkeys([ + *(reference["transform_feature_id"] for reference in target_transform_copy_refs), + *(reference["transform_feature_id"] for reference in tool_transform_copy_refs), + ])) feature = { "id": fid, "name": item.feature_id, "atomic_id": "boolean_bodies", - "depends_on": list(dict.fromkeys(targets + tools + pattern_dependencies + depends)), + "depends_on": list(dict.fromkeys(targets + tools + pattern_dependencies + transform_copy_dependencies + depends)), "params": { "operation": operation_map[operation], "keep_tools": _bool(p.get("keepTools")), }, "execution_status": "supported", } + if targetless_body_set: + feature["params"]["targetless_body_set"] = True if targets: feature["params"]["target_feature_ids"] = targets if tools: @@ -5818,6 +10569,10 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: feature["params"]["target_pattern_instance_refs"] = target_instance_refs if tool_instance_refs: feature["params"]["tool_pattern_instance_refs"] = tool_instance_refs + if target_transform_copy_refs: + feature["params"]["target_transform_copy_refs"] = target_transform_copy_refs + if tool_transform_copy_refs: + feature["params"]["tool_transform_copy_refs"] = tool_transform_copy_refs elif item.operation == "revolve": # surfaceOperationType alone does not make a body operation a # surface operation. CADFS emits it for closed sketch regions @@ -5825,6 +10580,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: # selects a sheet result; its profile is in surfaceEntities. surface_operation = str(p.get("bodyType") or "").rsplit(".", 1)[-1].upper() == "SURFACE" profile_entities = p.get("surfaceEntities") if surface_operation else p.get("entities") + profile_kind = parse_query(profile_entities).topology_type source = _source_sketch(p) if not source or source not in sketch_by_source: raise ValueError("revolve sketch query is unresolved") profile_sketch = _profile_selection_sketch(sketch_by_source[source], profile_entities, entity_by_sketch[source], fid) @@ -5866,6 +10622,16 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: and axis_direct ): frame["profile_source"] = source + # Keep the FeatureScript profile provenance separate + # from geometry equality. Some 1511 IMPRINT queries + # lower to identical contours but are not the original + # sketch topology; 2491 has explicit evidence for one + # complete unchanged materialization. + frame["revolve_profile_contract"] = ( + "verified_complete_materialization" + if profile_kind == "IMPRINT" + else "original_source" + ) feature_frames[item.feature_id] = frame elif item.operation in {"fillet", "chamfer"}: key = "radius" if item.operation == "fillet" else "width" @@ -5873,7 +10639,24 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: amount_value = p.get(key) if item.operation == "chamfer" and chamfer_type == "TWO_OFFSETS": amount_value = p.get("width1") amount = _number(amount_value, True); selectors = [] - for index, query_value in enumerate(_queries(p.get("entities"))): + source_set = _direct_query_set_operands(p.get("entities")) + query_values = _query_set_leaf_values(p.get("entities")) if source_set is not None else _queries(p.get("entities")) + offset_edge_forms: set[str] = set() + for query_value in query_values: + try: + _call, _owner, topology, _kind, definition = _direct_make_query(query_value) + except ValueError: + continue + if topology != "OFFSET_EDGE": + continue + disambiguation = definition.get("disambiguationData") + if isinstance(disambiguation, list): + offset_edge_forms.add( + "tdd" if any(isinstance(item, Call) and item.name == "TDD" for item in disambiguation) + else "osd" + ) + mixed_offset_edge_forms = len(offset_edge_forms) > 1 + for index, query_value in enumerate(query_values): query = parse_query(query_value) # ``parse_query`` retains nested source metadata for # diagnostics, but its recursive walk ends on an inner @@ -5888,7 +10671,70 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: outer_kind = query.kind owner = outer_owner if not owner: raise ValueError("selector owner is unresolved") + # A deferred source query still describes a CADFS result, + # but it cannot bind to a producer omitted from the CDSL + # prefix. Keeping that missing owner in a supported + # dress-up would create semantically invalid CDSL and + # falsely move this lowering failure into validation. + if f"f_{owner}" not in feature_by_id: + raise UnsupportedCapability( + "selector_owner_unavailable", + f"selector owner {owner} has no executable CDSL producer", + ) selector_kind = "face" if outer_kind in {"face", "entitytype.face"} else "edge" + if outer_topology == "BLEND_EDGE": + blend_selector = _direct_blend_edge_selector( + query_value, + owner=owner, + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=model.featurescript_version, + ) + if blend_selector is not None: + selectors.append(blend_selector) + continue + selectors.append({ + "kind": selector_kind, + "owner_feature_id": f"f_{owner}", + "stable_id": f"cadfs_{fid}_{index}", + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": _deferred_featurescript_selector_intent( + query_value, kind=selector_kind, + ), + }) + continue + if outer_topology == "OFFSET_EDGE": + offset_edge_selector = _direct_prism_shell_offset_edge_tdd_selector( + query_value, + owner=owner, + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=model.featurescript_version, + ) + if offset_edge_selector is None and not mixed_offset_edge_forms: + offset_edge_selector = _direct_prism_shell_offset_edge_vertex_selector( + query_value, + owner=owner, + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=model.featurescript_version, + ) + if offset_edge_selector is not None: + selectors.append(offset_edge_selector) + continue if outer_topology == "INTERSECT": intersection_selector = _direct_boolean_intersection_selector( query_value, @@ -5924,7 +10770,98 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: ), }) continue + if outer_topology == "COPY": + copy_selector = _direct_primary_cut_copy_cap_edge_selector( + query_value, + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=model.featurescript_version, + ) + if copy_selector is not None: + selectors.append(copy_selector) + continue + if outer_topology == "CAP_FACE": + # A primary ADD has only a transient prism tool. Its + # cap may drive the immediately following dress-up + # only when runtime proves the exact one-to-one + # extrude-to-union successor in the active member. + cap_selector = _cap_face_output_role_selector( + query_value, + feature_by_id, + sketches_by_id, + allow_primary_add_dressup=True, + ) + if ( + cap_selector is not None + and previous[-1:] == [cap_selector["owner_feature_id"]] + ): + selectors.append(cap_selector) + continue + if outer_topology in {"CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE"}: + imprint_selector = _planar_imprint_prism_selector( + query_value, + owner=owner, + selector_kind=selector_kind, + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketches_by_id=sketches_by_id, + previous=previous, + featurescript_version=model.featurescript_version, + ) + if imprint_selector is not None: + selectors.append(imprint_selector) + continue if outer_topology in {"CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE"}: + sweep_cap_edge_selector = _initial_direct_sweep_cap_edge_selector( + query_value, feature_by_id, sketches_by_id, previous, + ) if outer_topology == "CAP_EDGE" else None + if sweep_cap_edge_selector is not None: + selectors.append(sweep_cap_edge_selector) + continue + sweep_swept_face_selector = _initial_direct_sweep_swept_face_selector( + query_value, feature_by_id, sketches_by_id, previous, + ) if outer_topology == "SWEPT_FACE" else None + if sweep_swept_face_selector is not None: + selectors.append(sweep_swept_face_selector) + continue + sweep_swept_edge_selector = _initial_direct_sweep_swept_edge_selector( + query_value, feature_by_id, sketches_by_id, previous, + ) if outer_topology == "SWEPT_EDGE" else None + if sweep_swept_edge_selector is not None: + selectors.append(sweep_swept_edge_selector) + continue + # A direct PipeShell producer with an explicit source + # contract must not fall through to the legacy + # cylinder geometry hint when its profile/path pair is + # incomplete or contradictory. Preserve the source + # query for diagnostics and let runtime reject it. + sweep_producer = feature_by_id.get(f"f_{owner}") or {} + sweep_params = sweep_producer.get("params") or {} + if ( + outer_topology in {"SWEPT_FACE", "SWEPT_EDGE"} + and sweep_producer.get("atomic_id") == "sweep_add" + and sweep_params.get("initial_output_roles") is True + and isinstance( + sweep_params.get( + "swept_face_contract" if outer_topology == "SWEPT_FACE" else "swept_edge_contract" + ), + dict, + ) + ): + selectors.append({ + "kind": selector_kind, + "owner_feature_id": f"f_{owner}", + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": _deferred_featurescript_selector_intent( + query_value, kind=selector_kind, + ), + }) + continue lineage_selector = _direct_prism_swept_selector( query_value, owner=owner, @@ -5936,11 +10873,27 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: entity_by_sketch=entity_by_sketch, previous=previous, featurescript_version=model.featurescript_version, - allow_continuation=outer_topology == "CAP_EDGE", + allow_continuation=outer_topology in {"CAP_EDGE", "SWEPT_EDGE"}, ) if lineage_selector is not None: selectors.append(lineage_selector) continue + if outer_topology == "SWEPT_EDGE": + revolve_selector = _direct_full_revolve_swept_edge_selector( + query_value, + owner=owner, + selector_kind=selector_kind, + feature_by_id=feature_by_id, + feature_frames=feature_frames, + sketch_by_source=sketch_by_source, + sketches_by_id=sketches_by_id, + entity_by_sketch=entity_by_sketch, + previous=previous, + featurescript_version=model.featurescript_version, + ) + if revolve_selector is not None: + selectors.append(revolve_selector) + continue if _uses_planar_imprint_profile(owner, feature_by_id, sketches_by_id): selectors.append({ "kind": selector_kind, @@ -6003,6 +10956,14 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: if selector_kind == "face" and not geometry: raise ValueError(f"{query.topology_type or 'face'} selector geometry is unresolved") 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, "selector_intent": _deferred_featurescript_selector_intent(query_value, kind=selector_kind)}) + # Dress-up consumers must retain the source set expression, + # including a direct qUnion, because its ordered child + # provenance is what lets the runtime apply exact set + # algebra. (The shell consumer below intentionally keeps + # its legacy flat direct-union contract.) + query_set = _proven_query_set_selector(p.get("entities"), selectors) if source_set is not None else None + if query_set is not None: + selectors = [query_set] params = {"radius_mm" if item.operation == "fillet" else "distance_mm": amount} if item.operation == "fillet": params["tangent_propagation"] = _bool(p.get("tangentPropagation")) elif _bool(p.get("tangentPropagation")): @@ -6021,7 +10982,13 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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"))): + source_set = _direct_query_set_operands(p.get("entities")) + query_values = ( + _query_set_leaf_values(p.get("entities")) + if source_set is not None + else _queries(p.get("entities")) + ) + for index, query_value in enumerate(query_values): query = parse_query(query_value) _call, _owner, topology, kind, _definition = _direct_make_query(query_value) if kind not in {"face", "entitytype.face"}: @@ -6034,8 +11001,17 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: # retain their deferred source query and cannot pick a # geometrically similar face here. selector = _cap_face_output_role_selector( - query_value, feature_by_id, sketches_by_id, + query_value, + feature_by_id, + sketches_by_id, + allow_initial_loft=True, + allow_two_sided_circle_shell=True, + allow_primary_add_shell=True, ) + if selector is None: + selector = _initial_direct_sweep_cap_output_role_selector( + query_value, feature_by_id, sketches_by_id, + ) if selector is None or previous[-1:] != [selector["owner_feature_id"]]: selector = _face_reference(query_value, feature_frames, sketch_by_source, entity_by_sketch) if query.owner_feature and query.is_start is not None: @@ -6066,6 +11042,21 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: if selector.get("output_role") is None and not isinstance(selector.get("selector_intent"), dict): selector["stable_id"] = f"cadfs_{fid}_{index}" selectors.append(selector) + # A direct qUnion is already represented by the ordered flat + # removal-face list expected by the established shell + # contract. Preserve a QUERY_SET parent only for set + # operators whose intersection/subtraction or nested union + # semantics cannot be represented by that list. + needs_query_set_parent = bool( + source_set is not None + and ( + source_set[1] != "union" + or any(_direct_query_set_operands(item) is not None for item in source_set[2]) + ) + ) + query_set = _proven_query_set_selector(p.get("entities"), selectors) if needs_query_set_parent else None + if query_set is not None: + selectors = [query_set] if not selectors: raise ValueError("shell has no face removal selector") # CADFS's oppositeDirection selects the exterior material @@ -6091,7 +11082,17 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: "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} + def selector_owners(selector: dict[str, Any]) -> set[str]: + owners: set[str] = set() + owner = selector.get("owner_feature_id") + if isinstance(owner, str) and owner: + owners.add(owner.removeprefix("f_")) + for child in selector.get("query_operands") or (): + if isinstance(child, dict): + owners.update(selector_owners(child)) + return owners + + owners = set().union(*(selector_owners(selector) for selector in selectors)) if len(owners) == 1: source = next(iter(owners)) source_feature = feature_by_id.get(f"f_{source}") or {} @@ -6119,8 +11120,23 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: frame = feature_frames.setdefault(item.feature_id, {}) frame["shell_offset_edge_planes"] = offset_edge_planes elif item.operation == "hole": - locations = _queries(p.get("locations")); positions = []; host_plane = None + locations = _queries(p.get("locations")); positions = []; host_plane = None; host_attachment = None for location in locations: + location_query = parse_query(location) + # A direct source vertex only has a usable local position + # once its source sketch has an executable, explicit frame. + # Do not label an unresolved host workplane as a malformed + # vertex selector: it is an upstream sketch dependency. + if ( + _is_direct_hole_location_query(location) + and + isinstance(location_query.source_sketch, str) + and location_query.source_sketch not in sketch_by_source + ): + raise UnsupportedCapability( + "hole_location_sketch_unavailable", + "hole location source sketch is not executable", + ) resolved_location = _direct_hole_location(location, sketch_by_source, entity_by_sketch) if resolved_location is None: raise UnsupportedCapability( @@ -6135,9 +11151,21 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: ) positions.append({"mm": position}) host_plane = location_plane + attachment = ( + sketch_by_source.get(location_query.source_sketch or "", {}).get("attachment") + if isinstance(location_query.source_sketch, str) else None + ) + if attachment is not None and not isinstance(attachment, dict): + raise UnsupportedCapability("hole_location_attachment", "hole location sketch attachment is invalid") + if host_attachment is not None and attachment != host_attachment: + raise UnsupportedCapability("hole_location_attachment", "hole locations must share one runtime attachment") + host_attachment = attachment if not positions or host_plane is None: raise ValueError("hole has no resolved locations") frame = {**host_plane, "y_dir": _y_dir(host_plane)} - if _bool(p.get("oppositeDirection")): frame = {**frame, "normal": [-v for v in frame["normal"]]} + if _bool(p.get("oppositeDirection")): + if host_attachment is not None: + raise UnsupportedCapability("hole_location_attachment", "attached hole workplane does not yet support oppositeDirection") + frame = {**frame, "normal": [-v for v in frame["normal"]]} style = str(p.get("style") or "SIMPLE").split(".")[-1].lower(); end = str(p.get("endStyle") or "BLIND").upper() standard_through_diameter = _standard_tapped_through_bore_diameter(p, style, end) condition = "through_all_both" if "BOTH" in end else "through_all" if "THROUGH" in end or standard_through_diameter is not None else "blind" @@ -6145,7 +11173,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: if condition == "blind" and depth_value is None: raise ValueError("blind hole depth is unresolved") depth = _number(depth_value, True) if depth_value is not None else 1.0 condition_code = {"blind": 0, "through_all": 1, "through_all_both": 2}[condition] - hole_params: dict[str, Any] = {"hole_type": style, "diameter_mm": standard_through_diameter or _number(p.get("holeDiameter"), True), "depth_mm": depth, "end_condition": {"type": condition, "solidworks_code": condition_code}, "positions": positions, "host_face": {"frame": frame}} + hole_params: dict[str, Any] = {"hole_type": style, "diameter_mm": standard_through_diameter or _number(p.get("holeDiameter"), True), "depth_mm": depth, "end_condition": {"type": condition, "solidworks_code": condition_code}, "positions": positions, "host_face": host_attachment if host_attachment is not None else {"frame": frame}} if style.upper() in {"COUNTERSINK", "C_SINK"}: hole_params["countersink"] = {"diameter_mm": _number(p.get("countersinkDiameter") or p.get("cSinkDiameter") or p.get("majorDiameter"), True), "angle_rad": math.radians(_number(p.get("countersinkAngle") or p.get("cSinkAngle") or 90.0))} if standard_through_diameter is None and style.upper() in {"COUNTERBORE", "C_BORE"}: @@ -6212,7 +11240,15 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: if source_plane is None: raise ValueError("mirror plane frame is unresolved") else: plane = _mirror_plane_from_query( - plane_query, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, + plane_query, + feature_frames, + sketch_by_source, + entity_by_sketch, + feature_by_id, + sketches_by_id, + previous, + owners, + model.featurescript_version, ) if plane is None: raise ValueError("mirror plane is not a default or reference plane") plane_owner = f"{fid}_plane" @@ -6249,14 +11285,23 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: feature, ) _record_lowered_body_members(lowered_body_members, feature) - if item.operation == "extrude" and surface_profile_sketch is not None: + if ( + item.operation == "extrude" + and surface_profile_sketch is not None + and not pure_surface_operation + ): features.append(surface_feature) surface_profiles.append({ "profile": deepcopy(surface_profile_sketch["profile"]), "workplane": dict(surface_profile_sketch["workplane"]), **surface_feature["params"], }) - previous.append(fid) + if item.operation == "extrude" and pure_surface_operation: + # A surface shell has no CAP/SWEPT body lifecycle. Do not let + # subsequent source queries inherit a static prism frame. + feature_frames.pop(item.feature_id, None) + if item.operation != "assignVariable": + previous.append(fid) except UnsupportedCapability as exc: diagnostics.append({"code": "unsupported_engine_capability", "capability": exc.capability, "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False except Exception as exc: diff --git a/cadfs_to_cdsl/operation_registry.py b/cadfs_to_cdsl/operation_registry.py new file mode 100644 index 00000000..24987b68 --- /dev/null +++ b/cadfs_to_cdsl/operation_registry.py @@ -0,0 +1,88 @@ +"""Source-derived FeatureScript operation coverage inventory. + +The registry records what the current source corpus actually contains. It is +deliberately independent from conversion diagnostics: an old output directory +cannot make an operation appear supported, and a planned operation cannot be +reported as observed merely because its name is listed in the roadmap. +""" + +from __future__ import annotations + +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any, Iterable + +from .dataset import Sample +from .featurescript_parser import parse_featurescript + + +REGISTRY_SCHEMA = "cadfs_to_cdsl.operation_registry.v1" + +# These are roadmap work items, not parser aliases or support declarations. +P3_OPERATION_WORK_ITEMS = ( + "draft", + "thicken", + "split", + "moveFace", + "deleteFace", + "replaceFace", + "transform", + "derive", + "import", + "bend_add", +) + + +def build_operation_registry(samples: Iterable[Sample]) -> dict[str, Any]: + """Parse every available FeatureScript source and inventory feature calls. + + Parse failures and missing FeatureScript modalities are recorded separately + instead of being treated as a zero-count result. Sample examples remain + bounded only for report size; the count is computed from every feature. + """ + counts: Counter[str] = Counter() + sample_ids: dict[str, set[str]] = defaultdict(set) + parse_failures: list[dict[str, str]] = [] + source_sample_count = 0 + + for sample in samples: + source_path = sample.files.get("featurescript") + if not source_path: + continue + source_sample_count += 1 + try: + model = parse_featurescript(Path(source_path).read_text(encoding="utf-8"), sample.sample_id) + except Exception as error: + parse_failures.append({"sample_id": sample.sample_id, "error": f"{type(error).__name__}: {error}"}) + continue + for feature in model.features: + counts[feature.operation] += 1 + sample_ids[feature.operation].add(sample.sample_id) + + operations: dict[str, dict[str, Any]] = {} + for operation in sorted(counts): + operations[operation] = { + "state": "observed", + "feature_count": counts[operation], + "sample_count": len(sample_ids[operation]), + "sample_ids": sorted(sample_ids[operation]), + } + for operation in P3_OPERATION_WORK_ITEMS: + if operation in operations: + operations[operation]["roadmap_work_item"] = True + else: + operations[operation] = { + "state": "not_observed_in_current_source", + "feature_count": 0, + "sample_count": 0, + "sample_ids": [], + "roadmap_work_item": True, + } + + return { + "schema": REGISTRY_SCHEMA, + "source_sample_count": source_sample_count, + "parse_failure_count": len(parse_failures), + "parse_failures": parse_failures, + "operations": operations, + } diff --git a/cadfs_to_cdsl/pipeline.py b/cadfs_to_cdsl/pipeline.py index c40a1f97..8d8784ba 100644 --- a/cadfs_to_cdsl/pipeline.py +++ b/cadfs_to_cdsl/pipeline.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib, multiprocessing, random +from copy import deepcopy from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Any @@ -8,6 +9,7 @@ from .compare import compare_steps from .dataset import Sample, scan_dataset from .featurescript_parser import parse_featurescript from .lowering import lower_model +from .operation_registry import build_operation_registry from .rebuild import rebuild_candidate from .reports import generate_reports, read_json, write_json, write_manifest @@ -22,9 +24,34 @@ def _fingerprint(sample: Sample) -> str: def _sample_dir(output: Path, sample_id: str) -> Path: return output / "samples" / sample_id +def _semantic_valid_prefix( + cdsl: dict[str, Any], + validate_semantic_cdsl: Any, +) -> tuple[dict[str, Any], dict[str, Any]] | None: + """Return the longest contiguous semantically valid feature prefix. + + This is an artifact-recovery boundary, not permission to execute an + invalid candidate. The selected prefix is independently validated and + saved under an explicit prefix filename; the complete rejected CDSL stays + diagnostic-only. + """ + features = cdsl.get("features") or [] + for count in range(len(features) - 1, 0, -1): + prefix = deepcopy(cdsl) + prefix["features"] = prefix["features"][:count] + try: + semantic = validate_semantic_cdsl(prefix) + except Exception: + continue + return prefix, semantic + return None + + def scan(input_root: Path, output: Path) -> list[dict[str, Any]]: - records = [sample.as_dict() for sample in scan_dataset(input_root)] + samples = scan_dataset(input_root) + records = [sample.as_dict() for sample in samples] write_json(output / "dataset_index.json", {"schema": "cadfs_to_cdsl.dataset_index.v1", "input": str(input_root), "sample_count": len(records), "records": records}) + write_json(output / "operation_registry.json", build_operation_registry(samples)) initial = [{"sample_id": item["sample_id"], "status": "scanned", "diagnostics": item["diagnostics"]} for item in records] write_manifest(output / "manifest.jsonl", initial) return records @@ -56,26 +83,91 @@ def convert_one(sample: Sample, output: Path, *, force: bool = False) -> dict[st if cached.get("input_fingerprint") == fingerprint and cached.get("conversion_status"): return cached if force: - for name in ("candidate.cdsl.json", "bound.cdsl.json", "rebuild.step", "rebuild.json", "rebuild.worker.json", "comparison.json", "comparison.worker.json"): + for name in ( + "candidate.cdsl.json", "candidate.invalid.cdsl.json", "candidate.prefix.cdsl.json", + "bound.cdsl.json", "prefix.bound.cdsl.json", "rebuild.step", "prefix.rebuild.step", + "rebuild.json", "prefix.rebuild.json", "rebuild.worker.json", "comparison.json", + "comparison.worker.json", + ): (directory / name).unlink(missing_ok=True) diagnostics = list(sample.diagnostics) try: feature_path = Path(sample.files["featurescript"]) model = parse_featurescript(feature_path.read_text(encoding="utf-8"), sample.sample_id) - provenance = {"source_featurescript": str(feature_path), **{f"source_{key}_sha256": value for key, value in sample.hashes.items()}, "jsonl": sample.metadata} - result = lower_model(model, provenance); diagnostics.extend(result.diagnostics) - write_json(directory / "history.json", result.history); write_json(directory / "diagnostics.json", diagnostics) - if result.cdsl is not None: - from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl - semantic = validate_semantic_cdsl(result.cdsl); write_json(directory / "candidate.cdsl.json", result.cdsl) - else: semantic = None - status = {"schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id, "status": result.status, "conversion_status": result.status, "input_fingerprint": fingerprint, "semantic_validation": semantic, "diagnostic_count": len(diagnostics)} except Exception as exc: missing = isinstance(exc, FileNotFoundError) - diagnostics.append({"code": "source_missing" if missing else "parse_or_lowering_failed", "message": str(exc), "type": type(exc).__name__}) + diagnostics.append({"code": "source_missing" if missing else "parse_failed", "message": str(exc), "type": type(exc).__name__}) write_json(directory / "diagnostics.json", diagnostics) final_status = "source_missing" if missing else "parse_failed" status = {"schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id, "status": final_status, "conversion_status": final_status, "input_fingerprint": fingerprint, "diagnostic_count": len(diagnostics)} + write_json(status_path, status); return status + provenance = { + "source_featurescript": str(feature_path), + **{f"source_{key}_sha256": value for key, value in sample.hashes.items()}, + "jsonl": sample.metadata, + } + try: + result = lower_model(model, provenance) + except Exception as exc: + diagnostics.append({"code": "lowering_failed", "message": str(exc), "type": type(exc).__name__}) + write_json(directory / "diagnostics.json", diagnostics) + status = { + "schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id, + "status": "lowering_failed", "conversion_status": "lowering_failed", + "input_fingerprint": fingerprint, "diagnostic_count": len(diagnostics), + } + write_json(status_path, status); return status + diagnostics.extend(result.diagnostics) + write_json(directory / "history.json", result.history) + if result.cdsl is None: + write_json(directory / "diagnostics.json", diagnostics) + status = { + "schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id, + "status": result.status, "conversion_status": result.status, + "input_fingerprint": fingerprint, "semantic_validation": None, "diagnostic_count": len(diagnostics), + } + write_json(status_path, status); return status + try: + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + semantic = validate_semantic_cdsl(result.cdsl) + except Exception as exc: + diagnostics.append({"code": "semantic_validation_failed", "message": str(exc), "type": type(exc).__name__}) + write_json(directory / "candidate.invalid.cdsl.json", result.cdsl) + prefix_result = _semantic_valid_prefix(result.cdsl, validate_semantic_cdsl) + prefix_status: dict[str, Any] | None = None + if prefix_result is not None: + prefix, prefix_semantic = prefix_result + prefix_features = prefix.get("features") or [] + write_json(directory / "candidate.prefix.cdsl.json", prefix) + prefix_status = { + "feature_count": len(prefix_features), + "last_feature_id": prefix_features[-1].get("id"), + "semantic_validation": prefix_semantic, + } + diagnostics.append({ + "code": "semantic_valid_prefix_preserved", + "feature_count": len(prefix_features), + "last_feature_id": prefix_features[-1].get("id"), + "message": "longest contiguous semantic-valid CDSL prefix was preserved separately", + }) + write_json(directory / "diagnostics.json", diagnostics) + status = { + "schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id, + "status": "semantic_validation_failed", "conversion_status": "semantic_validation_failed", + "input_fingerprint": fingerprint, + "semantic_validation": {"valid": False, "error": {"type": type(exc).__name__, "message": str(exc)}}, + "diagnostic_count": len(diagnostics), + } + if prefix_status is not None: + status["semantic_valid_prefix"] = prefix_status + write_json(status_path, status); return status + write_json(directory / "candidate.cdsl.json", result.cdsl) + write_json(directory / "diagnostics.json", diagnostics) + status = { + "schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id, + "status": result.status, "conversion_status": result.status, + "input_fingerprint": fingerprint, "semantic_validation": semantic, "diagnostic_count": len(diagnostics), + } write_json(status_path, status); return status @@ -108,12 +200,19 @@ def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_se # must be rebuilt instead of being hidden behind the conversion label. # ``rebuild_candidate`` keeps the two outcomes distinct by reporting a # runtime-ineligible candidate when no executable body can be produced. - if not (directory / "candidate.cdsl.json").exists(): return status - rebuild_path = directory / "rebuild.json"; step_path = directory / "rebuild.step" - if not force and rebuild_path.exists() and status.get("rebuild_status"): - if status.get("rebuild_status") != "rebuilt" or step_path.exists(): return status + candidate_path = directory / "candidate.cdsl.json" + is_prefix = False + if not candidate_path.exists(): + candidate_path = directory / "candidate.prefix.cdsl.json" + is_prefix = candidate_path.exists() + if not candidate_path.exists(): return status + rebuild_path = directory / ("prefix.rebuild.json" if is_prefix else "rebuild.json") + step_path = directory / ("prefix.rebuild.step" if is_prefix else "rebuild.step") + status_key = "prefix_rebuild_status" if is_prefix else "rebuild_status" + if not force and rebuild_path.exists() and status.get(status_key): + if status.get(status_key) != "rebuilt" or step_path.exists(): return status worker_result = directory / "rebuild.worker.json" - outcome = _isolated(_rebuild_worker, (str(directory / "candidate.cdsl.json"), str(step_path), str(worker_result)), worker_result, timeout_seconds) + outcome = _isolated(_rebuild_worker, (str(candidate_path), str(step_path), str(worker_result)), worker_result, timeout_seconds) if outcome == "completed": result = read_json(worker_result); worker_result.unlink(missing_ok=True) bound_cdsl = result.pop("bound_cdsl", None) @@ -123,14 +222,17 @@ def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_se 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) + if bound_cdsl is not None: + write_json(directory / ("prefix.bound.cdsl.json" if is_prefix else "bound.cdsl.json"), bound_cdsl) else: step_path.unlink(missing_ok=True) error_type = "TimeoutError" if outcome == "timeout" else "WorkerProcessError" message = f"rebuild exceeded {timeout_seconds:g} seconds" if outcome == "timeout" else "rebuild worker exited without a result" result = {"status": "rebuild_timeout" if outcome == "timeout" else "rebuild_failed", "error": {"type": error_type, "message": message}} write_json(rebuild_path, result) - status["rebuild_status"] = result["status"]; status["status"] = result["status"] + status[status_key] = result["status"] + if not is_prefix: + status["status"] = result["status"] write_json(status_path, status); return status diff --git a/cadfs_to_cdsl/query_parser.py b/cadfs_to_cdsl/query_parser.py index c7b4fa72..b14d09b3 100644 --- a/cadfs_to_cdsl/query_parser.py +++ b/cadfs_to_cdsl/query_parser.py @@ -54,17 +54,109 @@ def query_ast(value: Any) -> dict[str, Any] | list[Any] | str | float | bool | N return str(value) +_SET_COMBINATORS = { + "qUnion": "union", + "qIntersection": "intersection", + "qSubtraction": "subtraction", +} +_FILTERS = { + "qAdjacent": "adjacent", + "qOwnerBody": "owner_body", + "qBodyType": "body_type", + "qConstructionFilter": "construction", +} + + +def _query_expression_node(value: Any) -> dict[str, Any]: + """Return a typed, lossless-enough representation of a source query. + + ``source_query.ast`` remains the source-level record, including parser line + numbers. This second representation makes set boundaries and filters + explicit so a later resolver can interpret them without re-parsing + FeatureScript text. Unknown calls stay opaque instead of being flattened + into a nearby supported topology query. + """ + if isinstance(value, Call): + name = value.name + args = value.args + if name in _SET_COMBINATORS: + operands = args[0] if len(args) == 1 and isinstance(args[0], list) else args + return { + "node": "set", + "operator": _SET_COMBINATORS[name], + "operands": [_query_expression_node(item) for item in operands], + } + if name in _FILTERS: + return { + "node": "filter", + "filter": _FILTERS[name], + "input": _query_expression_node(args[0]) if args else {"node": "literal", "value": None}, + "arguments": [_query_expression_node(item) for item in args[1:]], + } + if name == "makeQuery": + return { + "node": "topology_query", + "owner": _query_expression_node(args[0]) if len(args) > 0 else {"node": "literal", "value": None}, + "topology_type": _query_expression_node(args[1]) if len(args) > 1 else {"node": "literal", "value": None}, + "entity_type": _query_expression_node(args[2]) if len(args) > 2 else {"node": "literal", "value": None}, + "arguments": [_query_expression_node(item) for item in args[3:]], + } + if name == "qCreatedBy": + return { + "node": "created_by", + "owner": _query_expression_node(args[0]) if args else {"node": "literal", "value": None}, + "arguments": [_query_expression_node(item) for item in args[1:]], + } + if name in {"sQuery", "sketchEntityQuery"}: + return { + "node": "source_entity", + "sketch": _query_expression_node(args[0]) if len(args) > 0 else {"node": "literal", "value": None}, + "entity_type": _query_expression_node(args[1]) if len(args) > 1 else {"node": "literal", "value": None}, + "entity": _query_expression_node(args[2]) if len(args) > 2 else {"node": "literal", "value": None}, + "arguments": [_query_expression_node(item) for item in args[3:]], + } + if name == "qSketchRegion": + return { + "node": "sketch_region", + "sketch": _query_expression_node(args[0]) if args else {"node": "literal", "value": None}, + "arguments": [_query_expression_node(item) for item in args[1:]], + } + return { + "node": "opaque_call", + "name": name, + "arguments": [_query_expression_node(item) for item in args], + } + if isinstance(value, list): + return {"node": "list", "items": [_query_expression_node(item) for item in value]} + if isinstance(value, dict): + return { + "node": "map", + "entries": [ + {"key": str(key), "value": _query_expression_node(item)} + for key, item in value.items() + ], + } + if value is None or isinstance(value, (str, float, bool, int)): + return {"node": "literal", "value": value} + return {"node": "literal", "value": str(value)} + + +def query_expr(value: Any) -> dict[str, Any]: + """Produce the versioned CDSL query-expression contract for one query.""" + return {"version": "1.0", "root": _query_expression_node(value)} + + def parse_query(value: Any) -> QueryInfo: info = QueryInfo(ast=query_ast(value)) for call in walk_calls(value): info.calls.append(call.name) - if call.name in {"qUnion", "qIntersection", "qSubtraction", "qAdjacent"}: + if call.name in {"qUnion", "qIntersection", "qSubtraction"}: info.query_combinators.append(call.name) if call.name in {"qBodyType", "qOwnerBody"}: info.body_scope.append(call.name) if call.name in {"TDD", "trueDependencyDisambiguation"}: info.disambiguation.append(call.name) - if call.name in {"qBodyType", "qOwnerBody", "qAdjacent"}: + if call.name in {"qBodyType", "qOwnerBody", "qAdjacent", "qConstructionFilter"}: info.filters.append(call.name) if call.name in {"makeQuery", "qCreatedBy"} and call.args: owner = symbolic_string(call.args[0]) @@ -76,6 +168,11 @@ def parse_query(value: Any) -> QueryInfo: definition = next((arg for arg in call.args if isinstance(arg, dict)), {}) if isinstance(definition.get("isStart"), str): info.is_start = definition["isStart"].lower() == "true" elif "isStart" in definition: info.is_start = bool(definition["isStart"]) + elif call.name == "qCreatedBy" and len(call.args) > 1 and info.topology_type is None: + # qCreatedBy is itself a typed FeatureScript query. Keep its + # requested kind so consumers can distinguish a datum plane + # from a point without inspecting feature IDs or geometry. + info.kind = str(call.args[1]).lower() if call.name in {"sQuery", "sketchEntityQuery"} and len(call.args) >= 3: sketch = symbolic_string(call.args[0]); info.source_sketch = sketch.split(".", 1)[0] if info.topology_type is None: info.kind = str(call.args[1]).lower() diff --git a/cadfs_to_cdsl/rebuild.py b/cadfs_to_cdsl/rebuild.py index 8a4dedb2..d9266feb 100644 --- a/cadfs_to_cdsl/rebuild.py +++ b/cadfs_to_cdsl/rebuild.py @@ -49,6 +49,42 @@ def _failed_feature_id(error: Exception) -> str | None: return match.group(1) if match else None +def _last_preflight_executable_prefix( + cdsl: dict[str, Any], analysis: Any, output: Path, +) -> dict[str, Any] | None: + """Persist the contiguous executable prefix rejected by later preflight.""" + feature_results = list(getattr(analysis, "feature_results", ()) or ()) + first_blocked = next( + (index for index, result in enumerate(feature_results) if not result.executable), + None, + ) + if first_blocked in (None, 0): + return None + + features = list(cdsl.get("features") or []) + if first_blocked > len(features): + return None + prefix = deepcopy(cdsl) + prefix["features"] = features[:first_blocked] + try: + prefix_result = rebuild_candidate(prefix, output) + except Exception: + return None + if prefix_result.get("status") != "rebuilt": + nested_prefix = prefix_result.get("last_executable_prefix") + return nested_prefix if isinstance(nested_prefix, dict) else None + result = prefix_result.get("result") + if not isinstance(result, dict): + return None + return { + "failed_feature_id": feature_results[first_blocked].feature_id, + "feature_count": first_blocked, + "last_feature_id": features[first_blocked - 1].get("id"), + "bound_cdsl": prefix_result.get("bound_cdsl", prefix), + "result": result, + } + + def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]: from engine.cdsl_engine.runtime import analyze_cdsl, finalize_cdsl_execution from .selector_binding import bind_and_execute_candidate_selectors @@ -56,7 +92,11 @@ def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]: analysis = analyze_cdsl(cdsl) 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} + result = {"status": "runtime_ineligible", "analysis": analysis_dict} + prefix = _last_preflight_executable_prefix(cdsl, analysis, output) + if prefix is not None: + result["last_executable_prefix"] = prefix + return result try: replay = bind_and_execute_candidate_selectors(cdsl) result = finalize_cdsl_execution(replay.execution, output) diff --git a/cadfs_to_cdsl/reports.py b/cadfs_to_cdsl/reports.py index 8b26191b..32e8008d 100644 --- a/cadfs_to_cdsl/reports.py +++ b/cadfs_to_cdsl/reports.py @@ -190,10 +190,14 @@ def generate_markdown_report( key=lambda item: (-item[1], item[0]), ) - unsupported_ops = { - "shell", "sweep", "draft", "thicken", "split", "booleanBodies", "circularPattern", - "moveFace", "replaceFace", "deleteFace", "import", "derive", - } + operation_registry = _read_optional_json(output / "operation_registry.json") or {} + registry_operations = operation_registry.get("operations") or {} + planned_unobserved_operations = sorted( + name for name, value in registry_operations.items() + if isinstance(value, dict) + and value.get("roadmap_work_item") is True + and value.get("state") == "not_observed_in_current_source" + ) exact_mappings = { "extrude": "extrude_add_blind / extrude_add_two_sided / extrude_cut_blind", "loft": "loft_add (simple closed sketch profiles only)", @@ -312,9 +316,9 @@ def generate_markdown_report( "", *_table(["FeatureScript operation", "CDSL atomic policy"], sorted(exact_mappings.items())), "", - "Known unsupported FeatureScript operations recorded as capability gaps:", + "Roadmap operation work items absent from the scanned FeatureScript source:", "", - ", ".join(sorted(unsupported_ops)), + ", ".join(planned_unobserved_operations) if planned_unobserved_operations else "- None recorded; inspect operation_registry.json for observed work items.", "", "Engine executor atomic IDs:", "", @@ -345,6 +349,7 @@ def generate_markdown_report( f"- Manifest: `{output / 'manifest.jsonl'}`", f"- Summary JSON: `{output / 'summary.json'}`", f"- Capability gaps JSON: `{output / 'capability_gaps.json'}`", + f"- Source operation registry: `{output / 'operation_registry.json'}`", f"- Unsupported capabilities Markdown: `{output / 'unsupported_capabilities.md'}`", f"- Comparison CSV: `{output / 'comparison_summary.csv'}`", "", diff --git a/cadfs_to_cdsl/selector_binding.py b/cadfs_to_cdsl/selector_binding.py index 930248b1..04a83bc2 100644 --- a/cadfs_to_cdsl/selector_binding.py +++ b/cadfs_to_cdsl/selector_binding.py @@ -123,7 +123,10 @@ def _selector_roots(feature: dict[str, Any]) -> list[dict[str, Any]]: for name in ("end_condition", "reverse_end_condition"): condition = (feature.get("params") or {}).get(name) reference = condition.get("reference") if isinstance(condition, dict) else None - if isinstance(reference, dict): + # A source-sketch vertex is an immutable extent datum, not a topology + # selector. It has no runtime record to bind; the extent executor + # consumes its validated point directly. + if isinstance(reference, dict) and reference.get("kind") != "source_vertex": roots.append(reference) return roots diff --git a/cadfs_to_cdsl/selector_candidate_demo.py b/cadfs_to_cdsl/selector_candidate_demo.py index 1ce751f0..65b9cf84 100644 --- a/cadfs_to_cdsl/selector_candidate_demo.py +++ b/cadfs_to_cdsl/selector_candidate_demo.py @@ -13,6 +13,7 @@ from __future__ import annotations import argparse from copy import deepcopy import json +import math import multiprocessing from pathlib import Path from typing import Any @@ -29,10 +30,19 @@ def strip_provenance_intents(value: Any) -> int: """Remove provenance-only selector fields from one copied CDSL value.""" removed = 0 if isinstance(value, dict): + removed_intent = False for key in ("selector_intent", "selector_intent_version"): if key in value: value.pop(key) removed += 1 + removed_intent = True + # Feature output roles are semantic builder evidence, just like a + # selector intent. The geometry-only probe must not leave an output + # role in place after discarding the evidence that validates it. + if removed_intent: + for key in ("output_role", "output_role_source"): + if key in value: + value.pop(key) for child in value.values(): removed += strip_provenance_intents(child) elif isinstance(value, list): @@ -63,7 +73,7 @@ def _compare_worker(gold: str, rebuilt: str, result: str) -> None: write_json(Path(result), compare_steps(Path(gold), Path(rebuilt))) -def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_seconds: float) -> str: +def _isolated(target: Any, args: tuple[Any, ...], result_path: Path, timeout_seconds: float) -> str: """Bound an OCC experiment so one candidate cannot stall the demo.""" result_path.unlink(missing_ok=True) process = multiprocessing.get_context("spawn").Process(target=target, args=args) @@ -134,6 +144,41 @@ def _strict_passed(comparison: dict[str, Any] | None) -> bool: return bool(isinstance(comparison, dict) and (comparison.get("strict") or {}).get("passed")) +_SEARCH_TOKEN = "_selector_search_token" + + +def _selector_search_token(feature_id: str, location: str) -> str: + """Use a structural token while a group expands a public selector list.""" + return f"{feature_id}:{location}" + + +def _annotate_selector_search_tokens(cdsl: dict[str, Any]) -> int: + """Mark copied diagnostic selectors without creating a durable CDSL field.""" + count = 0 + for feature in cdsl.get("features") or []: + if not isinstance(feature, dict) or not isinstance(feature.get("id"), str): + continue + for location, selector in _selector_sites(feature): + selector[_SEARCH_TOKEN] = _selector_search_token(feature["id"], location) + count += 1 + return count + + +def _strip_selector_search_tokens(value: Any) -> int: + """Remove experiment-only branch tokens before a CDSL is persisted.""" + removed = 0 + if isinstance(value, dict): + if _SEARCH_TOKEN in value: + value.pop(_SEARCH_TOKEN) + removed += 1 + for child in value.values(): + removed += _strip_selector_search_tokens(child) + elif isinstance(value, list): + for child in value: + removed += _strip_selector_search_tokens(child) + return removed + + def _selector_sites(feature: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: """Return the public selector positions of one CDSL feature. @@ -270,6 +315,8 @@ def _emit_strict_selector_record( *, rebuild_timeout_seconds: float, comparison_timeout_seconds: float, + query_groups: list[dict[str, Any]] | None = None, + candidate_replay_kind: str = "geometry_probe", ) -> dict[str, Any]: """Create and independently verify a compiled CDSL only after strict proof.""" if not _strict_passed(geometry_comparison): @@ -286,6 +333,7 @@ def _emit_strict_selector_record( } compiled_candidate = deepcopy(bound_cdsl) + _strip_selector_search_tokens(compiled_candidate) compiled_path = directory / "selector_record.compiled.cdsl.json" write_json(compiled_path, compiled_candidate) compiled = _run_rebuild( @@ -322,20 +370,29 @@ def _emit_strict_selector_record( result["reason"] = "no_featurescript_selector_binding" return result record_path = directory / "selector-record.json" - write_json(record_path, { + verification = { + "compiled_cdsl_strict": True, + "compiled_cdsl": str(compiled_path), + } + if candidate_replay_kind == "geometry_probe": + verification["geometry_probe_strict"] = True + else: + verification["selector_search_branch_strict"] = True + record_payload: dict[str, Any] = { "schema": "cadfs_to_cdsl.selector_record_set.v1", "sample_id": sample.sample_id, "source": { "featurescript": sample.files.get("featurescript"), "step": sample.files.get("step"), }, - "verification": { - "geometry_probe_strict": True, - "compiled_cdsl_strict": True, - "compiled_cdsl": str(compiled_path), - }, + "verification": verification, "records": records, - }) + } + if query_groups: + # These are the query-to-record *sets* selected by the offline search. + # A FeatureScript query is not necessarily a single OCC record. + record_payload["query_groups"] = deepcopy(query_groups) + write_json(record_path, record_payload) result.update({ "status": "strict_replayed", "record_count": len(records), @@ -359,41 +416,58 @@ def _searchable_selector_sites(feature: dict[str, Any]) -> list[tuple[str, dict[ ] -def _replace_selector_site(cdsl: dict[str, Any], feature_id: str, location: str, selector: dict[str, Any]) -> None: - """Replace one public direct selector site in a copied candidate CDSL.""" +def _replace_selector_group( + cdsl: dict[str, Any], + feature_id: str, + token: str, + selectors: list[dict[str, Any]], +) -> None: + """Expand one selector root to an explicit branch-local record set.""" feature = next( (item for item in cdsl.get("features") or [] if isinstance(item, dict) and item.get("id") == feature_id), None, ) if feature is None: raise ValueError(f"search branch cannot find feature {feature_id}") - if location.startswith("selectors[") and location.endswith("]"): - index = int(location[len("selectors["):-1]) - selectors = feature.get("selectors") or [] - if index < 0 or index >= len(selectors) or not isinstance(selectors[index], dict): - raise ValueError(f"search branch selector site is unavailable: {feature_id}:{location}") - selectors[index] = deepcopy(selector) + roots = feature.get("selectors") or [] + index = next( + ( + item_index + for item_index, selector in enumerate(roots) + if isinstance(selector, dict) and selector.get(_SEARCH_TOKEN) == token + ), + None, + ) + if index is not None: + roots[index:index + 1] = [deepcopy(selector) for selector in selectors] + feature["selectors"] = roots return - prefix = "params." - suffix = ".reference" - if location.startswith(prefix) and location.endswith(suffix): - name = location[len(prefix):-len(suffix)] + for name in ("end_condition", "reverse_end_condition"): condition = (feature.get("params") or {}).get(name) - if not isinstance(condition, dict) or not isinstance(condition.get("reference"), dict): - raise ValueError(f"search branch selector site is unavailable: {feature_id}:{location}") - condition["reference"] = deepcopy(selector) - return - raise ValueError(f"search branch does not support nested selector site: {feature_id}:{location}") + reference = condition.get("reference") if isinstance(condition, dict) else None + if isinstance(reference, dict) and reference.get(_SEARCH_TOKEN) == token: + if len(selectors) != 1: + raise ValueError("search group cannot expand an extent reference") + condition["reference"] = deepcopy(selectors[0]) + return + raise ValueError(f"search branch selector token is unavailable: {feature_id}:{token}") -def _forced_candidate_selector(placeholder: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]: - """Turn one public runtime record into a branch-local explicit selector.""" +def _forced_candidate_selectors(placeholder: dict[str, Any], candidates: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Turn a query's runtime record set into explicit branch-local roots.""" from .selector_binding import _bound_selector - selector, _bound = _bound_selector(placeholder, [candidate]) - if not isinstance(selector, dict): - raise ValueError("search branch produced an invalid explicit selector") - return selector + selectors: list[dict[str, Any]] = [] + for candidate in candidates: + selector, _bound = _bound_selector(placeholder, [candidate]) + if not isinstance(selector, dict): + raise ValueError("search branch produced an invalid explicit selector") + if placeholder.get(_SEARCH_TOKEN) is not None: + selector[_SEARCH_TOKEN] = placeholder[_SEARCH_TOKEN] + selectors.append(selector) + if not selectors: + raise ValueError("search group has no runtime records") + return selectors def _resolve_search_selector(placeholder: dict[str, Any], *, registry: Any, active_body_id: str | None) -> Any: @@ -432,12 +506,552 @@ def _search_candidate_records( return [deepcopy(item) for item in candidates[:maximum]], len(candidates) -def _search_replay_worker(candidate: str, step: str, result: str, maximum_candidates: int) -> None: +def _vector(value: Any) -> list[float] | None: + if not isinstance(value, (list, tuple)) or len(value) != 3: + return None + try: + result = [float(component) for component in value] + except (TypeError, ValueError): + return None + return result if all(math.isfinite(component) for component in result) else None + + +def _sub(left: list[float], right: list[float]) -> list[float]: + return [left[index] - right[index] for index in range(3)] + + +def _dot(left: list[float], right: list[float]) -> float: + return sum(left[index] * right[index] for index in range(3)) + + +def _cross(left: list[float], right: list[float]) -> list[float]: + return [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ] + + +def _norm(value: list[float]) -> float: + return math.sqrt(_dot(value, value)) + + +def _unit(value: list[float] | None) -> list[float] | None: + if value is None or len(value) != 3: + return None + length = _norm(value) + if length <= 1e-9: + return None + return [component / length for component in value] + + +def _distance(left: list[float], right: list[float]) -> float: + return _norm(_sub(left, right)) + + +def _score_distance(distance_mm: float, *, tolerance_mm: float = 0.05) -> float: + return max(0.0, 1.0 - distance_mm / tolerance_mm) + + +def _source_selector_map(provenance_candidate: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Index source selectors by an experiment-only structural token.""" + selectors: dict[str, dict[str, Any]] = {} + for feature in provenance_candidate.get("features") or []: + if not isinstance(feature, dict) or not isinstance(feature.get("id"), str): + continue + for location, selector in _selector_sites(feature): + if isinstance(selector.get("selector_intent"), dict): + selectors[_selector_search_token(feature["id"], location)] = deepcopy(selector) + return selectors + + +def _source_feature(provenance_candidate: dict[str, Any], feature_id: Any) -> dict[str, Any] | None: + if not isinstance(feature_id, str): + return None + return next( + ( + feature + for feature in provenance_candidate.get("features") or [] + if isinstance(feature, dict) and feature.get("id") == feature_id + ), + None, + ) + + +def _source_sketch(provenance_candidate: dict[str, Any], producer: dict[str, Any], source_sketch_id: str | None) -> dict[str, Any] | None: + sketches = (provenance_candidate.get("geometry") or {}).get("sketches") or [] + sketch_id = producer.get("sketch_id") + if isinstance(sketch_id, str): + sketch = next((item for item in sketches if isinstance(item, dict) and item.get("id") == sketch_id), None) + if isinstance(sketch, dict): + return sketch + if isinstance(source_sketch_id, str): + sketch = next( + ( + item + for item in sketches + if isinstance(item, dict) and item.get("source_sketch_id") == source_sketch_id + ), + None, + ) + if isinstance(sketch, dict): + return sketch + return None + + +def _source_curve(sketch: dict[str, Any], entity_id: str) -> dict[str, Any] | None: + profile = sketch.get("profile") or {} + for entry in profile.get("source_entities") or []: + if isinstance(entry, dict) and entry.get("id") == entity_id and isinstance(entry.get("curve"), dict): + return entry["curve"] + for contour in profile.get("contours") or []: + if not isinstance(contour, dict): + continue + for segment in contour.get("segments") or []: + if isinstance(segment, dict) and segment.get("source_entity_id") == entity_id: + return segment + for segment in profile.get("construction") or []: + if isinstance(segment, dict) and segment.get("source_entity_id") == entity_id: + return segment + return None + + +def _producer_frame( + provenance_candidate: dict[str, Any], + source_selector: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any], list[float], float] | None: + """Recover only the direct sketch/extrude frame needed for candidate rank.""" + intent = source_selector.get("selector_intent") or {} + source_entity = intent.get("source_entity") if isinstance(intent.get("source_entity"), dict) else None + source_entities = intent.get("source_entities") or [] + source_sketch_id = ( + source_entity.get("sketch_id") + if isinstance(source_entity, dict) + else next( + ( + item.get("sketch_id") + for item in source_entities + if isinstance(item, dict) and isinstance(item.get("sketch_id"), str) + ), + None, + ) + ) + producer = _source_feature(provenance_candidate, source_selector.get("owner_feature_id")) + if producer is None or not str(producer.get("atomic_id") or "").startswith("extrude_"): + return None + sketch = _source_sketch(provenance_candidate, producer, source_sketch_id) + if sketch is None: + return None + plane = sketch.get("workplane") or {} + origin = _vector(plane.get("origin_mm")) + normal = _unit(_vector(plane.get("normal")) or []) + if origin is None or normal is None: + return None + params = producer.get("params") or {} + try: + span = abs(float(params["distance_mm"])) + except (KeyError, TypeError, ValueError): + return None + if span <= 1e-9: + return None + if bool(params.get("reverse") or params.get("opposite_direction")): + normal = [-component for component in normal] + return producer, sketch, normal, span + + +def _world_point(sketch: dict[str, Any], point: Any) -> list[float] | None: + local = point if isinstance(point, (list, tuple)) and len(point) in {2, 3} else None + if local is None: + return None + try: + coordinates = [float(value) for value in local] + except (TypeError, ValueError): + return None + plane = sketch.get("workplane") or {} + origin = _vector(plane.get("origin_mm")) + x_axis = _unit(_vector(plane.get("x_dir")) or []) + normal = _unit(_vector(plane.get("normal")) or []) + if origin is None or x_axis is None or normal is None: + return None + y_axis = _unit(_cross(normal, x_axis)) + if y_axis is None: + return None + z = coordinates[2] if len(coordinates) == 3 else 0.0 + return [ + origin[index] + x_axis[index] * coordinates[0] + y_axis[index] * coordinates[1] + normal[index] * z + for index in range(3) + ] + + +def _source_entity_ids(source_selector: dict[str, Any]) -> list[str]: + intent = source_selector.get("selector_intent") or {} + entity = intent.get("source_entity") + if isinstance(entity, dict) and isinstance(entity.get("entity_id"), str): + return [entity["entity_id"]] + return sorted({ + item["entity_id"] + for item in intent.get("source_entities") or [] + if isinstance(item, dict) and isinstance(item.get("entity_id"), str) + }) + + +def _cap_role_hint(source_selector: dict[str, Any]) -> str | None: + intent = source_selector.get("selector_intent") or {} + role = source_selector.get("output_role") or intent.get("output_role") + if isinstance(role, str) and role in {"extrude.start", "extrude.end"}: + return role.rsplit(".", 1)[1] + ast = ((intent.get("source_query") or {}).get("ast") or {}) + args = ast.get("args") if isinstance(ast, dict) else None + options = args[3] if isinstance(args, list) and len(args) >= 4 and isinstance(args[3], dict) else {} + is_start = options.get("isStart") + if is_start in {True, "true"}: + return "start" + if is_start in {False, "false"}: + return "end" + return None + + +def _query_group_descriptor(token: str, source_selector: dict[str, Any]) -> dict[str, Any]: + intent = source_selector.get("selector_intent") or {} + policy = intent.get("derivation_policy") or {} + return { + "token": token, + "feature_id": token.split(":", 1)[0], + "source_location": token.split(":", 1)[1] if ":" in token else token, + "kind": source_selector.get("kind"), + "owner_feature_id": source_selector.get("owner_feature_id"), + "query_family": intent.get("query_family"), + "multiplicity": policy.get("multiplicity", "one"), + "source_entity_ids": _source_entity_ids(source_selector), + "source_make_query": deepcopy((intent.get("source_query") or {}).get("ast")), + } + + +def _rank_cap_face( + provenance_candidate: dict[str, Any], + source_selector: dict[str, Any], + candidate: dict[str, Any], +) -> tuple[float, dict[str, Any]] | None: + frame = _producer_frame(provenance_candidate, source_selector) + role = _cap_role_hint(source_selector) + if frame is None or role is None: + return None + _producer, sketch, direction, span = frame + origin = _vector((sketch.get("workplane") or {}).get("origin_mm")) + geometry = candidate.get("geometry") or {} + actual_normal = _unit(_vector(geometry.get("plane_normal") or geometry.get("normal")) or []) + if origin is None or actual_normal is None or geometry.get("surface_type") != "plane": + return None + point = origin if role == "start" else [origin[index] + direction[index] * span for index in range(3)] + expected_offset = _dot(direction, point) + actual_offset = geometry.get("plane_offset_mm") + try: + actual_offset = float(actual_offset) + except (TypeError, ValueError): + return None + alignment = abs(_dot(direction, actual_normal)) + signed_offset = actual_offset if _dot(direction, actual_normal) >= 0 else -actual_offset + offset_error = abs(expected_offset - signed_offset) + return (alignment + _score_distance(offset_error)) / 2.0, { + "method": "extrude_cap_plane", + "cap_role": role, + "normal_alignment": round(alignment, 6), + "plane_offset_error_mm": round(offset_error, 9), + } + + +def _rank_swept_face( + provenance_candidate: dict[str, Any], + source_selector: dict[str, Any], + candidate: dict[str, Any], +) -> tuple[float, dict[str, Any]] | None: + frame = _producer_frame(provenance_candidate, source_selector) + entity_ids = _source_entity_ids(source_selector) + if frame is None or len(entity_ids) != 1: + return None + _producer, sketch, direction, _span = frame + curve = _source_curve(sketch, entity_ids[0]) + geometry = candidate.get("geometry") or {} + if not isinstance(curve, dict): + return None + if curve.get("type") == "line": + start, end = _world_point(sketch, curve.get("start")), _world_point(sketch, curve.get("end")) + actual_normal = _unit(_vector(geometry.get("plane_normal") or geometry.get("normal")) or []) + if start is None or end is None or actual_normal is None or geometry.get("surface_type") != "plane": + return None + expected_normal = _unit(_cross(_sub(end, start), direction)) + if expected_normal is None: + return None + try: + actual_offset = float(geometry["plane_offset_mm"]) + except (KeyError, TypeError, ValueError): + return None + alignment = abs(_dot(expected_normal, actual_normal)) + expected_offset = _dot(expected_normal, start) + signed_offset = actual_offset if _dot(expected_normal, actual_normal) >= 0 else -actual_offset + offset_error = abs(expected_offset - signed_offset) + return (alignment + _score_distance(offset_error)) / 2.0, { + "method": "source_line_supporting_plane", + "normal_alignment": round(alignment, 6), + "plane_offset_error_mm": round(offset_error, 9), + } + if curve.get("type") not in {"arc", "circle"}: + return None + center = _world_point(sketch, curve.get("center")) + actual_origin = _vector(geometry.get("axis_origin_mm")) + actual_direction = _unit(_vector(geometry.get("axis_direction")) or []) + try: + radius_error = abs(float(curve["radius_mm"]) - float(geometry["radius_mm"])) + except (KeyError, TypeError, ValueError): + return None + if center is None or actual_origin is None or actual_direction is None or geometry.get("surface_type") != "cylinder": + return None + alignment = abs(_dot(direction, actual_direction)) + radial_offset = _norm(_cross(_sub(center, actual_origin), actual_direction)) + return ( + alignment + _score_distance(radial_offset) + _score_distance(radius_error) + ) / 3.0, { + "method": "source_arc_cylinder", + "axis_alignment": round(alignment, 6), + "axis_offset_mm": round(radial_offset, 9), + "radius_error_mm": round(radius_error, 9), + } + + +def _point2(value: Any) -> tuple[float, float] | None: + if not isinstance(value, (list, tuple)) or len(value) < 2: + return None + try: + point = float(value[0]), float(value[1]) + except (TypeError, ValueError): + return None + return point if all(math.isfinite(component) for component in point) else None + + +def _curve_contains_point(curve: dict[str, Any], point: tuple[float, float], *, tolerance_mm: float = 0.05) -> bool: + curve_type = curve.get("type") + if curve_type == "line": + start, end = _point2(curve.get("start")), _point2(curve.get("end")) + if start is None or end is None: + return False + direction = end[0] - start[0], end[1] - start[1] + length = math.hypot(*direction) + if length <= 1e-9: + return False + offset = point[0] - start[0], point[1] - start[1] + distance = abs(direction[0] * offset[1] - direction[1] * offset[0]) / length + parameter = (direction[0] * offset[0] + direction[1] * offset[1]) / (length * length) + return distance <= tolerance_mm and -tolerance_mm / length <= parameter <= 1.0 + tolerance_mm / length + center = _point2(curve.get("center")) + try: + radius = float(curve["radius_mm"]) + except (KeyError, TypeError, ValueError): + return False + if center is None or radius <= 1e-9 or abs(math.dist(center, point) - radius) > tolerance_mm: + return False + if curve_type == "circle": + return True + if curve_type != "arc": + return False + start, end = _point2(curve.get("start")), _point2(curve.get("end")) + if start is None or end is None: + return False + angle = math.atan2(point[1] - center[1], point[0] - center[0]) + start_angle = math.atan2(start[1] - center[1], start[0] - center[0]) + end_angle = math.atan2(end[1] - center[1], end[0] - center[0]) + full_turn = 2.0 * math.pi + if bool(curve.get("clockwise")): + travelled = (start_angle - angle) % full_turn + total = (start_angle - end_angle) % full_turn + else: + travelled = (angle - start_angle) % full_turn + total = (end_angle - start_angle) % full_turn + angular_tolerance = tolerance_mm / radius + return travelled <= total + angular_tolerance + + +def _curve_intersections_2d(left: dict[str, Any], right: dict[str, Any]) -> list[tuple[float, float]]: + """Intersect line/arc/circle supports, retaining only source curve spans.""" + left_type, right_type = left.get("type"), right.get("type") + points: list[tuple[float, float]] = [] + if left_type == "line" and right_type == "line": + left_start, left_end = _point2(left.get("start")), _point2(left.get("end")) + right_start, right_end = _point2(right.get("start")), _point2(right.get("end")) + if None not in (left_start, left_end, right_start, right_end): + left_dx, left_dy = left_end[0] - left_start[0], left_end[1] - left_start[1] + right_dx, right_dy = right_end[0] - right_start[0], right_end[1] - right_start[1] + determinant = left_dx * right_dy - left_dy * right_dx + if abs(determinant) > 1e-9: + offset_x, offset_y = right_start[0] - left_start[0], right_start[1] - left_start[1] + parameter = (offset_x * right_dy - offset_y * right_dx) / determinant + points.append((left_start[0] + parameter * left_dx, left_start[1] + parameter * left_dy)) + else: + line, circular = (left, right) if left_type == "line" else (right, left) + if line.get("type") == "line" and circular.get("type") in {"arc", "circle"}: + start, end, center = _point2(line.get("start")), _point2(line.get("end")), _point2(circular.get("center")) + try: + radius = float(circular["radius_mm"]) + except (KeyError, TypeError, ValueError): + radius = 0.0 + if start is not None and end is not None and center is not None and radius > 1e-9: + dx, dy = end[0] - start[0], end[1] - start[1] + ox, oy = start[0] - center[0], start[1] - center[1] + quadratic_a = dx * dx + dy * dy + quadratic_b = 2.0 * (dx * ox + dy * oy) + quadratic_c = ox * ox + oy * oy - radius * radius + discriminant = quadratic_b * quadratic_b - 4.0 * quadratic_a * quadratic_c + if quadratic_a > 1e-12 and discriminant >= -1e-9: + root = math.sqrt(max(0.0, discriminant)) + for parameter in ((-quadratic_b - root) / (2.0 * quadratic_a), (-quadratic_b + root) / (2.0 * quadratic_a)): + points.append((start[0] + parameter * dx, start[1] + parameter * dy)) + elif left_type in {"arc", "circle"} and right_type in {"arc", "circle"}: + left_center, right_center = _point2(left.get("center")), _point2(right.get("center")) + try: + left_radius, right_radius = float(left["radius_mm"]), float(right["radius_mm"]) + except (KeyError, TypeError, ValueError): + left_radius = right_radius = 0.0 + if left_center is not None and right_center is not None and left_radius > 1e-9 and right_radius > 1e-9: + dx, dy = right_center[0] - left_center[0], right_center[1] - left_center[1] + center_distance = math.hypot(dx, dy) + if 1e-9 < center_distance <= left_radius + right_radius + 0.05: + along = (left_radius * left_radius - right_radius * right_radius + center_distance * center_distance) / (2.0 * center_distance) + height_squared = left_radius * left_radius - along * along + if height_squared >= -1e-9: + height = math.sqrt(max(0.0, height_squared)) + base_x, base_y = left_center[0] + along * dx / center_distance, left_center[1] + along * dy / center_distance + offset_x, offset_y = -dy * height / center_distance, dx * height / center_distance + points.extend(((base_x + offset_x, base_y + offset_y), (base_x - offset_x, base_y - offset_y))) + valid: list[tuple[float, float]] = [] + for point in points: + if not _curve_contains_point(left, point) or not _curve_contains_point(right, point): + continue + if not any(math.dist(point, existing) <= 1e-6 for existing in valid): + valid.append(point) + return valid + + +def _source_curve_intersection(sketch: dict[str, Any], entity_ids: list[str]) -> list[float] | None: + if len(entity_ids) != 2: + return None + curves = [_source_curve(sketch, entity_id) for entity_id in entity_ids] + if any(not isinstance(curve, dict) for curve in curves): + return None + intersections = _curve_intersections_2d(curves[0], curves[1]) + if len(intersections) != 1: + return None + return _world_point(sketch, intersections[0]) + + +def _rank_swept_edge( + provenance_candidate: dict[str, Any], + source_selector: dict[str, Any], + candidate: dict[str, Any], +) -> tuple[float, dict[str, Any]] | None: + frame = _producer_frame(provenance_candidate, source_selector) + entity_ids = _source_entity_ids(source_selector) + if frame is None: + return None + _producer, sketch, direction, _span = frame + source_point = _source_curve_intersection(sketch, entity_ids) + geometry = candidate.get("geometry") or {} + start, end = _vector(geometry.get("start_mm")), _vector(geometry.get("end_mm")) + if source_point is None or start is None or end is None or geometry.get("curve_type") != "line": + return None + edge_direction = _unit(_sub(end, start)) + if edge_direction is None: + return None + alignment = abs(_dot(direction, edge_direction)) + supporting_line_error = _norm(_cross(_sub(source_point, start), direction)) + return (alignment + _score_distance(supporting_line_error)) / 2.0, { + "method": "source_pair_extrusion_vertex", + "axis_alignment": round(alignment, 6), + "source_vertex_offset_mm": round(supporting_line_error, 9), + } + + +def _query_candidate_groups( + provenance_candidate: dict[str, Any], + token: str, + source_selector: dict[str, Any] | None, + resolution: Any, + *, + maximum: int, +) -> tuple[dict[str, Any], list[dict[str, Any]], int]: + """Rank branch alternatives as FeatureScript query result sets. + + This is deliberately an offline geometric heuristic. It gets one chance + to reduce a query to record *sets*, then final STEP strict replay decides + whether that set is useful. It never adds a geometry relation to the + production selector graph. + """ + normal_candidates, normal_count = _search_candidate_records(resolution, maximum=maximum) + if not isinstance(source_selector, dict): + descriptor = {"token": token, "ranking_status": "source_query_missing"} + return descriptor, [ + {"records": [candidate], "score": candidate["score"], "ranking": {"method": "resolver_candidate"}} + for candidate in normal_candidates + ], normal_count + descriptor = _query_group_descriptor(token, source_selector) + family = descriptor.get("query_family") + ranker = { + "CAP_FACE": _rank_cap_face, + "SWEPT_FACE": _rank_swept_face, + "SWEPT_EDGE": _rank_swept_edge, + }.get(family) + if ranker is None: + descriptor["ranking_status"] = "query_family_not_ranked" + return descriptor, [ + {"records": [candidate], "score": candidate["score"], "ranking": {"method": "resolver_candidate"}} + for candidate in normal_candidates + ], normal_count + ranked: list[tuple[float, dict[str, Any], dict[str, Any]]] = [] + for candidate in resolution.candidates: + if not isinstance(candidate, dict) or not candidate.get("record_id") or float(candidate.get("score") or 0.0) < 0.8: + continue + result = ranker(provenance_candidate, source_selector, candidate) + if result is None: + continue + score, reason = result + if score >= 0.98: + ranked.append((score, deepcopy(candidate), reason)) + ranked.sort(key=lambda item: (-item[0], str(item[1].get("record_id")))) + if not ranked: + descriptor["ranking_status"] = "source_geometry_unavailable" + return descriptor, [ + {"records": [candidate], "score": candidate["score"], "ranking": {"method": "resolver_candidate"}} + for candidate in normal_candidates + ], normal_count + descriptor["ranking_status"] = "source_geometry_ranked" + descriptor["ranked_record_count"] = len(ranked) + if descriptor.get("multiplicity") == "all_fragments": + records = [candidate for _score, candidate, _reason in ranked] + descriptor["selected_set_cardinality"] = len(records) + return descriptor, [{ + "records": records, + "score": round(sum(score for score, _candidate, _reason in ranked) / len(ranked), 6), + "ranking": {"method": "all_fragments_source_geometry", "records": [reason for _score, _candidate, reason in ranked]}, + }], 1 + groups = [ + {"records": [candidate], "score": round(score, 6), "ranking": reason} + for score, candidate, reason in ranked[:maximum] + ] + return descriptor, groups, len(ranked) + + +def _search_replay_worker( + candidate: str, + provenance: str, + step: str, + result: str, + maximum_candidates: int, +) -> None: """Replay one branch until it builds or exposes its next selector decision.""" from engine.cdsl_engine.runtime import finalize_cdsl_execution, prepare_cdsl_execution from .selector_binding import _bound_selector, _selector_key bound = read_json(Path(candidate)) + provenance_candidate = read_json(Path(provenance)) + source_selectors = _source_selector_map(provenance_candidate) execution = prepare_cdsl_execution(bound) if not execution.analysis.runtime_eligible: first = next((item for item in execution.analysis.feature_results if not item.executable), None) @@ -485,19 +1099,25 @@ def _search_replay_worker(candidate: str, step: str, result: str, maximum_candid active_body_id=active_body_id, ) if resolution.status != "resolved": - candidates, eligible_count = _search_candidate_records( + token = placeholder.get(_SEARCH_TOKEN) + descriptor, candidate_groups, group_count = _query_candidate_groups( + provenance_candidate, + str(token or f"{node.feature_id}:{location}"), + source_selectors.get(str(token)), resolution, maximum=maximum_candidates, ) - if candidates: + if candidate_groups: write_json(Path(result), { - "status": "branchable_selector", + "status": "branchable_query_group", "feature_id": node.feature_id, "source_location": location, + "selector_token": token, "resolution_status": resolution.status, "diagnostic": resolution.diagnostic.as_dict() if resolution.diagnostic is not None else None, - "candidates": candidates, - "candidate_count": eligible_count, + "query_group": descriptor, + "candidate_groups": candidate_groups, + "candidate_group_count": group_count, "bound_cdsl": bound, "selector_binding": evidence, }) @@ -572,11 +1192,18 @@ def _search_replay_worker(candidate: str, step: str, result: str, maximum_candid }) -def _run_search_replay(candidate_path: Path, step: Path, *, timeout_seconds: float, maximum_candidates: int) -> dict[str, Any]: +def _run_search_replay( + candidate_path: Path, + provenance_path: Path, + step: Path, + *, + timeout_seconds: float, + maximum_candidates: int, +) -> dict[str, Any]: worker = step.with_suffix(".worker.json") outcome = _isolated( _search_replay_worker, - (str(candidate_path), str(step), str(worker), maximum_candidates), + (str(candidate_path), str(provenance_path), str(step), str(worker), maximum_candidates), worker, timeout_seconds, ) @@ -596,6 +1223,8 @@ def _public_branch_summary(result: dict[str, Any], choices: list[dict[str, Any]] "status": result.get("status"), "feature_id": result.get("feature_id"), "source_location": result.get("source_location"), + "query_group": result.get("query_group"), + "candidate_group_count": result.get("candidate_group_count"), "reason": result.get("reason"), "resolution_status": result.get("resolution_status"), "choice_path": choices, @@ -630,7 +1259,10 @@ def _run_selector_search( return {"status": "disabled"}, None search_dir = directory / "selector-search" search_dir.mkdir(parents=True, exist_ok=True) - pending: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [(deepcopy(geometry_candidate), [])] + search_candidate = deepcopy(geometry_candidate) + _annotate_selector_search_tokens(search_candidate) + provenance_path = directory / "provenance.candidate.cdsl.json" + pending: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [(search_candidate, [])] seen: set[str] = set() terminals: list[tuple[dict[str, Any], dict[str, Any], list[dict[str, Any]], dict[str, Any] | None]] = [] branch_summaries: list[dict[str, Any]] = [] @@ -653,32 +1285,43 @@ def _run_selector_search( write_json(candidate_path, candidate) outcome = _run_search_replay( candidate_path, + provenance_path, step_path, timeout_seconds=rebuild_timeout_seconds, maximum_candidates=maximum_candidates, ) _write_rebuild_artifacts(search_dir, branch_name, outcome) - if outcome.get("status") == "branchable_selector": - candidates = list(outcome.get("candidates") or []) - total = int(outcome.get("candidate_count") or len(candidates)) - if total > len(candidates): + if outcome.get("status") == "branchable_query_group": + groups = [item for item in outcome.get("candidate_groups") or [] if isinstance(item, dict)] + total = int(outcome.get("candidate_group_count") or len(groups)) + if total > len(groups): candidates_truncated = True branch_summaries.append(_public_branch_summary(outcome, choices)) - for candidate_record in candidates: + for group in groups: child = deepcopy(outcome["bound_cdsl"]) feature_id = str(outcome["feature_id"]) - location = str(outcome["source_location"]) source_feature = next(item for item in child["features"] if item.get("id") == feature_id) - source_selector = dict(_searchable_selector_sites(source_feature)[ - next(index for index, (name, _selector) in enumerate(_searchable_selector_sites(source_feature)) if name == location) - ][1]) - forced = _forced_candidate_selector(source_selector, candidate_record) - _replace_selector_site(child, feature_id, location, forced) + token = outcome.get("selector_token") + source_selector = next( + ( + selector + for _location, selector in _searchable_selector_sites(source_feature) + if selector.get(_SEARCH_TOKEN) == token + ), + None, + ) + if not isinstance(source_selector, dict): + raise ValueError(f"search branch selector token is unavailable: {feature_id}:{token}") + records = [item for item in group.get("records") or [] if isinstance(item, dict)] + forced = _forced_candidate_selectors(source_selector, records) + _replace_selector_group(child, feature_id, str(token), forced) pending.append((child, [*choices, { "feature_id": feature_id, - "source_location": location, - "record_id": candidate_record.get("record_id"), - "score": candidate_record.get("score"), + "source_location": outcome.get("source_location"), + "query_group": deepcopy(outcome.get("query_group") or {}), + "resolved_runtime_records": records, + "score": group.get("score"), + "ranking": deepcopy(group.get("ranking") or {}), }])) continue comparison = None @@ -731,6 +1374,8 @@ def _run_selector_search( gold_step, rebuild_timeout_seconds=rebuild_timeout_seconds, comparison_timeout_seconds=comparison_timeout_seconds, + query_groups=choices, + candidate_replay_kind="selector_search_branch", ) report["selector_record"] = selector_record return report, selector_record if selector_record.get("status") == "strict_replayed" else None diff --git a/cadfs_to_cdsl/tests/test_integration.py b/cadfs_to_cdsl/tests/test_integration.py index 9ecf0ce6..27d566de 100644 --- a/cadfs_to_cdsl/tests/test_integration.py +++ b/cadfs_to_cdsl/tests/test_integration.py @@ -59,7 +59,8 @@ class IntegrationTests(unittest.TestCase): ]) with tempfile.TemporaryDirectory() as tmp: - outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step") + rebuilt = Path(tmp) / "rebuild.step" + outcome = rebuild_candidate(candidate, rebuilt) self.assertEqual(outcome["status"], "rebuilt") self.assertEqual( [item["feature_id"] for item in outcome["result"]["feature_results"]], @@ -123,7 +124,10 @@ class IntegrationTests(unittest.TestCase): [4.48, 2.36], ) - self.assertNotIn("result_mode", next(item for item in result.cdsl["features"] if item["id"] == "f_F7")["params"]) + self.assertEqual( + next(item for item in result.cdsl["features"] if item["id"] == "f_F7")["params"]["result_mode"], + "new_body", + ) self.assertEqual(next(item for item in result.cdsl["features"] if item["id"] == "f_F9")["params"]["result_mode"], "new_body") with tempfile.TemporaryDirectory() as tmp: @@ -140,7 +144,8 @@ class IntegrationTests(unittest.TestCase): candidate = lower_model(parse_featurescript(feature.read_text(), "00925274"), {}).cdsl with tempfile.TemporaryDirectory() as tmp: - outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step") + rebuilt = Path(tmp) / "rebuild.step" + outcome = rebuild_candidate(candidate, rebuilt) self.assertEqual(outcome["status"], "rebuilt") result = outcome["result"] @@ -166,9 +171,13 @@ class IntegrationTests(unittest.TestCase): result = lower_model(parse_featurescript(feature.read_text(), "00007264"), {}) fillet = next(item for item in result.cdsl["features"] if item["id"] == "f_F2") self.assertEqual(fillet["atomic_id"], "fillet") + self.assertEqual(len(fillet["selectors"]), 1) + query_set = fillet["selectors"][0] + self.assertEqual(query_set["selector_intent"]["query_family"], "QUERY_SET") + self.assertEqual(query_set["selector_intent"]["query_set_contract"], "proven_operand_union") self.assertTrue(all( - selector["selector_intent"]["query_family"] == "SWEPT_EDGE" - for selector in fillet["selectors"] + operand["selector_intent"]["query_family"] == "SWEPT_EDGE" + for operand in query_set["query_operands"] )) with tempfile.TemporaryDirectory() as tmp: @@ -176,6 +185,8 @@ class IntegrationTests(unittest.TestCase): self.assertEqual(outcome["status"], "rebuilt") binding = next(item for item in outcome["selector_binding"] if item["feature_id"] == "f_F2") self.assertEqual(len(binding["resolved"]), 4) + resolution = next(item for item in outcome["result"]["selector_resolution"] if item["feature_id"] == "f_F2") + self.assertEqual(resolution["resolution_mode"], "query_set_union") vertical_edges = [ relation for delta in outcome["result"]["topology_deltas"] @@ -287,11 +298,13 @@ class IntegrationTests(unittest.TestCase): self.assertNotIn("stable_id", selector) with tempfile.TemporaryDirectory() as tmp: - outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step") + rebuilt = Path(tmp) / "rebuild.step" + outcome = rebuild_candidate(candidate, rebuilt) + self.assertTrue(rebuilt.exists()) - self.assertEqual(outcome["status"], "rebuild_failed") - self.assertEqual(outcome["error"]["message"], "f_F4: selector_query_unsupported during incremental replay") + self.assertEqual(outcome["status"], "runtime_ineligible") prefix = outcome["last_executable_prefix"]["result"] + self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F3") self.assertEqual([item["feature_id"] for item in prefix["feature_results"]], ["f_F1", "f_F2", "f_F3"]) resolution = next(item for item in prefix["selector_resolution"] if item["feature_id"] == "f_F2") self.assertEqual(resolution["status"], "resolved") @@ -359,7 +372,7 @@ 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(result.status, "converted_partial") 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]]) @@ -367,6 +380,7 @@ class IntegrationTests(unittest.TestCase): 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]) + self.assertIn("F7", [item.get("feature_id") for item in result.diagnostics]) def test_direct_sketch_wire_qbodytype_path_lowers_and_rebuilds_00896761(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -390,17 +404,18 @@ class IntegrationTests(unittest.TestCase): self.assertEqual(outcome["status"], "rebuilt") self.assertEqual([item["feature_id"] for item in outcome["result"]["feature_results"]], ["f_F2"]) - def test_multi_entity_sketch_wire_qbodytype_path_remains_deferred_00786708(self): + def test_multi_entity_sketch_wire_qbodytype_path_lowers_as_a_source_ordered_wire_00786708(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0078/00786708.txt" if not feature.exists(): self.skipTest("CADFS sample is not installed") result = lower_model(parse_featurescript(feature.read_text(), "00786708"), {}) - self.assertIn({ - "code": "unsupported_engine_capability", "feature_id": "F2", "operation": "sweep", "capability": "sweep_path_query", - "message": "current CDSL sweep requires one direct sketch line or B-spline path", - }, result.diagnostics) - self.assertNotIn("f_F2", {item["id"] for item in result.cdsl["features"]}) + self.assertEqual(result.status, "converted_complete") + first_sweep = next(item for item in result.cdsl["features"] if item["id"] == "f_F2") + self.assertEqual( + [(item["source_entity_id"], item["type"]) for item in first_sweep["params"]["path"]["segments"]], + [("E1", "arc"), ("E0", "line"), ("E2.MirrorCS", "arc")], + ) later_sweep = next(item for item in result.cdsl["features"] if item["id"] == "f_F5") self.assertEqual(later_sweep["params"]["path"]["segment"]["source_entity_id"], "E5") @@ -417,14 +432,13 @@ class IntegrationTests(unittest.TestCase): 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) + self.assertEqual(rebuilt["solid_count"], 2) + self.assertEqual( + [item["feature_id"] for item in rebuilt["feature_results"]], + ["f_F1", "f_F3", "f_F5", "f_F6"], + ) - def test_fused_body_circular_copy_faces_preserve_shell_prefix_00542223(self): + def test_deferred_pattern_shell_keeps_later_executable_feature_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") @@ -433,9 +447,11 @@ class IntegrationTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp: outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step") - self.assertEqual(outcome["status"], "rebuild_failed") - self.assertEqual(outcome["error"]["message"], "f_F7: selector_query_unsupported during incremental replay") - self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F6") + self.assertEqual(outcome["status"], "rebuilt") + self.assertEqual( + [item["feature_id"] for item in outcome["result"]["feature_results"]], + ["f_F1", "f_F3", "f_F5", "f_F6", "f_F10"], + ) def test_face_chamfer_source_query_is_not_geometry_bound_00111611(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" diff --git a/cadfs_to_cdsl/tests/test_lowering.py b/cadfs_to_cdsl/tests/test_lowering.py index 84aea462..5624d59e 100644 --- a/cadfs_to_cdsl/tests/test_lowering.py +++ b/cadfs_to_cdsl/tests/test_lowering.py @@ -1,16 +1,19 @@ from __future__ import annotations -import json, math, tempfile, unittest +import json, math, re, tempfile, unittest from copy import deepcopy from pathlib import Path +from unittest.mock import patch from cadfs_to_cdsl.compare import compare_steps from cadfs_to_cdsl.featurescript_parser import parse_featurescript from cadfs_to_cdsl.ir import Call from cadfs_to_cdsl.lowering import ( UnsupportedCapability, _arc, _contours, _curve_endpoint_tangent, - _direct_hole_location, _direct_line_angle_axis, _direct_sketch_wire_path, + _direct_hole_location, _direct_line_angle_axis, _direct_segmented_sketch_wire_path, + _is_direct_hole_location_query, + _direct_line_angle_wire_axis, _direct_sketch_wire_path, _direct_spatial_segmented_sketch_wire_path, _direct_boolean_intersection_selector, _direct_primary_cut_intersection_selector, _global, _number, - _record_single_body_successor, lower_model, + _multi_source_sketch_region_profile, _record_single_body_successor, _surface_profile_selection_sketch, lower_model, ) from cadfs_to_cdsl.pipeline import compare_one, convert_one, rebuild_one from cadfs_to_cdsl.rebuild import rebuild_candidate @@ -40,8 +43,426 @@ precondition{} }''' +CAP_VERTEX_UP_TO_VERTEX_SOURCE = '''FeatureScript 1511; +import(path : "onshape/std/geometry.fs", version : "1511.0"); +const mm = millimeter; +const EDGE = EntityType.EDGE; +const VERTEX = EntityType.VERTEX; +const FACE = EntityType.FACE; +function v(x, y){return vector(x, y);} +annotation { "Feature Type Name" : "Feature" } +export const myFeature = defineFeature(function(context is Context, id is Id, definition is map) +precondition{} +{ + { var Q0; Q0=qCreatedBy(makeId("Top.planeOp"),FACE); var sketch=newSketch(context,id+"F0",{"sketchPlane":qUnion([Q0])}); skLineSegment(sketch,"E0",{"start":v(0,0)*mm,"end":v(10,0)*mm}); skLineSegment(sketch,"E1",{"start":v(10,0)*mm,"end":v(10,5)*mm}); skLineSegment(sketch,"E2",{"start":v(10,5)*mm,"end":v(0,5)*mm}); skLineSegment(sketch,"E3",{"start":v(0,5)*mm,"end":v(0,0)*mm}); skSolve(sketch); } + { var Q0; Q0=qSketchRegion(id+"F0",true); extrude(context,id+"F1",{"entities":qUnion([Q0]),"depth":10*mm}); } + { var Q0; Q0=qSketchRegion(id+"F0",true); var Q1; Q1=makeQuery(id+"F1.opExtrude","CAP_VERTEX",VERTEX,{"disambiguationData":[OSD([sQuery(id+"F0.wireOp",EDGE,"E0"),sQuery(id+"F0.wireOp",EDGE,"E1")])],"isStart":false}); extrude(context,id+"F2",{"entities":qUnion([Q0]),"endBound":BoundingType.UP_TO_VERTEX,"endBoundEntityVertex":qUnion([Q1]),"depth":8*mm}); } +}''' + + class LoweringTests(unittest.TestCase): - def test_direct_sketch_wire_path_accepts_only_one_nonconstruction_line_or_bspline(self): + def test_multi_source_sketch_region_union_preserves_all_direct_profiles(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test/featurescript_rp" + source = root / "0001/00011635.txt" + if not source.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(source.read_text(encoding="utf-8"), "00011635"), {}) + self.assertEqual({item["id"] for item in result.cdsl["features"]}, {"f_F1", "f_F3", "f_F5", "f_F7", "f_F10"}) + profile = next( + sketch["profile"] for sketch in result.cdsl["geometry"]["sketches"] + if sketch["id"] == "sketch_regions_f_F7" + ) + self.assertEqual(profile["type"], "multi_source_regions") + self.assertEqual(profile["source_sketch_ids"], ["F6", "F4"]) + self.assertEqual(len(profile["profiles"]), 2) + + # A composed source query outside the strict direct-region contract + # must defer rather than select the last source sketch as older + # lowering did. + text = source.read_text(encoding="utf-8") + needle = 'qSketchRegion(id + "F4", true)' + offset = text.rfind(needle) + invalid = lower_model(parse_featurescript( + text[:offset] + text[offset:].replace(needle, 'qSketchRegion(id + "F4", false)', 1), + "00011635-invalid-regions", + ), {}) + self.assertNotIn("f_F7", {item["id"] for item in invalid.cdsl["features"]}) + self.assertIn(("F7", "unsupported_engine_capability", "extrude qSketchRegion union requires direct executable source profiles on one identical unattached frame"), { + (item.get("feature_id"), item.get("code"), item.get("message")) for item in invalid.diagnostics + }) + + def test_multi_source_sketch_region_requires_identical_unattached_frames(self): + def sketch(radius, plane, attachment=None): + result = {"workplane": plane, "profile": {"type": "circle", "radius_mm": radius}} + if attachment is not None: result["attachment"] = attachment + return result + + query = Call("qUnion", [[ + Call("qSketchRegion", ["F0", "true"]), Call("qSketchRegion", ["F1", "true"]), + ]]) + plane = {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]} + self.assertIsNotNone(_multi_source_sketch_region_profile(query, { + "F0": sketch(2, plane), "F1": sketch(3, deepcopy(plane)), + }, "f_regions")) + self.assertIsNone(_multi_source_sketch_region_profile(query, { + "F0": sketch(2, plane), "F1": sketch(3, {**plane, "origin_mm": [0, 0, 1]}), + }, "f_regions")) + self.assertIsNone(_multi_source_sketch_region_profile(query, { + "F0": sketch(2, plane), "F1": sketch(3, plane, {"kind": "face"}), + }, "f_regions")) + + def test_multi_source_sketch_region_union_flattens_only_nested_union_wrappers(self): + plane = {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]} + sources = { + identifier: {"workplane": deepcopy(plane), "profile": {"type": "circle", "radius_mm": radius}} + for identifier, radius in (("F0", 2), ("F1", 3), ("F2", 4)) + } + region = lambda identifier: Call("qSketchRegion", [identifier, "true"]) + nested = Call("qUnion", [[region("F0"), Call("qUnion", [[region("F1"), region("F2")]])]]) + profile = _multi_source_sketch_region_profile(nested, sources, "f_regions") + self.assertEqual(profile["profile"]["source_sketch_ids"], ["F0", "F1", "F2"]) + + # qIntersection has different set semantics and cannot be normalized + # as a profile union merely because it contains direct region leaves. + mixed = Call("qUnion", [[region("F0"), Call("qIntersection", [region("F1"), region("F2")])]]) + self.assertIsNone(_multi_source_sketch_region_profile(mixed, sources, "f_regions")) + def test_cpoint_direct_source_line_is_an_explicit_datum_only(self): + """A direct source line cPoint feeds cPlane without topology fallback.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test/featurescript_rp" + source = root / "0024/00240949.txt" + if not source.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(source.read_text(), "00240949"), {}) + self.assertEqual(result.status, "converted_complete") + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F3"], { + "id": "f_F3", "name": "F3", "atomic_id": "reference_point", + "depends_on": ["f_F1"], "params": {"point_mm": [15.05, 0.0, 22.985]}, + "execution_status": "supported", + }) + self.assertEqual(features["f_F4"]["params"]["plane"]["origin_mm"], [15.05, 0.0, 22.985]) + + from engine.cdsl_engine.runtime import prepare_cdsl_execution + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + validate_semantic_cdsl(result.cdsl) + execution = prepare_cdsl_execution(result.cdsl) + for _ in range(6): + execution.execute_next() + self.assertEqual(list(execution.session.results), ["f_F1", "f_F3", "f_F4", "f_F6", "f_F7", "f_F9"]) + self.assertIsNone(execution.session.results["f_F3"].body_id) + + def test_cpoint_direct_line_boundary_and_rejection_contract(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test/featurescript_rp" + direct = root / "0050/00503730.txt" + if not direct.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(direct.read_text(), "00503730"), {}) + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F1"]["atomic_id"], "reference_point") + self.assertEqual(features["f_F1"]["params"]["point_mm"], [0.0, 4.5, 0.0]) + self.assertEqual(features["f_F2"]["params"]["plane"]["origin_mm"], [0.0, 4.5, 0.0]) + self.assertEqual(features["f_F4"]["params"]["end_condition"]["reference"], { + "kind": "source_vertex", "source_sketch_id": "F0", + "source_entity_id": "E4.bottom.end", "point_mm": [-1.3, -4.5, 0.0], + }) + self.assertIn(("F6", "feature_deferred", "swept edge source endpoint provenance is unsupported"), { + (item.get("feature_id"), item.get("code"), item.get("message")) for item in result.diagnostics + }) + + invalid = lower_model(parse_featurescript( + direct.read_text().replace('"parameter" : 0.5', '"parameter" : 1.5', 1), "00503730"), {}) + self.assertIn(("F1", "unsupported_engine_capability", "cPoint parameter must be a finite value in [0, 1]"), { + (item.get("feature_id"), item.get("code"), item.get("message")) for item in invalid.diagnostics + }) + + rejected = root / "0023/00238956.txt" + if not rejected.exists(): self.skipTest("CADFS sample is not installed") + outcome = lower_model(parse_featurescript(rejected.read_text(), "00238956"), {}) + self.assertTrue(any( + item.get("code") == "unsupported_engine_capability" + and item.get("message") == "cPoint datum source is unsupported" + for item in outcome.diagnostics + )) + + def test_cpoint_direct_prism_swept_and_cap_edges_are_source_datums(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test/featurescript_rp" + source = root / "0028/00289068.txt" + if not source.exists(): self.skipTest("CADFS sample is not installed") + + text = source.read_text(encoding="utf-8") + result = lower_model(parse_featurescript(text, "00289068"), {}) + self.assertEqual(result.status, "converted_complete") + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F3"]["params"]["point_mm"], [25.85, -42.6, 38.1]) + self.assertEqual(features["f_F4"]["params"]["point_mm"], [25.85, -4.5, 76.2]) + self.assertEqual(features["f_F5"]["params"]["plane"]["origin_mm"], [-12.25, -42.6, 76.2]) + self.assertTrue(all("selectors" not in features[feature_id] for feature_id in ("f_F3", "f_F4"))) + + from engine.cdsl_engine.runtime import prepare_cdsl_execution + execution = prepare_cdsl_execution(result.cdsl) + execution.execute_all() + self.assertEqual(list(execution.session.results), ["f_F1", "f_F3", "f_F4", "f_F5", "f_F7"]) + + # A source pair without a unique profile vertex cannot describe the + # physical swept span. A through-all extrusion has no blind cap role. + mutations = ( + text.replace('sQuery(id+"F0.wireOp",EDGE,"E0.bottom"),sQuery(id+"F0.wireOp",EDGE,"E0.right")', + 'sQuery(id+"F0.wireOp",EDGE,"E0.bottom"),sQuery(id+"F0.wireOp",EDGE,"E0.top")', 1), + text.replace('"entities" : qUnion([Q0]), "depth" : 76.2 * mm', + '"entities" : qUnion([Q0]), "endBound" : BoundingType.THROUGH_ALL, "depth" : 76.2 * mm', 1), + ) + for index, mutated in enumerate(mutations): + with self.subTest(mutation=index): + candidate = lower_model(parse_featurescript(mutated, f"cpoint-prism-mutation-{index}"), {}) + rejected_feature = "F3" if index == 0 else "F4" + self.assertIn((rejected_feature, "unsupported_engine_capability", "cPoint datum source is unsupported"), { + (item.get("feature_id"), item.get("code"), item.get("message")) + for item in candidate.diagnostics + }) + + def test_direct_prism_cap_vertex_up_to_vertex_uses_kernel_history(self): + result = lower_model(parse_featurescript(CAP_VERTEX_UP_TO_VERTEX_SOURCE), {}) + self.assertEqual(result.status, "converted_complete") + end = {item["id"]: item for item in result.cdsl["features"]}["f_F2"]["params"]["end_condition"] + self.assertEqual(end["type"], "up_to_vertex") + reference = end["reference"] + self.assertEqual(reference["kind"], "vertex") + self.assertEqual(reference["owner_feature_id"], "f_F1") + self.assertEqual(reference["selector_intent"]["query_family"], "CAP_VERTEX") + self.assertEqual(reference["selector_intent"]["lineage_role"], "extrude.end") + self.assertEqual(reference["selector_intent"]["derivation_policy"], { + "allowed": ["boundary"], "multiplicity": "one", + }) + self.assertEqual(reference["selector_intent"]["source_entities"], [ + {"sketch_id": "F0", "entity_id": "E0"}, + {"sketch_id": "F0", "entity_id": "E1"}, + ]) + from engine.cdsl_engine.runtime import rebuild_cdsl + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(result.cdsl, Path(directory) / "cap-vertex.step") + self.assertEqual([item["status"] for item in rebuilt["feature_results"]], ["executed", "executed"]) + resolution = rebuilt["selector_resolution"][-1] + self.assertEqual(resolution["status"], "resolved") + self.assertEqual(resolution["resolution_mode"], "kernel_lineage") + self.assertEqual(resolution["evidence"]["relations"][0]["result_kind"], "vertex") + + def test_direct_source_vertex_up_to_vertex_is_a_datum_not_a_runtime_selector(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source = root / "featurescript_rp/0044/00444951.txt" + if not source.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(source.read_text(encoding="utf-8"), "00444951"), {}) + self.assertEqual(result.status, "converted_complete") + features = {item["id"]: item for item in result.cdsl["features"]} + for feature_id, source_entity_id in (("f_F5", "E0.left.end"), ("f_F7", "E6.end"), ("f_F9", "E5.end")): + reference = features[feature_id]["params"]["end_condition"]["reference"] + self.assertEqual(reference["kind"], "source_vertex") + self.assertEqual(reference["source_sketch_id"], "F0") + self.assertEqual(reference["source_entity_id"], source_entity_id) + self.assertNotIn("selector_intent", reference) + self.assertNotIn("stable_id", reference) + + from engine.cdsl_engine.runtime import rebuild_cdsl + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(result.cdsl, Path(directory) / "source-vertex-extent.step") + self.assertEqual([item["status"] for item in rebuilt["feature_results"]], ["executed"] * 6) + self.assertEqual(rebuilt["selector_resolution"], []) + + # The CADFS pipeline uses incremental selector binding before runtime + # execution. A source datum must not be handed to that binder. + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(result.cdsl, Path(directory) / "source-vertex-bound.step") + self.assertEqual(outcome["status"], "rebuilt") + self.assertTrue(all(not item["resolved"] for item in outcome["selector_binding"])) + + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + invalid = deepcopy(result.cdsl) + invalid["features"][3]["params"]["end_condition"]["reference"]["source_sketch_id"] = "missing" + with self.assertRaisesRegex(ValueError, "invalid source_vertex extent datum"): + validate_semantic_cdsl(invalid) + + wrapped = source.read_text(encoding="utf-8").replace( + 'Q1=sQuery(id+"F0.wireOp",VERTEX,"E0.left.end");', + 'Q1=qAdjacent(sQuery(id+"F0.wireOp",VERTEX,"E0.left.end"));', + 1, + ) + rejected = lower_model(parse_featurescript(wrapped, "00444951-wrapped-vertex"), {}) + self.assertIn(("F5", "feature_deferred", "topology query is unresolved"), { + (item.get("feature_id"), item.get("code"), item.get("message")) + for item in rejected.diagnostics + }) + + def test_direct_source_vertex_up_to_vertex_real_corpus_matrix(self): + """Direct source vertices remain datums across one- and two-sided extents.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test/featurescript_rp" + cases = { + "00383982": ("0038/00383982.txt", "converted_complete", 1), + "00503730": ("0050/00503730.txt", "converted_partial", 1), + "00510558": ("0051/00510558.txt", "converted_complete", 4), + "00753006": ("0075/00753006.txt", "converted_partial", 2), + "00894150": ("0089/00894150.txt", "converted_complete", 3), + "00975649": ("0097/00975649.txt", "converted_complete", 1), + } + for sample_id, (relative_path, expected_status, expected_count) in cases.items(): + with self.subTest(sample_id=sample_id): + source = root / relative_path + if not source.exists(): + self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(source.read_text(encoding="utf-8"), sample_id), {}) + references = [ + condition["reference"] + for feature in result.cdsl["features"] + for condition in ( + (feature.get("params") or {}).get("end_condition"), + (feature.get("params") or {}).get("reverse_end_condition"), + ) + if isinstance(condition, dict) + and condition.get("type") == "up_to_vertex" + and isinstance(condition.get("reference"), dict) + and condition["reference"].get("kind") == "source_vertex" + ] + self.assertEqual(result.status, expected_status) + self.assertEqual(len(references), expected_count) + self.assertTrue(all("selector_intent" not in reference for reference in references)) + + def test_cap_vertex_survives_an_exact_independent_body_member_preservation(self): + """An added independent body may retain an older member only by IsSame.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0033/00330726.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(feature.read_text(), "00330726"), {}) + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertTrue({"f_F1", "f_F3", "f_F5"}.issubset(features)) + reference = features["f_F5"]["params"]["end_condition"]["reference"] + self.assertEqual(reference["selector_intent"]["query_family"], "CAP_VERTEX") + self.assertEqual(reference["selector_intent"]["derivation_policy"], { + "allowed": ["boundary", "continuation"], "multiplicity": "one", + }) + + from engine.cdsl_engine.runtime import prepare_cdsl_execution + execution = prepare_cdsl_execution(result.cdsl) + for _index in range(3): execution.execute_next() + resolution = execution.session.selector_resolutions[-1] + self.assertEqual(resolution["status"], "resolved") + self.assertEqual(resolution["resolution_mode"], "kernel_lineage") + self.assertEqual( + [item["operation"] for item in resolution["evidence"]["relations"]], + ["extrude", "body_member_preserve"], + ) + + def test_three_point_plane_accepts_only_direct_prism_cap_vertex_source_data(self): + """CAP vertices used as datum points retain source, not snapshot, semantics.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + from engine.cdsl_engine.runtime import prepare_cdsl_execution + + for sample_id in ("00243142", "00245768"): + 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), {}) + plane = next(item for item in result.cdsl["features"] if item["id"] == "f_F2") + self.assertEqual(plane["atomic_id"], "reference_plane") + self.assertNotIn("selectors", plane) + + execution = prepare_cdsl_execution(result.cdsl) + execution.execute_next() + execution.execute_next() + self.assertEqual(execution.session.results["f_F2"].atomic_id, "reference_plane") + execution.execute_all() + self.assertTrue(all( + feature_result.status == "executed" + for feature_result in execution.session.results.values() + )) + + # This otherwise direct CAP_VERTEX is separated from its prism by a + # hole. Its source-datum frame must not pretend the pre-hole cap is a + # currently valid direct-prism point. + mutated = root / "featurescript_rp/0005/00053942.txt" + if not mutated.exists(): self.skipTest("CADFS sample is not installed") + rejected = lower_model(parse_featurescript(mutated.read_text(), "00053942"), {}) + self.assertNotIn("f_F4", {item["id"] for item in rejected.cdsl["features"]}) + self.assertIn( + ("F4", "feature_deferred", "CAP_VERTEX datum source is unsupported"), + {(item.get("feature_id"), item.get("code"), item.get("message")) for item in rejected.diagnostics}, + ) + + def test_line_angle_source_wire_axis_accepts_one_line_including_construction(self): + plane = {"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "normal": [0.0, 0.0, 1.0]} + query = Call("qBodyType", [ + Call("qCreatedBy", [Call("__binary__", ["id", "+", "F0"]), "EDGE"]), + "BodyType.WIRE", + ]) + source = {"F0": {"workplane": plane}} + construction_line = { + "type": "line", "start": [2.0, 3.0], "end": [2.0, 8.0], "construction": True, + } + kwargs = { + "featurescript_version": "1511", + "standard_library": "onshape/std/geometry.fs", + "standard_library_version": "1511.0", + } + + self.assertEqual( + _direct_line_angle_wire_axis(query, source, {"F0": {"axis": construction_line}}, **kwargs), + ([2.0, 3.0, 0.0], [0.0, 1.0, 0.0]), + ) + self.assertIsNone(_direct_line_angle_wire_axis( + query, source, {"F0": {"axis": construction_line, "extra": {**construction_line, "start": [0.0, 0.0]}}}, **kwargs, + )) + self.assertIsNone(_direct_line_angle_wire_axis( + query, source, {"F0": {"arc": {"type": "arc", "start": [0.0, 0.0], "end": [1.0, 1.0]}}}, **kwargs, + )) + self.assertIsNone(_direct_line_angle_wire_axis( + query, source, {"F0": {"axis": construction_line}}, + **{**kwargs, "featurescript_version": "1512"}, + )) + filtered = Call("qConstructionFilter", [query, "ConstructionObject.NO"]) + self.assertIsNone(_direct_line_angle_wire_axis( + filtered, source, {"F0": {"axis": construction_line}}, **kwargs, + )) + self.assertEqual( + _direct_line_angle_wire_axis( + filtered, + source, + {"F0": {"axis": construction_line, "visible": {"type": "line", "start": [5.0, 1.0], "end": [5.0, 4.0]}}}, + **kwargs, + ), + ([5.0, 1.0, 0.0], [0.0, 1.0, 0.0]), + ) + + def test_surface_loft_accepts_two_direct_closed_source_wires_without_changing_body_lifecycle(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0017/00174697.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00174697"), {}) + surface_loft = next(item for item in result.cdsl["features"] if item["id"] == "f_F3") + self.assertEqual(result.status, "converted_complete") + self.assertEqual(surface_loft["atomic_id"], "loft_surface") + self.assertEqual(len(surface_loft["params"]["profile_sketch_ids"]), 2) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "surface-loft.step") + self.assertEqual(rebuilt["status"], "rebuilt") + self.assertEqual(rebuilt["result"]["solid_count"], 0) + self.assertEqual(rebuilt["result"]["surface_count"], 1) + + def test_surface_loft_defers_when_source_wire_form_has_an_unmodeled_derivative(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0017/00174697.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + source = feature.read_text().replace( + '"wireProfilesArray" : [{ "wireProfileEntities" : qUnion([Q0]) }, { "wireProfileEntities" : qUnion([Q1]) }]', + '"startCondition" : LoftEndDerivativeType.NORMAL_TO_PROFILE, "wireProfilesArray" : [{ "wireProfileEntities" : qUnion([Q0]) }, { "wireProfileEntities" : qUnion([Q1]) }]', + ) + result = lower_model(parse_featurescript(source, "00174697-surface-loft-derivative"), {}) + self.assertIn({ + "code": "unsupported_engine_capability", + "capability": "loft_surface_wire_profiles", + "feature_id": "F3", + "operation": "loft", + "message": "current CDSL surface loft requires two direct closed source-wire profiles", + }, result.diagnostics) + + def test_direct_sketch_wire_path_accepts_only_one_nonconstruction_open_curve(self): plane = {"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "normal": [0.0, 0.0, 1.0]} line = {"type": "line", "start": [0.0, 0.0], "end": [10.0, 0.0], "source_entity_id": "E0"} query = Call("qUnion", [[ @@ -84,6 +505,318 @@ class LoweringTests(unittest.TestCase): query.args[0][0], sketches, entities, featurescript_version="1511", standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", )) + arc = { + "type": "arc", "start": [0.0, 0.0], "end": [10.0, 0.0], + "center": [5.0, 5.0], "radius_mm": math.sqrt(50.0), "clockwise": False, + } + self.assertEqual( + _direct_sketch_wire_path( + query, sketches, {"F0": {"E0": arc}}, featurescript_version="1511", + standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + ), + ("F0", "E0", arc, sketches["F0"]), + ) + self.assertIsNone(_direct_sketch_wire_path( + query, sketches, {"F0": {"E0": {"type": "circle", "center": [0.0, 0.0], "radius_mm": 5.0}}}, + featurescript_version="1511", standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + )) + + def test_direct_source_arc_sweep_preserves_cap_source_contract(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source = root / "featurescript_rp/0000/00007135.txt" + if not source.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(source.read_text(encoding="utf-8"), "00007135"), {}) + self.assertEqual(result.status, "converted_complete") + sweep = next(item for item in result.cdsl["features"] if item["id"] == "f_F2") + self.assertEqual(sweep["params"]["path"]["segment"]["type"], "arc") + self.assertEqual(sweep["params"]["cap_output_contract"], { + "profile_source": "F1", "profile_entity": "E1", + "path_source": "F0", "path_entity": "E0", "path_reversed": False, + }) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "arc-sweep-shell.step") + self.assertEqual(rebuilt["status"], "rebuild_failed") + self.assertEqual(rebuilt["last_executable_prefix"]["last_feature_id"], "f_F2") + + standalone = root / "featurescript_rp/0017/00175627.txt" + if not standalone.exists(): self.skipTest("CADFS sample is not installed") + standalone_result = lower_model(parse_featurescript(standalone.read_text(encoding="utf-8"), "00175627"), {}) + self.assertEqual(standalone_result.status, "converted_complete") + standalone_sweep = next(item for item in standalone_result.cdsl["features"] if item["id"] == "f_F2") + self.assertEqual(standalone_sweep["params"]["path"]["segment"]["type"], "arc") + with tempfile.TemporaryDirectory() as directory: + standalone_rebuilt = rebuild_candidate(standalone_result.cdsl, Path(directory) / "arc-sweep.step") + self.assertEqual(standalone_rebuilt["status"], "rebuilt") + self.assertEqual(standalone_rebuilt["result"]["feature_results"][-1]["feature_id"], "f_F2") + + def test_single_arc_source_wire_sweep_uses_the_complete_wire_selection(self): + """A one-curve qBodyType(WIRE) path is not a parser representative leaf.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + for sample_id in ("00071885", "00109840", "00627942"): + source = root / f"featurescript_rp/{sample_id[:4]}/{sample_id}.txt" + if not source.exists(): + self.skipTest("CADFS sample is not installed") + with self.subTest(sample_id=sample_id): + lowered = lower_model(parse_featurescript(source.read_text(encoding="utf-8"), sample_id), {}) + self.assertNotIn( + "sweep_path", + [item.get("capability") for item in lowered.diagnostics], + ) + # This source's profile attachment is independently + # degenerate. The path is nevertheless no longer classified + # as an unsupported query, and remains outside CDSL when no + # executable feature can be retained. + if lowered.cdsl is None: + self.assertIn("reference plane x direction is degenerate", [ + item.get("message") for item in lowered.diagnostics + ]) + continue + sweep = next(item for item in lowered.cdsl["features"] if item["id"] == "f_F2") + self.assertEqual(sweep["params"]["path"]["segment"]["type"], "arc") + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(lowered.cdsl, Path(directory) / f"{sample_id}-arc-wire.step") + self.assertIn(rebuilt["status"], {"rebuilt", "rebuild_failed"}) + self.assertNotEqual( + rebuilt.get("error", {}).get("message"), + "Feature f_F2 is not runtime eligible: sweep_path", + ) + + def test_direct_source_circle_sweep_is_closed_and_has_no_endpoint_roles(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test/featurescript_rp" + for sample_id, relative_path in ( + ("00232443", "0023/00232443.txt"), + ("00310959", "0031/00310959.txt"), + ): + source = root / relative_path + if not source.exists(): + self.skipTest("CADFS sample is not installed") + lowered = lower_model(parse_featurescript(source.read_text(encoding="utf-8"), sample_id), {}) + self.assertEqual(lowered.status, "converted_complete") + sweep = next(item for item in lowered.cdsl["features"] if item["id"] == "f_F2") + self.assertEqual(sweep["params"]["path"]["segment"]["type"], "circle") + self.assertNotIn("initial_output_roles", sweep["params"]) + self.assertNotIn("cap_output_contract", sweep["params"]) + self.assertNotIn("swept_face_contract", sweep["params"]) + self.assertNotIn("swept_edge_contract", sweep["params"]) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(lowered.cdsl, Path(directory) / f"{sample_id}-circle-sweep.step") + self.assertEqual(rebuilt["status"], "rebuilt") + self.assertEqual(rebuilt["result"]["solid_count"], 1) + + multi_query_source = (root / "0023/00232443.txt").read_text(encoding="utf-8") + multi_query_source = multi_query_source.replace( + 'Q1=sQuery(id+"F1.wireOp",EDGE,"E1");', + 'Q1=qUnion([sQuery(id+"F1.wireOp",EDGE,"E1"),sQuery(id+"F1.wireOp",EDGE,"E1")]);', + ) + rejected = lower_model(parse_featurescript(multi_query_source, "circle-multi-query"), {}) + self.assertEqual(rejected.status, "deferred_no_executable_feature") + self.assertIsNone(rejected.cdsl) + + def test_single_circle_source_wire_sweep_uses_complete_qbodytype_selection(self): + """A qBodyType circle needs complete-set proof, not a parser leaf.""" + source = ( + Path(__file__).parents[2] + / "data/cadfs-sample/CADFS_test/featurescript_rp/0049/00498974.txt" + ) + if not source.exists(): + self.skipTest("CADFS sample is not installed") + lowered = lower_model(parse_featurescript(source.read_text(encoding="utf-8"), "00498974"), {}) + self.assertNotIn("sweep_path_query", [item.get("capability") for item in lowered.diagnostics]) + self.assertIsNotNone(lowered.cdsl) + assert lowered.cdsl is not None + sweep = next(item for item in lowered.cdsl["features"] if item["id"] == "f_F2") + self.assertEqual(sweep["params"]["path"]["segment"]["type"], "circle") + self.assertNotIn("initial_output_roles", sweep["params"]) + self.assertNotIn("cap_output_contract", sweep["params"]) + self.assertNotIn("swept_face_contract", sweep["params"]) + self.assertNotIn("swept_edge_contract", sweep["params"]) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(lowered.cdsl, Path(directory) / "qbodytype-circle-sweep.step") + self.assertNotEqual( + rebuilt.get("error", {}).get("message"), + "Feature f_F2 is not runtime eligible: sweep_path_query", + ) + + def test_direct_segmented_sketch_wire_path_requires_one_open_source_wire(self): + plane = {"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "normal": [0.0, 0.0, 1.0]} + query = Call("qUnion", [[ + Call("qBodyType", [ + Call("qCreatedBy", [Call("__binary__", ["id", "+", "F0"]), "EDGE"]), + "BodyType.WIRE", + ]), + ]]) + segments = { + # Creation order is intentionally not wire order. + "E0": {"type": "line", "start": [0.0, 0.0], "end": [0.0, 4.0]}, + "E1": {"type": "line", "start": [1.0, 6.0], "end": [3.0, 10.0]}, + "E2": { + "type": "arc", "start": [0.0, 4.0], "end": [1.0, 6.0], + "center": [-5.0, 4.0], "radius_mm": 5.0, "clockwise": False, + }, + # qCreatedBy(..., EDGE) does not select this source point. + "visualSharp": {"type": "point", "point": [0.0, 5.0]}, + } + result = _direct_segmented_sketch_wire_path( + query, {"F0": {"workplane": plane}}, {"F0": segments}, + featurescript_version="1511", standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + ) + self.assertIsNotNone(result) + assert result is not None + self.assertEqual(result[0], "F0") + self.assertEqual([item["source_entity_id"] for item in result[1]], ["E0", "E2", "E1"]) + self.assertEqual([item["type"] for item in result[1]], ["line", "arc", "line"]) + + construction = {**segments, "axis": {"type": "line", "start": [0.0, 0.0], "end": [10.0, 0.0], "construction": True}} + self.assertIsNone(_direct_segmented_sketch_wire_path( + query, {"F0": {"workplane": plane}}, {"F0": construction}, + featurescript_version="1511", standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + )) + filtered_query = Call("qUnion", [[Call("qConstructionFilter", [query.args[0][0], "ConstructionObject.NO"])]] ) + self.assertIsNotNone(_direct_segmented_sketch_wire_path( + filtered_query, {"F0": {"workplane": plane}}, {"F0": construction}, + featurescript_version="1511", standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + )) + + branched = {**segments, "branch": {"type": "line", "start": [0.0, 4.0], "end": [-4.0, 5.0]}} + self.assertIsNone(_direct_segmented_sketch_wire_path( + query, {"F0": {"workplane": plane}}, {"F0": branched}, + featurescript_version="1511", standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + )) + + direct_leaf_union = Call("qUnion", [[ + Call("sQuery", ["F0.wireOp", "EDGE", "E0"]), + Call("sQuery", ["F0.wireOp", "EDGE", "E2"]), + Call("sQuery", ["F0.wireOp", "EDGE", "E1"]), + ]]) + direct_result = _direct_segmented_sketch_wire_path( + direct_leaf_union, {"F0": {"workplane": plane}}, {"F0": segments}, + featurescript_version="1511", standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + ) + self.assertIsNotNone(direct_result) + assert direct_result is not None + self.assertEqual([item["source_entity_id"] for item in direct_result[1]], ["E0", "E2", "E1"]) + + cross_sketch_union = Call("qUnion", [[ + Call("sQuery", ["F0.wireOp", "EDGE", "E0"]), + Call("sQuery", ["F1.wireOp", "EDGE", "E1"]), + ]]) + self.assertIsNone(_direct_segmented_sketch_wire_path( + cross_sketch_union, + {"F0": {"workplane": plane}, "F1": {"workplane": plane}}, + {"F0": segments, "F1": {"E1": segments["E1"]}}, + featurescript_version="1511", standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + )) + duplicate_leaf_union = Call("qUnion", [[ + Call("sQuery", ["F0.wireOp", "EDGE", "E0"]), + Call("sQuery", ["F0.wireOp", "EDGE", "E0"]), + ]]) + self.assertIsNone(_direct_segmented_sketch_wire_path( + duplicate_leaf_union, {"F0": {"workplane": plane}}, {"F0": segments}, + featurescript_version="1511", standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + )) + + def test_direct_spatial_segmented_sketch_wire_path_requires_connected_source_frames(self): + top = {"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "normal": [0.0, 0.0, 1.0]} + vertical = {"origin_mm": [10.0, 0.0, 0.0], "x_dir": [0.0, 0.0, 1.0], "normal": [0.0, 1.0, 0.0]} + + def wire(sketch_id): + return Call("qConstructionFilter", [ + Call("qBodyType", [ + Call("qCreatedBy", [Call("__binary__", ["id", "+", sketch_id]), "EDGE"]), + "BodyType.WIRE", + ]), + "ConstructionObject.NO", + ]) + + query = Call("qUnion", [[wire("F0"), wire("F1")]]) + sketches = {"F0": {"workplane": top}, "F1": {"workplane": vertical}} + entities = { + "F0": {"E0": {"type": "line", "start": [0.0, 0.0], "end": [10.0, 0.0]}}, + "F1": {"E1": {"type": "line", "start": [0.0, 0.0], "end": [10.0, 0.0]}}, + } + result = _direct_spatial_segmented_sketch_wire_path( + query, sketches, entities, + featurescript_version="1511", standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + ) + self.assertIsNotNone(result) + assert result is not None + self.assertEqual([item["source_sketch_id"] for item in result], ["F0", "F1"]) + self.assertEqual([item["source_entity_id"] for item in result], ["E0", "E1"]) + self.assertEqual(result[0]["start_mm"], [0.0, 0.0, 0.0]) + self.assertEqual(result[0]["end_mm"], [10.0, 0.0, 0.0]) + self.assertEqual(result[1]["end_mm"], [10.0, 0.0, 10.0]) + + disconnected = deepcopy(sketches) + disconnected["F1"] = {**vertical, "origin_mm": [11.0, 0.0, 0.0]} + self.assertIsNone(_direct_spatial_segmented_sketch_wire_path( + query, disconnected, entities, + featurescript_version="1511", standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + )) + repeated = Call("qUnion", [[wire("F0"), wire("F0")]]) + self.assertIsNone(_direct_spatial_segmented_sketch_wire_path( + repeated, sketches, entities, + featurescript_version="1511", standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + )) + + def test_unfiltered_source_wire_sweep_rebuilds_its_last_executable_feature(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source = root / "featurescript_rp/0088/00885126.txt" + if not source.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(source.read_text(encoding="utf-8"), "00885126"), {}) + self.assertEqual(result.status, "converted_partial") + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(set(features), {"f_F2"}) + path = features["f_F2"]["params"]["path"] + self.assertEqual([item["source_entity_id"] for item in path["segments"]], ["E0", "E2.filletArc", "E1"]) + self.assertEqual([item["type"] for item in path["segments"]], ["line", "arc", "line"]) + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(result.cdsl, Path(directory) / "rebuild.step") + self.assertEqual(outcome["status"], "rebuilt") + self.assertEqual(outcome["result"]["feature_results"][-1]["feature_id"], "f_F2") + + def test_multi_leaf_arc_wire_sweep_keeps_every_source_segment(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source = root / "featurescript_rp/0068/00684140.txt" + if not source.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(source.read_text(encoding="utf-8"), "00684140"), {}) + self.assertEqual(result.status, "converted_complete") + sweep = next(item for item in result.cdsl["features"] if item["id"] == "f_F2") + self.assertEqual( + [segment["source_entity_id"] for segment in sweep["params"]["path"]["segments"]], + ["E0", "E3.filletArc", "E1"], + ) + self.assertEqual( + [segment["type"] for segment in sweep["params"]["path"]["segments"]], + ["line", "arc", "arc"], + ) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(result.cdsl, Path(directory) / "rebuild.step") + self.assertEqual(outcome["status"], "rebuilt") + self.assertEqual(outcome["result"]["feature_results"][-1]["feature_id"], "f_F2") + + def test_cross_sketch_source_wire_sweep_rebuilds_its_executable_prefix(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source = root / "featurescript_rp/0061/00610979.txt" + if not source.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(source.read_text(encoding="utf-8"), "00610979"), {}) + self.assertEqual(result.status, "converted_partial") + sweep = next(item for item in result.cdsl["features"] if item["id"] == "f_F4") + path = sweep["params"]["path"] + self.assertNotIn("workplane", path) + self.assertEqual( + [(item["source_sketch_id"], item["source_entity_id"]) for item in path["segments"]], + [("F1", "E1"), ("F1", "E2"), ("F3", "E3"), ("F3", "E4")], + ) + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(result.cdsl, Path(directory) / "rebuild.step") + self.assertEqual(outcome["status"], "rebuilt") + self.assertEqual(outcome["result"]["feature_results"][-1]["feature_id"], "f_F4") + self.assertEqual(outcome["result"]["solid_count"], 1) def test_direct_hole_location_accepts_only_original_sketch_vertices(self): plane = {"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "normal": [0.0, 0.0, 1.0]} @@ -105,6 +838,126 @@ class LoweringTests(unittest.TestCase): self.assertIsNone(_direct_hole_location(Call("qAdjacent", [vertex("C0.center")]), sketches, entities)) self.assertIsNone(_direct_hole_location(vertex("C0.center.copy"), sketches, entities)) + def test_hole_location_wrapper_is_not_a_direct_source_vertex_query(self): + direct = Call("sQuery", ["F2.wireOp", "VERTEX", "P0"]) + self.assertTrue(_is_direct_hole_location_query(direct)) + self.assertFalse(_is_direct_hole_location_query(Call("qAdjacent", [direct]))) + + def test_hole_location_accepts_one_direct_source_arc_center(self): + plane = {"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "normal": [0.0, 0.0, 1.0]} + sketches = {"F2": {"workplane": plane}} + entities = {"F2": {"A0": { + "type": "arc", "start": [1.0, 0.0], "end": [-1.0, 0.0], "center": [0.0, 0.0], + }}} + + self.assertEqual( + _direct_hole_location(Call("sQuery", ["F2.wireOp", "VERTEX", "A0.center"]), sketches, entities), + ([0.0, 0.0, 0.0], plane), + ) + self.assertIsNone( + _direct_hole_location(Call("sQuery", ["F2.wireOp", "VERTEX", "A0.trim.center"]), sketches, entities) + ) + + def test_hole_location_arc_centers_rebuild_multiple_real_histories(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + for sample_id in ("00392565", "00091356", "00486207"): + source = next(root.glob(f"featurescript_rp/*/{sample_id}.txt"), None) + if source is None: + self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(source.read_text(encoding="utf-8"), sample_id), {}) + self.assertFalse(any( + item.get("capability") == "hole_location_vertex" + for item in result.diagnostics + ), sample_id) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(result.cdsl, Path(directory) / f"{sample_id}.step") + self.assertEqual(outcome["status"], "rebuilt", sample_id) + + def test_swept_face_host_uses_closed_contour_orientation_not_vertex_average(self): + """A valid asymmetric profile can average onto its selected boundary.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test/featurescript_rp" + expected = { + "00139790": ("f_F6", "rebuilt", "f_F7"), + "00140109": ("f_F5", "rebuilt", "f_F5"), + # F4 is independently an open, disconnected cut profile. The + # rebuilt prefix must nevertheless retain the new F3 hole. + "00140261": ("f_F3", "runtime_ineligible", "f_F3"), + } + for sample_id, (hole_id, status, last_feature_id) in expected.items(): + with self.subTest(sample_id=sample_id): + source = next(root.glob(f"*/{sample_id}.txt"), None) + if source is None: + self.skipTest("CADFS sample is not installed") + lowered = lower_model(parse_featurescript(source.read_text(encoding="utf-8"), sample_id), {}) + self.assertNotIn("swept face interior is degenerate", [ + item.get("message") for item in lowered.diagnostics + ]) + self.assertIn(hole_id, {feature["id"] for feature in lowered.cdsl["features"]}) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(lowered.cdsl, Path(directory) / "rebuild.step") + self.assertEqual(outcome["status"], status) + if status == "rebuilt": + self.assertEqual(outcome["result"]["feature_results"][-1]["feature_id"], last_feature_id) + else: + self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], last_feature_id) + + def test_hole_reports_an_unavailable_source_sketch_before_vertex_rejection(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source = root / "featurescript_rp/0054/00548761.txt" + if not source.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(source.read_text(encoding="utf-8"), "00548761"), {}) + diagnostics = [ + item for item in result.diagnostics + if item.get("feature_id") == "F5" and item.get("operation") == "hole" + ] + self.assertEqual(result.status, "converted_partial") + self.assertEqual(len(diagnostics), 1) + self.assertEqual(diagnostics[0]["capability"], "hole_location_sketch_unavailable") + self.assertEqual(diagnostics[0]["message"], "hole location source sketch is not executable") + + def test_hole_wrapper_with_an_unavailable_nested_sketch_stays_a_vertex_rejection(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source = root / "featurescript_rp/0054/00548761.txt" + if not source.exists(): self.skipTest("CADFS sample is not installed") + + history = source.read_text(encoding="utf-8").replace( + 'Q0=sQuery(id+"F4.wireOp",VERTEX,"E8.center");', + 'Q0=qAdjacent(sQuery(id+"F4.wireOp",VERTEX,"E8.center"));', + ) + result = lower_model(parse_featurescript(history, "00548761-wrapped-location"), {}) + diagnostics = [ + item for item in result.diagnostics + if item.get("feature_id") == "F5" and item.get("operation") == "hole" + ] + self.assertEqual(result.status, "converted_partial") + self.assertEqual(len(diagnostics), 1) + self.assertEqual(diagnostics[0]["capability"], "hole_location_vertex") + self.assertEqual(diagnostics[0]["message"], "hole location requires one direct original sketch vertex") + + def test_hole_scope_keeps_one_explicit_live_member_among_multiple_bodies(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0021/00219259.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), "00219259"), {}) + + hole = next(item for item in result.cdsl["features"] if item["id"] == "f_F6") + self.assertEqual(result.status, "converted_complete") + self.assertEqual(hole["atomic_id"], "hole_wizard") + self.assertEqual(hole["params"]["scope_feature_id"], "f_F1") + self.assertFalse(any( + item.get("feature_id") == "F6" and item.get("capability") == "hole_scope_body_source" + for item in result.diagnostics + )) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(result.cdsl, Path(directory) / "rebuild.step") + self.assertEqual(outcome["status"], "rebuilt") + self.assertEqual( + [item["feature_id"] for item in outcome["result"]["feature_results"]], + ["f_F1", "f_F3", "f_F6"], + ) + def test_strict_comparison_status_is_not_labeled_approximate(self): with tempfile.TemporaryDirectory() as tmp: output = Path(tmp); directory = output / "samples" / "strict-status"; directory.mkdir(parents=True) @@ -155,17 +1008,25 @@ class LoweringTests(unittest.TestCase): 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"])) if sample_id == "00000715": - self.assertTrue(all("geometry" not in item for item in fillet["selectors"])) + self.assertEqual(len(fillet["selectors"]), 1) + selector = fillet["selectors"][0] + self.assertEqual(selector["selector_intent"]["query_family"], "QUERY_SET") + self.assertEqual(selector["selector_intent"]["query_set_contract"], "proven_operand_union") + self.assertEqual(len(selector["query_operands"]), selector_count) self.assertTrue(all( item["selector_intent"]["query_family"] == "SWEPT_EDGE" - and item["selector_intent"]["derivation_policy"] == {"allowed": ["boundary"], "multiplicity": "one"} + and item["selector_intent"]["derivation_policy"] == { + "allowed": ["boundary", "continuation"], "multiplicity": "one", + } and len(item["selector_intent"]["source_entities"]) == 2 - for item in fillet["selectors"] + and item["owner_feature_id"] == fillet["depends_on"][0] + and "geometry" not in item + for item in selector["query_operands"] )) else: + 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) self.assertTrue(all( @@ -206,33 +1067,280 @@ class LoweringTests(unittest.TestCase): "message": "swept edge source endpoint provenance is unsupported", }]) - def test_full_revolve_swept_circle_edges_remain_deferred_with_prefixes(self): + def test_direct_query_intersection_and_subtraction_lower_as_proven_sets(self): + """Only direct leaf provenance may enter the S0 set-algebra bridge.""" 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), + feature = root / "featurescript_rp/0000/00000715.txt" + source = feature.read_text(encoding="utf-8") + cases = { + "intersection": source.replace("qUnion([Q0, Q1])", "qIntersection([Q0, Q1])"), + "subtraction": source.replace("qUnion([Q0, Q1])", "qSubtraction(Q0, Q1)"), + } + contracts = { + "intersection": "proven_operand_intersection", + "subtraction": "proven_operand_subtraction", + } + expected_rebuild_status = { + "intersection": "rebuild_failed", + "subtraction": "rebuilt", + } + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + + for operator, candidate in cases.items(): + with self.subTest(operator=operator): + result = lower_model(parse_featurescript(candidate, f"set-{operator}"), {}) + 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"]), 1) + selector = fillet["selectors"][0] + self.assertEqual(selector["selector_intent"]["query_set_contract"], contracts[operator]) + self.assertEqual(selector["selector_intent"]["query_expr"]["root"]["operator"], operator) + self.assertEqual(len(selector["query_operands"]), 2) + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + with tempfile.TemporaryDirectory() as directory: + rebuilt = Path(directory) / "rebuild.step" + outcome = rebuild_candidate(result.cdsl, rebuilt) + self.assertTrue(rebuilt.exists()) + self.assertEqual(outcome["status"], expected_rebuild_status[operator]) + if operator == "intersection": + self.assertEqual(outcome["error"]["message"], "f_F2: selector_query_empty during incremental replay") + self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F1") + + def test_nested_query_union_preserves_recursive_set_expression(self): + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + 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( + "qUnion([Q0, Q1])", "qUnion([Q0, qUnion([Q1, Q0])])", + ) + result = lower_model(parse_featurescript(source, "nested-query-union"), {}) + 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"]), 1) + selector = fillet["selectors"][0] + intent = selector["selector_intent"] + self.assertEqual(intent["query_set_contract"], "proven_operand_union") + self.assertEqual(intent["query_expr"]["root"]["operator"], "union") + self.assertEqual(len(selector["query_operands"]), 2) + nested = selector["query_operands"][1] + self.assertEqual(nested["selector_intent"]["query_set_contract"], "proven_operand_union") + self.assertEqual(nested["selector_intent"]["query_expr"]["root"]["operator"], "union") + self.assertEqual(len(nested["query_operands"]), 2) + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(result.cdsl, Path(directory) / "rebuild.step") + self.assertEqual(outcome["status"], "rebuilt") + + def test_1511_full_revolve_swept_edges_use_exact_vertex_history(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + for sample_id, dressup_id in ( + ("00048326", "f_F3"), + ("00025622", "f_F2"), ): 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.assertEqual(len(dressup["selectors"]), 1) + root_selector = dressup["selectors"][0] + leaves = root_selector.get("query_operands") or [root_selector] 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 + "geometry" not in selector and selector["selector_intent"]["query_family"] == "SWEPT_EDGE" - and selector["selector_intent"]["evidence"] == "feature_script_query" - and selector["selector_intent"]["derivation_policy"]["multiplicity"] == "none" - for selector in dressup["selectors"] + and selector["selector_intent"]["evidence"] == "kernel_history" + and selector["selector_intent"]["source_query"]["featurescript_version"] == "1511" + and selector["selector_intent"]["derivation_policy"] == { + "allowed": ["boundary", "continuation"], "multiplicity": "one", + } + for selector in leaves )) with tempfile.TemporaryDirectory() as directory: rebuilt = Path(directory) / "rebuild.step" outcome = rebuild_candidate(result.cdsl, rebuilt) - self.assertEqual(outcome["status"], "rebuild_failed") - self.assertEqual(outcome["error"]["message"], f"{dressup_id}: selector_query_unsupported during incremental replay") - self.assertEqual(outcome["last_executable_prefix"]["failed_feature_id"], dressup_id) + self.assertEqual(outcome["status"], "rebuilt") + resolution = next( + item for item in outcome["result"]["selector_resolution"] + if item["feature_id"] == dressup_id + ) + if root_selector.get("query_operands"): + self.assertEqual(resolution["resolution_mode"], "query_set_union") + relations = [ + relation + for operand in resolution["evidence"]["operand_resolutions"] + for relation in operand["relations"] + ] + else: + self.assertEqual(resolution["resolution_mode"], "kernel_lineage") + relations = resolution["evidence"]["relations"] + self.assertTrue(relations) + self.assertTrue(all(relation["operation"] == "revolve" for relation in relations)) + + def test_1511_full_revolve_accepts_direct_initial_dressup_only(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0040/00407186.txt" + result = lower_model(parse_featurescript(feature.read_text(), "00407186"), {}) + # Unsupported selectors are retained in the candidate as an explicit + # non-executable source-query contract. This does not hide the + # earlier F1/F2 prefix or turn the later revolve BLEND_EDGE tuple into + # a geometry selector at lowering time. + self.assertEqual(result.status, "converted_complete") + deferred = next(item for item in result.cdsl["features"] if item["id"] == "f_F5") + intent = deferred["selectors"][0]["selector_intent"] + self.assertEqual(intent["query_family"], "BLEND_EDGE") + self.assertEqual(intent["evidence"], "feature_script_query") + self.assertEqual(intent["derivation_policy"], {"allowed": ["continuation"], "multiplicity": "none"}) + prefix = deepcopy(result.cdsl) + prefix["features"] = result.cdsl["features"][:2] + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(prefix, Path(directory) / "rebuild.step") + self.assertEqual(outcome["status"], "rebuilt") + resolution = outcome["result"]["selector_resolution"][0] + self.assertEqual(resolution["resolution_mode"], "kernel_lineage") + self.assertEqual(resolution["evidence"]["relations"][0]["operation"], "revolve") + + def test_1511_direct_prism_blend_edge_requires_complete_immediate_tuple(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0041/00414347.txt" + source = feature.read_text(encoding="utf-8") + result = lower_model(parse_featurescript(source, "00414347"), {}) + + self.assertEqual(result.status, "converted_complete") + fillet = next(item for item in result.cdsl["features"] if item["id"] == "f_F3") + selector = fillet["selectors"][0] + intent = selector["selector_intent"] + self.assertEqual(intent["query_family"], "BLEND_EDGE") + self.assertEqual(intent["evidence"], "kernel_history") + self.assertEqual(intent["derivation_policy"], {"allowed": ["boundary"], "multiplicity": "one"}) + self.assertEqual(intent["blend_sources"], { + "edge": { + "query_family": "CAP_EDGE", "owner_feature_id": "f_F1", + "source_entity": {"sketch_id": "F0", "entity_id": "E0.right"}, + "lineage_role": "extrude.end", + }, + "face": { + "query_family": "CAP_FACE", "owner_feature_id": "f_F1", + "output_role": "extrude.end", + }, + }) + self.assertFalse(any(key in selector for key in ( + "stable_id", "snapshot_id", "geometry", "binding_feature_id", "output_role", + ))) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(result.cdsl, Path(directory) / "rebuild.step") + self.assertEqual(outcome["status"], "rebuilt") + resolution = next( + item for item in outcome["result"]["selector_resolution"] + if item["feature_id"] == "f_F3" + ) + self.assertEqual(resolution["resolution_mode"], "blend_boundary") + self.assertEqual(len(resolution["evidence"]["source_records"]), 2) + self.assertEqual(len(resolution["evidence"]["result_records"]), 1) + + # One incomplete source cap set, a changed blendedInto role, or an + # additional blendedFrom member must stay non-executable. Each + # mutation remains valid FeatureScript; rejection is contract-based. + mutations = ( + source.replace('sQuery(id+"F0.wireOp",EDGE,"E0.top"),', "", 1), + source.replace( + 'sQuery(id+"F0.wireOp",EDGE,"E0.bottom"),sQuery(id+"F0.wireOp",EDGE,"E0.top"),sQuery(id+"F0.wireOp",EDGE,"E0.left"),subQ0])],"isStart":false})]});}', + 'sQuery(id+"F0.wireOp",EDGE,"E0.bottom"),sQuery(id+"F0.wireOp",EDGE,"E0.top"),sQuery(id+"F0.wireOp",EDGE,"E0.left"),subQ0])],"isStart":true})]});}', + 1, + ), + source.replace( + '"isStart":false})],"blendedInto"', + '"isStart":false}),makeQuery(id+"F1.opExtrude","CAP_EDGE",EDGE,{"disambiguationData":[OSD([subQ0])],"isStart":false})],"blendedInto"', + 1, + ), + ) + for index, mutated in enumerate(mutations): + with self.subTest(mutation=index): + candidate = lower_model(parse_featurescript(mutated, f"blend-edge-mutation-{index}"), {}) + deferred = next(item for item in candidate.cdsl["features"] if item["id"] == "f_F3") + deferred_intent = deferred["selectors"][0]["selector_intent"] + self.assertEqual(deferred_intent["query_family"], "BLEND_EDGE") + self.assertEqual(deferred_intent["evidence"], "feature_script_query") + self.assertNotIn("blend_sources", deferred_intent) + + def test_1511_direct_prism_blend_edge_accepts_exact_swept_face_pair_only(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + cases = (("00596552", "f_F5", 2), ("00456146", "f_F4", 1)) + for sample_id, feature_id, expected_blends in cases: + with self.subTest(sample_id=sample_id): + source = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt" + result = lower_model(parse_featurescript(source.read_text(encoding="utf-8"), sample_id), {}) + feature = next(item for item in result.cdsl["features"] if item["id"] == feature_id) + leaves = feature["selectors"][0].get("query_operands") or feature["selectors"] + blends = [item for item in leaves if item["selector_intent"]["query_family"] == "BLEND_EDGE"] + self.assertEqual(len(blends), expected_blends) + for selector in blends: + intent = selector["selector_intent"] + self.assertEqual(intent["evidence"], "kernel_history") + self.assertEqual(intent["blend_sources"]["face"]["query_family"], "SWEPT_FACE") + self.assertEqual( + intent["blend_sources"]["face"]["source_entity"], + intent["blend_sources"]["edge"]["source_entity"], + ) + + invalid = deepcopy(result.cdsl) + invalid_feature = next(item for item in invalid["features"] if item["id"] == feature_id) + invalid_leaf = next(item for item in (invalid_feature["selectors"][0].get("query_operands") or invalid_feature["selectors"]) + if item["selector_intent"]["query_family"] == "BLEND_EDGE") + invalid_leaf["selector_intent"]["blend_sources"]["face"]["source_entity"] = { + "sketch_id": "F0", "entity_id": "unrelated", + } + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + with self.assertRaisesRegex(ValueError, "swept-face source must match"): + validate_semantic_cdsl(invalid) + + def test_1511_direct_prism_blend_face_sketch_uses_runtime_patch_attachment(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + cases = (("00313870", {"sketch_F3", "sketch_F4"}),) + for sample_id, expected_sketches in cases: + with self.subTest(sample_id=sample_id): + source = root / f"featurescript_rp/{sample_id[:4]}/{sample_id}.txt" + result = lower_model(parse_featurescript(source.read_text(encoding="utf-8"), sample_id), {}) + attached = { + sketch["id"]: sketch["attachment"] + for sketch in result.cdsl["geometry"]["sketches"] + if isinstance(sketch.get("attachment"), dict) + and sketch["attachment"].get("selector_intent", {}).get("query_family") == "BLEND_FACE" + } + self.assertEqual(set(attached), expected_sketches) + for attachment in attached.values(): + intent = attachment["selector_intent"] + self.assertEqual(intent["evidence"], "kernel_history") + self.assertEqual(intent["blend_face_source"]["query_family"], "CAP_EDGE") + self.assertNotIn("geometry", attachment) + self.assertNotIn("stable_id", attachment) + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + tampered = deepcopy(result.cdsl) + tampered_attachment = next( + sketch for sketch in tampered["geometry"]["sketches"] + if sketch.get("attachment", {}).get("selector_intent", {}).get("query_family") == "BLEND_FACE" + )["attachment"] + tampered_attachment["selector_intent"]["blend_face_source"]["source_entity"]["entity_id"] = "unrelated" + with self.assertRaisesRegex(ValueError, "direct new-body blind prism"): + validate_semantic_cdsl(tampered) + + # F2 is explicit ADD/fuse here, so its CAP edge cannot be promoted to + # the independent direct-prism BLEND_FACE host contract. + fused = root / "featurescript_rp/0043/00436592.txt" + fused_result = lower_model(parse_featurescript(fused.read_text(encoding="utf-8"), "00436592"), {}) + self.assertFalse(any( + sketch.get("attachment", {}).get("selector_intent", {}).get("query_family") == "BLEND_FACE" + for sketch in fused_result.cdsl["geometry"]["sketches"] + )) + + # A symmetric prism uses the same outer query spelling, but it is not + # the direct blind-prism lifecycle represented by this contract. + rejected = root / "featurescript_rp/0058/00588828.txt" + result = lower_model(parse_featurescript(rejected.read_text(encoding="utf-8"), "00588828"), {}) + self.assertFalse(any( + sketch.get("attachment", {}).get("selector_intent", {}).get("query_family") == "BLEND_FACE" + for sketch in result.cdsl["geometry"]["sketches"] + )) def test_full_revolve_swept_circle_requires_one_direct_shared_source_endpoint(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -251,6 +1359,49 @@ class LoweringTests(unittest.TestCase): "message": "swept edge source endpoint provenance is unsupported", }, result.diagnostics) + def test_2491_full_revolve_swept_edge_uses_exact_vertex_history(self): + """A 2491 full revolve must bind from MakeRevol vertex history only.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0040/00404735.txt" + result = lower_model(parse_featurescript(feature.read_text(), "00404735"), {}) + + # F2's source pair crosses intervening profile entities and therefore + # does not identify one direct vertex. F3 independently does, so its + # executable prefix is retained without weakening that rejection. + self.assertEqual(result.status, "converted_partial") + self.assertIn({ + "code": "feature_deferred", + "feature_id": "F2", + "operation": "chamfer", + "message": "swept edge source endpoint provenance is unsupported", + }, result.diagnostics) + chamfer = next(item for item in result.cdsl["features"] if item["id"] == "f_F3") + selector = chamfer["selectors"][0] + self.assertNotIn("geometry", selector) + intent = selector["selector_intent"] + self.assertEqual(intent["query_family"], "SWEPT_EDGE") + self.assertEqual(intent["evidence"], "kernel_history") + self.assertEqual(intent["source_query"]["featurescript_version"], "2491") + self.assertEqual(intent["source_query"]["standard_library_version"], "2491.0") + self.assertEqual(intent["source_entities"], [ + {"sketch_id": "F0", "entity_id": "E0"}, + {"sketch_id": "F0", "entity_id": "E5"}, + ]) + self.assertEqual(intent["derivation_policy"], { + "allowed": ["boundary", "continuation"], "multiplicity": "one", + }) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(result.cdsl, Path(directory) / "rebuild.step") + self.assertEqual(outcome["status"], "rebuilt") + resolution = outcome["result"]["selector_resolution"][0] + self.assertEqual(resolution["resolution_mode"], "kernel_lineage") + self.assertEqual(resolution["evidence"]["semantic_anchor"], { + "type": "source_vertex", + "source_entities": intent["source_entities"], + }) + self.assertEqual(resolution["evidence"]["relations"][0]["operation"], "revolve") + self.assertEqual(resolution["evidence"]["relations"][0]["derivation"], "boundary") + def test_equivalent_imprint_profile_preserves_but_does_not_bind_full_revolve_queries(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" source = root / "featurescript_rp/0011/00112257.txt" @@ -290,6 +1441,89 @@ class LoweringTests(unittest.TestCase): self.assertIsNone(result.cdsl) self.assertEqual(result.diagnostics[0]["code"], "unsupported_operation") + def test_unknown_direct_context_operation_is_reported_not_silently_dropped(self): + source = SOURCE.replace( + '\n});\n', + '\n unknownOperation(context, id + "F2", {"value":10 * mm});\n});\n', + ) + result = lower_model(parse_featurescript(source, "assign-variable"), {}) + self.assertEqual(result.status, "converted_partial") + self.assertIn( + {"code": "unsupported_operation", "feature_id": "F2", "operation": "unknownOperation"}, + result.diagnostics, + ) + + def test_assign_variable_resolves_ordered_source_get_variable_calls(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0006/00067276.txt" + if not feature.exists(): self.skipTest("CADFS assignVariable sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), "00067276"), {}) + variables = [item for item in result.cdsl["features"] if item["atomic_id"] == "assign_variable"] + self.assertEqual([item["params"] for item in variables], [ + {"name": "thickness", "value": 4.0, "value_kind": "any"}, + {"name": "nut_height", "value": 3.2, "value_kind": "any"}, + ]) + self.assertFalse(any(diagnostic.get("operation") == "assignVariable" for diagnostic in result.diagnostics)) + + def test_assign_variable_rejects_missing_lookup_redeclaration_and_ambiguous_value(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test/featurescript_rp" + source_path = root / "0006/00067276.txt" + if not source_path.exists(): self.skipTest("CADFS assignVariable sample is not installed") + source = source_path.read_text(encoding="utf-8") + + missing = lower_model(parse_featurescript( + source.replace("getVariable(context, 'thickness')", "getVariable(context, 'missing')", 1), "00067276"), {}) + self.assertIn(("F3", "feature_deferred", "source variable is unavailable: missing"), { + (item.get("feature_id"), item.get("code"), item.get("message")) for item in missing.diagnostics + }) + + redeclared = lower_model(parse_featurescript( + source.replace('"name" : "nut_height"', '"name" : "thickness"', 1), "00067276"), {}) + self.assertIn(("F1", "unsupported_engine_capability", "assignVariable redeclares source variable thickness"), { + (item.get("feature_id"), item.get("code"), item.get("message")) for item in redeclared.diagnostics + }) + + ambiguous = lower_model(parse_featurescript( + source.replace('"anyValue" : 4', '"anyValue" : 4, "lengthValue" : 4 * mm', 1), "00067276"), {}) + self.assertIn(("F0", "unsupported_engine_capability", "assignVariable requires exactly one anyValue or lengthValue source expression"), { + (item.get("feature_id"), item.get("code"), item.get("message")) for item in ambiguous.diagnostics + }) + + def test_assign_variable_does_not_break_an_immediate_cap_edge_consumer(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test/featurescript_rp" + source_path = root / "0002/00021014.txt" + if not source_path.exists(): self.skipTest("CADFS CAP_EDGE sample is not installed") + source = source_path.read_text(encoding="utf-8") + source = source.replace( + ' {\n var Q0;\n Q0=makeQuery(id+"F1.opExtrude",', + ' { assignVariable(context, id + "F1variable", {"name" : "radius", "anyValue" : 5.08}); }\n' + ' {\n var Q0;\n Q0=makeQuery(id+"F1.opExtrude",', + 1, + ) + result = lower_model(parse_featurescript(source, "00021014"), {}) + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F1variable"]["atomic_id"], "assign_variable") + self.assertEqual(features["f_F2"]["depends_on"], ["f_F1"]) + + from engine.cdsl_engine.runtime import prepare_cdsl_execution + execution = prepare_cdsl_execution(result.cdsl) + self.assertTrue(execution.analysis.feature_results[-1].executable) + + def test_signed_parenthesized_transform_translation_lowers_without_a_parser_defer(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test/featurescript_rp" + source_path = root / "0029/00292242.txt" + if not source_path.exists(): self.skipTest("CADFS signed transform sample is not installed") + result = lower_model(parse_featurescript(source_path.read_text(encoding="utf-8"), "00292242"), {}) + # This is a non-copy move of the immediately preceding independent + # source body, so the existing contract bakes it into its sketch + # frame instead of creating a second body-graph feature. + self.assertEqual([item["id"] for item in result.cdsl["features"]], ["f_F1"]) + self.assertEqual(result.cdsl["geometry"]["sketches"][0]["workplane"]["origin_mm"], [-69.3, 0.0, 0.0]) + self.assertFalse(any( + item.get("feature_id") == "F2" and item.get("operation") == "transform" + for item in result.diagnostics + )) + def test_direct_translation_transform_is_baked_into_the_source_feature(self): result = lower_model(parse_featurescript(TRANSFORM_SOURCE, "transform"), {}) self.assertEqual(result.status, "converted_complete") @@ -337,10 +1571,10 @@ class LoweringTests(unittest.TestCase): 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"}, + # F3 is a typed ADD fuse. F4 therefore targets its active F3 + # successor while preserving F1 as an explicit source alias. + # F11 remains an identity copy of its direct F10 source. + "00981258": {"f_F4": "f_F1", "f_F11": "f_F10"}, } for sample_id, expected_sources in cases.items(): with self.subTest(sample_id=sample_id): @@ -355,11 +1589,18 @@ class LoweringTests(unittest.TestCase): for item in result.diagnostics )) for feature_id, source_feature_id in expected_sources.items(): - self.assertEqual(transforms[feature_id]["params"], { + expected = { "source_feature_ids": [source_feature_id], "transform": {"type": "translation", "translation_mm": [0.0, 0.0, 0.0]}, "make_copy": True, - }) + } + if sample_id == "00981258" and feature_id == "f_F4": + expected["source_feature_ids"] = ["f_F3"] + expected["source_member_aliases"] = [{ + "source_feature_id": "f_F1", + "active_member_feature_id": "f_F3", + }] + self.assertEqual(transforms[feature_id]["params"], expected) def test_transform_lowers_one_proven_mirror_copy_member(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -399,6 +1640,35 @@ class LoweringTests(unittest.TestCase): ]) self.assertEqual(transforms["f_F4"]["depends_on"], ["f_F1", "f_F2", "f_F3"]) + def test_transform_copy_chain_retains_semantic_source_and_active_member(self): + """COPY owns the original CADFS body while executing its live member.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source = root / "featurescript_rp/0018/00184423.txt" + if not source.exists(): + self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(source.read_text(), "00184423"), {}) + 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_member_aliases"], [{ + "source_feature_id": "f_F1", "active_member_feature_id": "f_F2", + }]) + self.assertEqual(transforms["f_F4"]["params"]["source_member_aliases"], [{ + "source_feature_id": "f_F1", "active_member_feature_id": "f_F3", + }]) + self.assertEqual(transforms["f_F5"]["params"]["source_member_aliases"], [{ + "source_feature_id": "f_F1", "active_member_feature_id": "f_F4", + }]) + # The alias is source provenance. Replacing its selected runtime + # member cannot silently redirect a transform to another body. + invalid = deepcopy(result.cdsl) + invalid_feature = next(item for item in invalid["features"] if item["id"] == "f_F3") + invalid_feature["params"]["source_member_aliases"][0]["active_member_feature_id"] = "f_F1" + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + with self.assertRaisesRegex(ValueError, "does not bind one preceding semantic source"): + validate_semantic_cdsl(invalid) + 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', @@ -508,12 +1778,9 @@ class LoweringTests(unittest.TestCase): 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 - )) + self.assertNotIn("f_F7", {feature["id"] for feature in result.cdsl["features"]}) + diagnostic = next(item for item in result.diagnostics if item.get("feature_id") == "F7") + self.assertEqual(diagnostic["message"], "pattern copy source feature is not replayed by its owner") def test_translation_distance_lowers_from_a_linear_sketch_direction(self): source = TRANSFORM_SOURCE.replace( @@ -746,6 +2013,47 @@ class LoweringTests(unittest.TestCase): self.assertEqual(feature["params"]["distance_mm"], 60.0) self.assertEqual(feature["params"]["reverse_distance_mm"], 60.0) + def test_new_body_operation_only_new_emits_new_body_result_mode(self): + """FeatureScript ADD uses the CDSL fuse path; only NEW creates a member.""" + new = lower_model(parse_featurescript( + SOURCE.replace('"depth":120 * mm', '"operationType":NewBodyOperationType.NEW, "depth":120 * mm'), + "explicit-new", + ), {}) + add = lower_model(parse_featurescript( + SOURCE.replace('"depth":120 * mm', '"operationType":NewBodyOperationType.ADD, "depth":120 * mm'), + "explicit-add", + ), {}) + self.assertEqual(new.cdsl["features"][0]["params"].get("result_mode"), "new_body") + self.assertNotIn("result_mode", add.cdsl["features"][0]["params"]) + + def test_typed_add_sweep_and_revolve_do_not_emit_new_body_result_mode(self): + """The shared ADD lifecycle rule applies to every additive producer.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test/featurescript_rp" + sweep_source = root / "0000/00007135.txt" + revolve_source = root / "0004/00048326.txt" + if not sweep_source.exists() or not revolve_source.exists(): + self.skipTest("CADFS sweep/revolve source samples are not installed") + sweep = lower_model(parse_featurescript( + sweep_source.read_text(encoding="utf-8").replace( + '"path" : qUnion([Q1])', + '"path" : qUnion([Q1]), "operationType" : NewBodyOperationType.ADD', + 1, + ), + "00007135-typed-add", + ), {}) + revolve = lower_model(parse_featurescript( + revolve_source.read_text(encoding="utf-8").replace( + '"revolveType" : RevolveType.FULL', + '"revolveType" : RevolveType.FULL, "operationType" : NewBodyOperationType.ADD', + 1, + ), + "00048326-typed-add", + ), {}) + sweep_feature = next(item for item in sweep.cdsl["features"] if item["atomic_id"] == "sweep_add") + revolve_feature = next(item for item in revolve.cdsl["features"] if item["atomic_id"] == "revolve_add") + self.assertNotIn("result_mode", sweep_feature["params"]) + self.assertNotIn("result_mode", revolve_feature["params"]) + def test_through_all_extrudes_reuse_the_engine_extent_contract(self): for operation_type, expected_atomic in [ ("NewBodyOperationType.NEW", "extrude_add_blind"), @@ -829,6 +2137,265 @@ class LoweringTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "invalid SWEPT_BODY member contract"): validate_semantic_cdsl(mixed_evidence) + def test_primary_cut_copy_cap_edge_chamfer_uses_exact_copy_lineage(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0025/00252195.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), "00252195"), {}) + chamfer = next(item for item in result.cdsl["features"] if item["id"] == "f_F4") + selector = chamfer["selectors"][0] + self.assertEqual(selector["selector_intent"]["query_family"], "COPY") + self.assertEqual(selector["selector_intent"]["copy_contract"], "primary_cut_cap_edge") + self.assertEqual(selector["query_input"]["selector_intent"]["query_family"], "CAP_EDGE") + self.assertNotIn("stable_id", selector) + self.assertNotIn("geometry", selector) + + prefix = deepcopy(result.cdsl) + prefix["features"] = [ + item for item in prefix["features"] if item["id"] in {"f_F1", "f_F3", "f_F4"} + ] + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(prefix, Path(directory) / "copy-primary-cut-cap-edge.step") + self.assertEqual(rebuilt["status"], "rebuild_failed") + self.assertEqual(rebuilt["error"]["type"], "RuntimeExecutionError") + resolution = rebuilt["error"]["selector_resolutions"][0] + self.assertEqual(resolution["status"], "resolved") + self.assertEqual(resolution["resolution_mode"], "copy_lineage") + self.assertEqual([relation["operation"] for relation in resolution["evidence"]["relations"]], [ + "extrude", "subtract", + ]) + + def test_boolean_copy_face_workplane_does_not_reuse_its_nested_cap_frame(self): + """COPY(FACE) needs its own runtime provenance, not a nested CAP frame.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0025/00252794.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), "00252794"), {}) + self.assertEqual(result.status, "converted_partial") + self.assertTrue(any( + diagnostic["code"] == "sketch_deferred" + and diagnostic["feature_id"] == "F8" + and "COPY(FACE) workplane requires a dedicated complete/proven runtime face relation" in diagnostic["message"] + for diagnostic in result.diagnostics + )) + self.assertFalse(any(sketch["id"] == "sketch_F8" for sketch in result.cdsl["geometry"]["sketches"])) + self.assertFalse(any(feature["id"] == "f_F9" for feature in result.cdsl["features"])) + + # A second primary-cut CAP-face source proves this is an outer COPY + # contract, rather than a sample-specific F3/F8 coordinate rule. + second_feature = root / "featurescript_rp/0025/00252895.txt" + if not second_feature.exists(): self.skipTest("second CADFS sample is not installed") + second = lower_model(parse_featurescript(second_feature.read_text(encoding="utf-8"), "00252895"), {}) + self.assertEqual(second.status, "converted_partial") + self.assertTrue(any( + diagnostic["code"] == "sketch_deferred" + and diagnostic["feature_id"] == "F4" + and "COPY(FACE) workplane requires a dedicated complete/proven runtime face relation" in diagnostic["message"] + for diagnostic in second.diagnostics + )) + + def test_immediate_primary_cut_copy_cap_face_workplane_uses_runtime_attachment(self): + """COPY(CAP_FACE) attaches only through the exact cut successor.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + cases = ( + ("00573124", "f_F5", "hole_wizard"), + ("00951631", "f_F5", "extrude_cut_blind"), + ) + for sample_id, consumer_id, atomic_id in cases: + with self.subTest(sample_id=sample_id): + feature = root / f"featurescript_rp/{sample_id[:4]}/{sample_id}.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), sample_id), {}) + sketch = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F4") + attachment = sketch["attachment"] + self.assertEqual(attachment["selector_intent"]["copy_contract"], "primary_cut_cap_face_workplane") + self.assertEqual(attachment["query_input"]["selector_intent"]["query_family"], "CAP_FACE") + self.assertNotIn("geometry", attachment) + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + tampered = deepcopy(result.cdsl) + tampered_attachment = next( + item for item in tampered["geometry"]["sketches"] if item["id"] == "sketch_F4" + )["attachment"] + tampered_attachment["query_input"]["selector_intent"]["source_entities"][0]["entity_id"] = "unrelated" + with self.assertRaisesRegex(ValueError, "complete direct primary-cut source-profile edge set"): + validate_semantic_cdsl(tampered) + consumer = next(item for item in result.cdsl["features"] if item["id"] == consumer_id) + self.assertEqual(consumer["atomic_id"], atomic_id) + if atomic_id == "hole_wizard": + self.assertEqual(consumer["params"]["host_face"], attachment) + prefix = deepcopy(result.cdsl) + prefix["features"] = [ + item for item in prefix["features"] if item["id"] in {"f_F1", "f_F3", consumer_id} + ] + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(prefix, Path(directory) / f"{sample_id}.step") + self.assertEqual(rebuilt["status"], "rebuilt") + resolution = next( + item for item in rebuilt["result"]["selector_resolution"] + if item["feature_id"] == consumer_id + ) + self.assertEqual(resolution["resolution_mode"], "copy_lineage") + self.assertEqual([relation["operation"] for relation in resolution["evidence"]["relations"]], [ + "extrude", "subtract", + ]) + + def test_direct_prism_cap_face_workplane_stays_runtime_attached_00126630(self): + """A direct CAP host is not lowered into a static F2 workplane.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0012/00126630.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), "00126630"), {}) + attachment = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F2")["attachment"] + self.assertEqual(attachment["kind"], "face") + self.assertEqual(attachment["owner_feature_id"], "f_F1") + self.assertEqual(attachment["output_role"], "extrude.start") + self.assertEqual(attachment["selector_intent"]["consumer_contract"], "direct_prism_cap_face_workplane") + self.assertNotIn("geometry", attachment) + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + + imprint = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F2__f_F3") + self.assertEqual(imprint["profile"]["type"], "planar_imprint") + self.assertEqual(imprint["profile"]["selections"][0]["fragment"]["external_anchor_id"], "cap_boundary_0") + self.assertEqual(imprint["profile"]["external_anchors"][0]["selector"]["selector_intent"]["query_family"], "CAP_EDGE") + + # F1 -> F3 proves the attached CAP-face and its exact CAP-edge + # boundary resolve before the later independent F4 dress-up gap. + executable = deepcopy(result.cdsl) + executable["features"] = [ + item for item in executable["features"] if item["id"] in {"f_F1", "f_F3"} + ] + from engine.cdsl_engine.runtime import prepare_cdsl_execution + execution = prepare_cdsl_execution(executable) + self.assertTrue(execution.analysis.runtime_eligible) + execution.execute_all() + resolutions = [item for item in execution.session.selector_resolutions if item["feature_id"] == "f_F3"] + self.assertEqual([item["resolution_mode"] for item in resolutions], ["operation_role", "kernel_lineage"]) + self.assertTrue(all(item["status"] == "resolved" for item in resolutions)) + + delayed = deepcopy(executable) + blocker = deepcopy(delayed["features"][0]) + blocker["id"] = "f_blocker" + blocker["name"] = "blocker" + delayed["features"] = [delayed["features"][0], blocker, delayed["features"][1]] + with self.assertRaisesRegex(ValueError, "CAP_FACE attachment must be consumed immediately"): + validate_semantic_cdsl(delayed) + + def test_attached_cap_edge_imprint_requires_the_attachment_owner_00126630(self): + """An external CAP edge cannot be substituted from another producer.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0012/00126630.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + mismatched = feature.read_text(encoding="utf-8").replace( + 'makeQuery(id+"F1.opExtrude","CAP_EDGE"', + 'makeQuery(id+"F99.opExtrude","CAP_EDGE"', + ) + result = lower_model(parse_featurescript(mismatched, "00126630-owner-mismatch"), {}) + self.assertEqual(result.status, "converted_partial") + self.assertEqual([item["id"] for item in result.cdsl["features"]], ["f_F1"]) + diagnostic = next(item for item in result.diagnostics if item["feature_id"] == "F3") + self.assertEqual(diagnostic["capability"], "extrude_profile_topology:cap_edge") + + def test_attached_cap_edge_imprint_rejects_other_fragment_and_consumer_forms(self): + """The singleton circle/new-body contract does not absorb nearby forms.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + cases = ("00612552", "00183004", "00880585") + for sample_id in cases: + with self.subTest(sample_id=sample_id): + feature = root / f"featurescript_rp/{sample_id[:4]}/{sample_id}.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), sample_id), {}) + self.assertFalse(any( + isinstance(sketch, dict) and (sketch.get("profile") or {}).get("external_anchors") + for sketch in result.cdsl["geometry"]["sketches"] + )) + diagnostic = next(item for item in result.diagnostics if item["feature_id"] == "F3") + self.assertEqual(diagnostic["capability"], "extrude_profile_topology:cap_edge") + + def test_immediate_primary_cut_copy_swept_face_workplane_uses_runtime_attachment(self): + """COPY(SWEPT_FACE) follows the exact tool side face, never its frame.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + cases = (("00321940", "sketch_F4", "f_F5"), ("00171671", "sketch_F4", "f_F5")) + for sample_id, sketch_id, consumer_id in cases: + with self.subTest(sample_id=sample_id): + feature = root / f"featurescript_rp/{sample_id[:4]}/{sample_id}.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), sample_id), {}) + attachment = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == sketch_id)["attachment"] + self.assertEqual(attachment["selector_intent"]["copy_contract"], "primary_cut_swept_face_workplane") + self.assertEqual(attachment["query_input"]["selector_intent"]["query_family"], "SWEPT_FACE") + self.assertNotIn("geometry", attachment) + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + tampered = deepcopy(result.cdsl) + tampered_attachment = next(item for item in tampered["geometry"]["sketches"] if item["id"] == sketch_id)["attachment"] + tampered_attachment["query_input"]["selector_intent"]["source_entity"]["entity_id"] = "unrelated" + with self.assertRaisesRegex(ValueError, "one direct primary-cut source-profile edge"): + validate_semantic_cdsl(tampered) + prefix = deepcopy(result.cdsl) + prefix["features"] = [ + item for item in prefix["features"] if item["id"] in {"f_F1", "f_F3", consumer_id} + ] + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(prefix, Path(directory) / f"{sample_id}.step") + if sample_id == "00321940": + self.assertEqual(rebuilt["status"], "rebuilt") + resolution = next(item for item in rebuilt["result"]["selector_resolution"] if item["feature_id"] == consumer_id) + self.assertEqual(resolution["resolution_mode"], "copy_lineage") + self.assertEqual([relation["operation"] for relation in resolution["evidence"]["relations"]], ["extrude", "subtract"]) + else: + self.assertEqual(rebuilt["status"], "rebuilt") + self.assertEqual( + [item["feature_id"] for item in rebuilt["result"]["feature_results"]], + ["f_F1", "f_F3"], + ) + self.assertFalse(any( + item["feature_id"] == consumer_id + for item in rebuilt["result"]["selector_resolution"] + )) + + def test_primary_cut_copy_swept_face_requires_a_unique_active_successor(self): + """A real direct source still rejects when OCC reports no continuation.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0032/00326645.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), "00326645"), {}) + attachment = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F4")["attachment"] + self.assertEqual(attachment["selector_intent"]["copy_contract"], "primary_cut_swept_face_workplane") + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "00326645.step") + self.assertEqual(rebuilt["status"], "rebuild_failed") + resolution = rebuilt["error"]["selector_resolutions"][0] + self.assertEqual(resolution["diagnostic"]["code"], "selector_copy_kernel_history_missing") + + def test_up_to_body_q_owner_body_projects_a_direct_swept_face(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0069/00694309.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + source = feature.read_text().replace( + 'Q1=makeQuery(id+"F1.opExtrude","SWEPT_BODY",BODY,{"disambiguationData":[OSD([sQuery(id+"F0.wireOp",EDGE,"E0")])]});', + 'Q1=qOwnerBody(makeQuery(id+"F1.opExtrude","SWEPT_FACE",FACE,{"disambiguationData":[OSD([sQuery(id+"F0.wireOp",EDGE,"E0")])]}));', + ).replace('"endBoundEntityBody" : qUnion([Q1])', '"endBoundEntityBody" : Q1') + result = lower_model(parse_featurescript(source, "00694309-owner-body"), {}) + + self.assertEqual(result.status, "converted_complete") + reference = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")["params"]["end_condition"]["reference"] + self.assertEqual(reference["kind"], "body") + self.assertEqual(reference["selector_intent"]["query_family"], "OWNER_BODY") + self.assertEqual(reference["selector_intent"]["owner_body_contract"], "exact_input_owner") + self.assertEqual(reference["selector_intent"]["query_expr"]["root"]["filter"], "owner_body") + self.assertEqual(reference["query_input"]["selector_intent"]["query_family"], "SWEPT_FACE") + + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "owner-body.step") + self.assertEqual(rebuilt["status"], "rebuilt") + def test_up_to_surface_extrude_captures_the_cap_face(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0092/00925274.txt" @@ -860,6 +2427,93 @@ class LoweringTests(unittest.TestCase): mirror = next(item for item in result.cdsl["features"] if item["id"] == "f_F18") self.assertTrue(mirror["params"]["mirror_current_body"]) + def test_up_to_surface_can_consume_an_immediate_imprint_prism_retained_wall(self): + """One unchanged selected source edge may prove its own prism wall.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0040/00408613.txt" + if not source_path.exists(): self.skipTest("CADFS sample is not installed") + source = source_path.read_text(encoding="utf-8") + result = lower_model(parse_featurescript(source, "00408613"), {}) + self.assertEqual(result.status, "converted_complete") + feature = next(item for item in result.cdsl["features"] if item["id"] == "f_F3") + reference = feature["params"]["end_condition"]["reference"] + self.assertEqual(reference["selector_intent"]["query_family"], "SWEPT_FACE") + self.assertEqual( + reference["selector_intent"]["consumer_contract"], + "immediate_retained_source_prism_swept_face_up_to_surface", + ) + self.assertEqual( + reference["selector_intent"]["derivation_policy"], + {"allowed": ["boundary"], "multiplicity": "one"}, + ) + self.assertNotIn("stable_id", reference) + self.assertNotIn("geometry", reference) + + prefix = deepcopy(result.cdsl) + prefix["features"] = prefix["features"][:2] + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(prefix, Path(directory) / "00408613-retained-wall.step") + self.assertEqual(rebuilt["status"], "rebuilt") + resolution = next(item for item in rebuilt["result"]["selector_resolution"] if item["feature_id"] == "f_F3") + self.assertEqual(resolution["resolution_mode"], "kernel_lineage") + self.assertEqual(resolution["record"]["kind"], "face") + self.assertEqual(resolution["evidence"]["semantic_anchor"], { + "type": "source_entity", "sketch_id": "F0", "entity_id": "E0.bottom", + }) + + from engine.cdsl_engine.runtime import analyze_cdsl + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + + self.assertTrue(validate_semantic_cdsl(prefix)["future_rebuild_ready"]) + self.assertTrue(analyze_cdsl(prefix).feature_results[-1].executable) + + tampered = deepcopy(prefix) + tampered_reference = tampered["features"][-1]["params"]["end_condition"]["reference"] + tampered_reference["selector_intent"]["source_entity"]["entity_id"] = "E1" + with self.assertRaisesRegex(ValueError, "retained source prism SWEPT_FACE"): + validate_semantic_cdsl(tampered) + self.assertIn( + "unsupported_retained_source_prism_swept_face_extent", + [blocker.code for blocker in analyze_cdsl(tampered).feature_results[-1].blockers], + ) + + # E1 is an original circle but not part of F1's selected IMPRINT + # region. A query change must not recover it by geometry or owner. + rejected = lower_model(parse_featurescript(source.replace( + 'sQuery(id+"F0.wireOp",EDGE,"E0.bottom")])]});\n extrude(context, id + "F3"', + 'sQuery(id+"F0.wireOp",EDGE,"E1")] )]});\n extrude(context, id + "F3"', + ), "00408613-unselected-circle"), {}) + self.assertNotIn("f_F3", {item["id"] for item in rejected.cdsl["features"]}) + self.assertIn(("F3", "extrude_extent_face_selector"), { + (item.get("feature_id"), item.get("capability")) for item in rejected.diagnostics + }) + + def test_shell_mutated_swept_body_cannot_be_reused_as_an_up_to_body_target(self): + """A historical prism body is not an active-body fallback after shell.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0041/00415457.txt" + if not source_path.exists(): + self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(source_path.read_text(encoding="utf-8"), "00415457"), {}) + self.assertEqual(result.status, "converted_partial") + self.assertNotIn("f_F5", {feature["id"] for feature in result.cdsl["features"]}) + self.assertIn({ + "code": "unsupported_engine_capability", + "capability": "extrude_extent_body_selector", + "feature_id": "F5", + "operation": "extrude", + "message": "current CDSL body extent requires a complete/proven active selector reference", + }, result.diagnostics) + + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "00415457-shell-body-boundary.step") + self.assertEqual(rebuilt["status"], "rebuilt") + self.assertEqual( + [item["feature_id"] for item in rebuilt["result"]["feature_results"]], + ["f_F1", "f_F2", "f_F3", "f_F7"], + ) + def test_mirrored_copy_cap_face_uses_the_mirrored_loft_frame(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0061/00612529.txt" @@ -977,6 +2631,29 @@ class LoweringTests(unittest.TestCase): for diagnostic in result.diagnostics )) + def test_line_angle_q_body_type_source_wires_keep_one_construction_axis_per_sketch(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0050/00506444.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00506444"), {}) + + self.assertEqual(result.status, "converted_complete") + self.assertTrue(any( + item["id"] == "f_F6" and item["atomic_id"] == "reference_plane" + for item in result.cdsl["features"] + )) + self.assertTrue(any( + item["id"] == "f_F8" and item["atomic_id"] == "extrude_add_blind" + for item in result.cdsl["features"] + )) + self.assertFalse(any( + diagnostic.get("feature_id") == "F6" and diagnostic.get("operation") == "cPlane" + for diagnostic in result.diagnostics + )) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "wire-axis.step") + self.assertEqual(rebuilt["status"], "rebuilt") + def test_line_angle_two_direct_entities_cover_axis_point_and_degenerate_contracts(self): result = lower_model(parse_featurescript(LINE_ANGLE_TWO_ENTITY_SOURCE, "line-angle-two-entities"), {}) self.assertEqual(result.status, "converted_partial") @@ -1024,6 +2701,43 @@ class LoweringTests(unittest.TestCase): self.assertIsNotNone(_direct_line_angle_axis(direct, sketch_by_source, entity_by_sketch)) self.assertIsNone(_direct_line_angle_axis(derived, sketch_by_source, entity_by_sketch)) + def test_line_angle_direct_prism_swept_edge_is_a_source_datum_axis(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + cases = { + # One axis-only plane and one axis-plus-default-plane form. Both + # source OSDs name the unique profile vertex that the immediately + # preceding unchanged direct prism sweeps along its blind span. + "00040198": "f_F2", + "00722278": "f_F2", + } + for sample_id, feature_id in cases.items(): + with self.subTest(sample_id=sample_id): + source = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt" + result = lower_model(parse_featurescript(source.read_text(), sample_id), {}) + self.assertEqual(result.status, "converted_complete") + plane_feature = next(item for item in result.cdsl["features"] if item["id"] == feature_id) + self.assertEqual(plane_feature["atomic_id"], "reference_plane") + self.assertFalse(any( + diagnostic.get("feature_id") == "F2" and diagnostic.get("operation") == "cPlane" + for diagnostic in result.diagnostics + )) + self.assertNotIn("selectors", plane_feature) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / f"{sample_id}.step") + self.assertEqual(rebuilt["status"], "rebuilt") + + # An immediate derived face alongside the SWEPT_EDGE is not an + # explicit datum reference plane. It must not inherit this axis-only + # contract merely because the prism itself is otherwise direct. + source = root / "featurescript_rp/0064/00644299.txt" + deferred = lower_model(parse_featurescript(source.read_text(), "00644299"), {}) + self.assertIn({ + "code": "feature_deferred", + "feature_id": "F2", + "operation": "cPlane", + "message": "line-angle reference selection is unsupported", + }, deferred.diagnostics) + def test_line_angle_direct_axis_refuses_a_query_wrapper_and_keeps_the_prefix(self): direct = Call("sQuery", ["F0.wireOp", "EDGE", "E0"], 1) wrapped = Call("qAdjacent", [direct, "VERTEX", "EDGE"], 2) @@ -1124,23 +2838,262 @@ class LoweringTests(unittest.TestCase): 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): + def test_offset_face_profile_uses_the_retained_direct_shell_contract(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") + profile = next(item for item in result.cdsl["features"] if item["id"] == "f_F14") + selector = profile["selectors"][0] + self.assertEqual(profile["atomic_id"], "extrude_from_face") + self.assertEqual(selector["owner_feature_id"], "f_F13") + self.assertEqual(selector["output_role"], "shell.offset_face") + self.assertEqual(selector["output_role_source"], { + "owner_feature_id": "f_F12", "output_role": "extrude.start", + }) + self.assertFalse(any(item.get("feature_id") == "F14" for item in result.diagnostics)) + + def test_retained_direct_prism_shell_cap_offset_face_is_a_runtime_profile(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0071/00719927.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00719927"), {}) + + self.assertEqual(result.status, "converted_complete") + features = {item["id"]: item for item in result.cdsl["features"]} + profile = features["f_F3"] + self.assertEqual(profile["atomic_id"], "extrude_from_face") + self.assertEqual(profile["params"]["result_mode"], "new_body") + self.assertEqual(features["f_F4"]["params"]["source_feature_ids"], ["f_F2"]) + selector = profile["selectors"][0] + self.assertEqual(selector["output_role"], "shell.offset_face") + self.assertEqual(selector["output_role_source"], { + "owner_feature_id": "f_F1", "output_role": "extrude.start", + }) + self.assertEqual( + selector["selector_intent"]["consumer_contract"], + "shell_retained_direct_prism_cap_offset_face_profile", + ) + + from engine.cdsl_engine.runtime import rebuild_cdsl + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(result.cdsl, Path(directory) / "retained-shell-cap.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_F2"]) + self.assertEqual(resolution["selected"]["output_roles"], ["shell.offset_face"]) + self.assertEqual( + resolution["selected"]["output_role_sources"], + [{"output_role": "shell.offset_face", "owner_feature_id": "f_F1", "source_output_role": "extrude.start"}], + ) + self.assertEqual([item["status"] for item in rebuilt["feature_results"]], ["executed"] * 4) + + def test_retained_shell_cap_offset_face_rejects_partial_source_profile(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0071/00719927.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + source = feature.read_text(encoding="utf-8").replace( + 'Q0=makeQuery(id+"F2.opShell","OFFSET_FACE",FACE,{"disambiguationData":[OSD([sQuery(id+"F0.wireOp",EDGE,"E0"),sQuery(id+"F0.wireOp",EDGE,"E1"),sQuery(id+"F0.wireOp",EDGE,"E2")])]});', + 'Q0=makeQuery(id+"F2.opShell","OFFSET_FACE",FACE,{"disambiguationData":[OSD([sQuery(id+"F0.wireOp",EDGE,"E0"),sQuery(id+"F0.wireOp",EDGE,"E1")])]} );', + 1, + ) + result = lower_model(parse_featurescript(source, "00719927-partial-offset-profile"), {}) + + self.assertNotIn("f_F3", {item["id"] for item in result.cdsl["features"]}) + diagnostic = next(item for item in result.diagnostics if item.get("feature_id") == "F3") self.assertEqual(diagnostic["capability"], "extrude_profile_topology:offset_face") - def test_circular_pattern_uses_the_proven_fused_body_successor(self): + def test_retained_shell_cap_offset_face_rejects_the_removed_cap_source(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0071/00719927.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00719927"), {}) + invalid = deepcopy(result.cdsl) + invalid["features"][2]["selectors"][0]["output_role_source"]["output_role"] = "extrude.end" + + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + with self.assertRaisesRegex(ValueError, "immediate opposite direct-prism cap removal"): + validate_semantic_cdsl(invalid) + + def test_offset_edge_tdd_uses_the_retained_direct_prism_cap_continuation(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0065/00650671.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00650671"), {}) + + self.assertEqual(result.status, "converted_complete") + chamfer = next(item for item in result.cdsl["features"] if item["id"] == "f_F3") + selector = chamfer["selectors"][0] + intent = selector["selector_intent"] + self.assertEqual(selector["owner_feature_id"], "f_F1") + self.assertEqual(intent["query_family"], "OFFSET_EDGE") + self.assertEqual(intent["consumer_contract"], "direct_prism_shell_offset_edge_tdd") + self.assertEqual(intent["lineage_role"], "extrude.start") + self.assertEqual(intent["derivation_policy"], { + "allowed": ["boundary", "continuation"], "multiplicity": "one", + }) + self.assertEqual(intent["disambiguation"], { + "type": "offset_edge_tdd_cap_continuation", + "shell_feature_id": "f_F2", + "outer_owner_feature_id": "f_F2", + "tdd_cap_owner_feature_id": "f_F1", + "tdd_cap_role": "extrude.start", + "source_entity": {"sketch_id": "F0", "entity_id": "E0"}, + }) + + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "offset-edge-tdd.step") + self.assertEqual(rebuilt["status"], "rebuild_failed") + self.assertEqual(rebuilt["last_executable_prefix"]["last_feature_id"], "f_F3") + binding = next(item for item in rebuilt["selector_binding"] if item["feature_id"] == "f_F3") + self.assertEqual(binding["resolved"][0]["record_id"], "body:f_F2:edge:1") + self.assertEqual(binding["resolved"][0]["output_roles"], ["extrude.start"]) + + def test_offset_edge_tdd_has_a_second_real_shell_consumer_and_rejects_mixed_set(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0076/00768679.txt" + mixed_feature = root / "featurescript_rp/0079/00791920.txt" + if not feature.exists() or not mixed_feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00768679"), {}) + chamfer = next(item for item in result.cdsl["features"] if item["id"] == "f_F8") + selector = chamfer["selectors"][0] + self.assertEqual(selector["selector_intent"]["consumer_contract"], "direct_prism_shell_offset_edge_tdd") + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "offset-edge-tdd-second.step") + self.assertEqual(rebuilt["status"], "rebuilt") + binding = next(item for item in rebuilt["selector_binding"] if item["feature_id"] == "f_F8") + self.assertEqual(binding["resolved"][0]["record_id"], "body:f_F7:1:edge:1") + + mixed = lower_model(parse_featurescript(mixed_feature.read_text(), "00791920"), {}) + fillet = next(item for item in mixed.cdsl["features"] if item["id"] == "f_F3") + contracts = [ + (item.get("selector_intent") or {}).get("consumer_contract") + for item in fillet["selectors"] + ] + self.assertIn("direct_prism_shell_offset_edge_tdd", contracts) + self.assertIn(None, contracts) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(mixed.cdsl, Path(directory) / "offset-edge-tdd-mixed.step") + self.assertEqual(rebuilt["status"], "rebuild_failed") + self.assertIn("selector_query_unsupported", rebuilt["error"]["message"]) + + def test_offset_edge_vertex_uses_the_direct_prism_swept_edge_continuation(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0005/00059593.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00059593"), {}) + + self.assertEqual(result.status, "converted_complete") + fillet = next(item for item in result.cdsl["features"] if item["id"] == "f_F3") + operands = fillet["selectors"][0]["query_operands"] + vertex_selectors = [ + item for item in operands + if (item.get("selector_intent") or {}).get("consumer_contract") + == "direct_prism_shell_offset_edge_vertex" + ] + self.assertEqual(len(vertex_selectors), 4) + for selector in vertex_selectors: + intent = selector["selector_intent"] + self.assertEqual(intent["query_family"], "OFFSET_EDGE") + self.assertNotIn("source_entity", intent) + self.assertEqual(len(intent["source_entities"]), 2) + self.assertEqual(intent["derivation_policy"], { + "allowed": ["boundary", "continuation"], "multiplicity": "one", + }) + self.assertEqual(intent["disambiguation"]["type"], "offset_edge_vertex_continuation") + + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "offset-edge-vertex.step") + self.assertEqual(rebuilt["status"], "rebuilt") + binding = next(item for item in rebuilt["selector_binding"] if item["feature_id"] == "f_F3") + self.assertEqual(len(binding["resolved"]), 8) + self.assertTrue(all(item["record_id"].startswith("body:f_F2:edge:") for item in binding["resolved"])) + + def test_offset_edge_vertex_rejects_mixed_osd_and_tdd_set(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0079/00791920.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00791920"), {}) + fillet = next(item for item in result.cdsl["features"] if item["id"] == "f_F3") + contracts = [(item.get("selector_intent") or {}).get("consumer_contract") for item in fillet["selectors"]] + self.assertIn("direct_prism_shell_offset_edge_tdd", contracts) + self.assertIn(None, contracts) + self.assertNotIn("direct_prism_shell_offset_edge_vertex", contracts) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "offset-edge-vertex-mixed.step") + self.assertEqual(rebuilt["status"], "rebuild_failed") + self.assertIn("selector_query_unsupported", rebuilt["error"]["message"]) + + def test_offset_edge_swept_edge_tdd_does_not_reuse_the_direct_prism_contracts(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0023/00239888.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00239888"), {}) + + # Its OFFSET_EDGE uses TDD(SWEPT_EDGE), but the producer is an + # IMPRINT profile and the shell follows a fillet. It cannot inherit + # either the retained CAP_EDGE or immediate source-vertex contract. + self.assertEqual(result.status, "converted_partial") + self.assertNotIn("f_F6", {item["id"] for item in result.cdsl["features"]}) + diagnostic = next(item for item in result.diagnostics if item.get("feature_id") == "F6") + # The IMPRINT producer F3 is absent from the executable prefix, so + # owner availability must reject the downstream selector before its + # TDD(SWEPT_EDGE) source form could be considered. + self.assertEqual(diagnostic["capability"], "selector_owner_unavailable") + self.assertEqual(diagnostic["message"], "selector owner F3 has no executable CDSL producer") + + def test_offset_edge_vertex_semantic_validation_rejects_a_mismatched_shell(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0005/00059593.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00059593"), {}) + invalid = deepcopy(result.cdsl) + selector = next( + item for item in invalid["features"] + if item["id"] == "f_F3" + )["selectors"][0]["query_operands"][8] + selector["selector_intent"]["disambiguation"]["removed_cap_role"] = "extrude.start" + + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + with self.assertRaisesRegex(ValueError, "OFFSET_EDGE vertex requires"): + validate_semantic_cdsl(invalid) + + from engine.cdsl_engine.capabilities import CapabilityAnalyzer + from engine.cdsl_engine.registry import EXECUTORS + from engine.cdsl_engine.sketch_solver import CORE_SHAPE_GENERATORS + analysis = CapabilityAnalyzer( + atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS, + ).analyze(invalid) + f3 = next(item for item in analysis.feature_results if item.feature_id == "f_F3") + self.assertIn( + "unsupported_offset_edge_vertex_selector", + [blocker.code for blocker in f3.blockers], + ) + + def test_offset_edge_tdd_semantic_validation_rejects_a_mismatched_shell(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0065/00650671.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00650671"), {}) + invalid = deepcopy(result.cdsl) + selector = next(item for item in invalid["features"] if item["id"] == "f_F3")["selectors"][0] + selector["selector_intent"]["disambiguation"]["shell_feature_id"] = "f_F1" + + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + with self.assertRaisesRegex(ValueError, "OFFSET_EDGE TDD requires"): + validate_semantic_cdsl(invalid) + + def test_circular_pattern_uses_the_active_typed_add_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"]} + # F5 fuses into F1 and becomes the active member. The circular + # pattern must use that member instead of reviving stale F1. + self.assertNotIn("result_mode", features["f_F5"]["params"]) self.assertEqual(features["f_F6"]["params"]["source_feature_ids"], ["f_F5"]) def test_circular_copy_boolean_uses_the_proven_fused_body_successor(self): @@ -1150,17 +3103,87 @@ class LoweringTests(unittest.TestCase): 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") + # F7 attaches to an outer MERGE(FACE), not directly to F4's nested + # CAP_FACE. The selector has no active provenance contract for that + # merge result, so preserving F1--F6 is preferable to materializing + # an unproven static workplane for F7/F8. + self.assertEqual(result.status, "converted_partial") + # F4 uses typed ADD and therefore fuses into the active member. F5 + # and the targetless union must consume that active successor. + self.assertNotIn("result_mode", features["f_F4"]["params"]) self.assertEqual(features["f_F5"]["params"]["source_feature_ids"], ["f_F4"]) self.assertEqual(features["f_F6"]["params"], { "operation": "union", "keep_tools": False, + "targetless_body_set": True, "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}, ], }) + self.assertNotIn("f_F7", features) + self.assertNotIn("f_F8", features) + self.assertIn({ + "code": "sketch_deferred", + "feature_id": "F7", + "message": "MERGE(FACE) workplane requires a dedicated complete/proven runtime face relation", + }, result.diagnostics) + + def test_merge_face_workplane_never_reuses_a_nested_swept_face_frame(self): + """Outer MERGE faces must not inherit one derived SWEPT_FACE plane.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + cases = { + "00013930": ("0001", "F7", "f_F7"), + "00020631": ("0002", "F4", "f_F4"), + "00036155": ("0003", "F5", "f_F5"), + } + for sample_id, (shard, sketch_id, dependent_feature) in cases.items(): + with self.subTest(sample_id=sample_id): + feature = root / f"featurescript_rp/{shard}/{sample_id}.txt" + if not feature.exists(): + self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), sample_id), {}) + features = {item["id"] for item in result.cdsl["features"]} + self.assertEqual(result.status, "converted_partial") + self.assertNotIn(dependent_feature, features) + self.assertIn({ + "code": "sketch_deferred", + "feature_id": sketch_id, + "message": "MERGE(FACE) workplane requires a dedicated complete/proven runtime face relation", + }, result.diagnostics) + + def test_boolean_uses_source_qualified_multi_source_transform_copies(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") + source = feature.read_text(encoding="utf-8") + # This is a source-history contract variant, not a source STEP + # comparison: F3 is the real history's two-source makeCopy output. + boolean = ''' { + var Q0; + Q0=makeQuery(id+"F3.opPattern","COPY",BODY,{"derivedFrom":makeQuery(id+"F1.opExtrude","SWEPT_BODY",BODY,{"disambiguationData":[OSD([sQuery(id+"F0.wireOp",EDGE,"E0.bottom"),sQuery(id+"F0.wireOp",EDGE,"E0.top"),sQuery(id+"F0.wireOp",EDGE,"E0.left"),sQuery(id+"F0.wireOp",EDGE,"E0.right")])]}),"instanceName":"1"}); + var Q1; + Q1=makeQuery(id+"F3.opPattern","COPY",BODY,{"derivedFrom":makeQuery(id+"F2.opPattern","COPY",BODY,{"derivedFrom":makeQuery(id+"F1.opExtrude","SWEPT_BODY",BODY,{"disambiguationData":[OSD([sQuery(id+"F0.wireOp",EDGE,"E0.bottom"),sQuery(id+"F0.wireOp",EDGE,"E0.top"),sQuery(id+"F0.wireOp",EDGE,"E0.left"),sQuery(id+"F0.wireOp",EDGE,"E0.right")])]}),"instanceName":"1"}),"instanceName":"1"}); + booleanBodies(context, id + "F5", {"operationType" : BooleanOperationType.UNION, "targets" : qUnion([Q0]), "tools" : qUnion([Q1])}); + } +''' + source = source.rsplit(" });", 1)[0] + boolean + " });" + result = lower_model(parse_featurescript(source, "00699847-copy-boolean-contract"), {}) + + self.assertEqual(result.status, "converted_complete") + feature = next(item for item in result.cdsl["features"] if item["id"] == "f_F5") + self.assertEqual(feature["params"], { + "operation": "union", "keep_tools": False, + "target_transform_copy_refs": [{"transform_feature_id": "f_F3", "source_feature_id": "f_F1"}], + "tool_transform_copy_refs": [{"transform_feature_id": "f_F3", "source_feature_id": "f_F2"}], + }) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "copy-boolean-contract.step") + self.assertEqual(rebuilt["status"], "rebuilt") + self.assertEqual([item["feature_id"] for item in rebuilt["result"]["feature_results"]], [ + "f_F1", "f_F2", "f_F3", "f_F4", "f_F5", + ]) def test_shared_sketch_edge_produces_each_bounded_region(self): segments = [ @@ -1254,6 +3277,178 @@ class LoweringTests(unittest.TestCase): self.assertEqual(rebuilt["last_executable_prefix"]["failed_feature_id"], "f_F10") self.assertEqual(rebuilt["last_executable_prefix"]["last_feature_id"], "f_F9") + def test_pure_surface_extrude_lowers_one_direct_open_line_without_creating_a_body(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0067/00677236.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00677236"), {}) + + self.assertEqual(result.status, "converted_complete") + first = next(item for item in result.cdsl["features"] if item["id"] == "f_F1") + self.assertEqual(first["atomic_id"], "extrude_surface") + sketch = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == first["sketch_id"]) + self.assertEqual(sketch["profile"]["contours"], [{ + "role": "open", "closed": False, "surface_wire": True, + "segments": [{ + "type": "line", "start": [-1012.95, 885.86], + "end": [-1012.95, -974.14], "source_entity_id": "E0.left", + }], + }]) + + prefix = deepcopy(result.cdsl) + prefix["features"] = [first] + with tempfile.TemporaryDirectory() as directory: + from cadfs_to_cdsl.rebuild import rebuild_candidate + rebuilt = rebuild_candidate(prefix, Path(directory) / "pure-surface-line.step") + self.assertEqual(rebuilt["status"], "rebuilt") + self.assertEqual(rebuilt["result"]["solid_count"], 0) + self.assertEqual(rebuilt["result"]["surface_count"], 1) + + def test_pure_surface_extrude_lowers_multiple_direct_open_line_wires(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0041/00414424.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00414424"), {}) + + self.assertEqual(result.status, "converted_complete") + features = {item["id"]: item for item in result.cdsl["features"]} + sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} + self.assertEqual({features[feature_id]["atomic_id"] for feature_id in ("f_F2", "f_F3", "f_F4")}, {"extrude_surface"}) + self.assertEqual( + [[segment["source_entity_id"] for segment in contour["segments"]] + for contour in sketches[features["f_F2"]["sketch_id"]]["profile"]["contours"]], + [["E26", "E25", "E32"]], + ) + self.assertEqual( + [[segment["source_entity_id"] for segment in contour["segments"]] + for contour in sketches[features["f_F3"]["sketch_id"]]["profile"]["contours"]], + [["E27"], ["E31"]], + ) + self.assertEqual( + [[segment["source_entity_id"] for segment in contour["segments"]] + for contour in sketches[features["f_F4"]["sketch_id"]]["profile"]["contours"]], + [["E30", "E29", "E28"]], + ) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "multi-open-surface-wires.step") + self.assertEqual(rebuilt["status"], "rebuilt") + self.assertEqual(rebuilt["result"]["solid_count"], 0) + self.assertEqual(rebuilt["result"]["surface_count"], 3) + self.assertEqual(rebuilt["result"]["surface_face_count"], 8) + + def test_pure_surface_extrude_lowers_a_second_real_multiline_open_wire(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0042/00424680.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00424680"), {}) + + self.assertEqual(result.status, "converted_complete") + surface = next(item for item in result.cdsl["features"] if item["id"] == "f_F1") + self.assertEqual(surface["atomic_id"], "extrude_surface") + sketch = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == surface["sketch_id"]) + contours = sketch["profile"]["contours"] + self.assertEqual(len(contours), 1) + self.assertEqual(len(contours[0]["segments"]), 4) + self.assertTrue(contours[0]["surface_wire"]) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "second-multi-open-surface-wire.step") + self.assertEqual(rebuilt["status"], "rebuilt") + self.assertEqual(rebuilt["result"]["solid_count"], 0) + self.assertEqual(rebuilt["result"]["surface_count"], 1) + self.assertEqual(rebuilt["result"]["surface_face_count"], 4) + + def test_pure_surface_extrude_accepts_independent_add_and_symmetric_extent(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0059/00592110.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00592110"), {}) + + surface = next(item for item in result.cdsl["features"] if item["id"] == "f_F3") + self.assertEqual(surface["atomic_id"], "extrude_surface") + self.assertEqual(surface["params"], { + "distance_mm": 12.7, "reverse": False, "reverse_distance_mm": 12.7, + }) + self.assertFalse([item for item in result.diagnostics if item.get("feature_id") == "F3"]) + + def test_pure_surface_extrude_preserves_a_symmetric_direct_circle_shell(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0050/00509429.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00509429"), {}) + + self.assertEqual(result.status, "converted_complete") + surface = result.cdsl["features"][0] + self.assertEqual(surface["atomic_id"], "extrude_surface") + self.assertEqual(surface["params"], { + "distance_mm": 228.6, "reverse": False, "reverse_distance_mm": 228.6, + }) + with tempfile.TemporaryDirectory() as directory: + from cadfs_to_cdsl.rebuild import rebuild_candidate + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "pure-surface-circle.step") + self.assertEqual(rebuilt["status"], "rebuilt") + self.assertEqual(rebuilt["result"]["solid_count"], 0) + self.assertEqual(rebuilt["result"]["surface_face_count"], 2) + + def test_pure_surface_extrude_rejects_remove_semantics(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0059/00595418.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00595418"), {}) + + self.assertNotIn("f_F9", {item["id"] for item in result.cdsl["features"]}) + self.assertIn({ + "code": "unsupported_engine_capability", + "capability": "extrude_surface_operation", + "feature_id": "F9", + "operation": "extrude", + "message": "pure ToolBodyType.SURFACE extrusion supports only an independent ADD surface operation", + }, result.diagnostics) + + def test_pure_surface_extrude_rejects_cross_sketch_source_wires(self): + source_sketch = { + "id": "sketch_F0", "name": "F0", + "profile": {"type": "analytic_contours", "contours": []}, + } + source_line = {"type": "line", "start": [0, 0], "end": [10, 0]} + query = Call("qUnion", [[ + Call("sQuery", ["F0.wireOp", "EDGE", "E0"]), + Call("sQuery", ["F1.wireOp", "EDGE", "E0"]), + ]]) + + with self.assertRaisesRegex( + UnsupportedCapability, + "surface extrude source wires must come from one source sketch", + ): + _surface_profile_selection_sketch( + source_sketch, query, {"E0": source_line}, "f_surface", allow_open_wire=True, + ) + + def test_pure_surface_extrude_rejects_branched_or_closed_open_line_sets(self): + source_sketch = { + "id": "sketch_F0", "name": "F0", + "profile": {"type": "analytic_contours", "contours": []}, + } + def query(*entity_ids): + return Call("qUnion", [[ + Call("sQuery", ["F0.wireOp", "EDGE", entity_id]) for entity_id in entity_ids + ]]) + + branched = { + "E0": {"type": "line", "start": [0, 0], "end": [10, 0]}, + "E1": {"type": "line", "start": [0, 0], "end": [0, 10]}, + "E2": {"type": "line", "start": [0, 0], "end": [-10, 0]}, + } + closed = { + "E0": {"type": "line", "start": [0, 0], "end": [10, 0]}, + "E1": {"type": "line", "start": [10, 0], "end": [5, 10]}, + "E2": {"type": "line", "start": [5, 10], "end": [0, 0]}, + } + for entities in (branched, closed): + with self.assertRaisesRegex(UnsupportedCapability, "non-branching open chains"): + _surface_profile_selection_sketch( + source_sketch, query(*entities), entities, "f_surface", allow_open_wire=True, + ) + def test_fit_spline_loft_lowers_to_executable_loft_add(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0061/00612529.txt" @@ -1280,6 +3475,735 @@ class LoweringTests(unittest.TestCase): self.assertEqual(rebuilt["status"], "rebuilt") self.assertGreater(rebuilt["result"]["volume_mm3"], 0) + def test_initial_direct_loft_shell_cap_uses_osd_profile_role_not_is_start(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0002/00023963.txt" + if not source_path.exists(): self.skipTest("CADFS sample is not installed") + source = source_path.read_text(encoding="utf-8") + result = lower_model(parse_featurescript(source, "00023963"), {}) + + self.assertEqual(result.status, "converted_complete") + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F3"]["params"], { + "profile_sketch_ids": ["sketch_F0", "sketch_F2"], + "initial_output_roles": True, + "cap_output_profile_sources": ["F0", "F2"], + }) + selector = features["f_F4"]["selectors"][0] + # The source query says isStart:true but its OSD names F2, the second + # loft input. The output role must follow that profile provenance. + self.assertEqual(selector["output_role"], "loft.end") + self.assertEqual(selector["selector_intent"]["disambiguation"], { + "type": "loft_profile_source", "source_sketch_id": "F2", + }) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "loft-shell.step") + self.assertEqual(rebuilt["status"], "rebuilt") + resolution = next(item for item in rebuilt["result"]["selector_resolution"] if item["feature_id"] == "f_F4") + self.assertEqual(resolution["resolution_mode"], "operation_role") + self.assertEqual(resolution["selected"]["output_roles"], ["loft.end"]) + + constrained = source.replace( + '{"sheetProfilesArray"', '{"matchConnections" : true, "sheetProfilesArray"', 1, + ) + deferred = lower_model(parse_featurescript(constrained, "loft-cap-constrained"), {}) + deferred_selector = next(item for item in deferred.cdsl["features"] if item["id"] == "f_F4")["selectors"][0] + self.assertNotIn("initial_output_roles", next(item for item in deferred.cdsl["features"] if item["id"] == "f_F3")["params"]) + self.assertNotIn("output_role", deferred_selector) + self.assertEqual(deferred_selector["selector_intent"]["evidence"], "feature_script_query") + + unsupported_version = source.replace("FeatureScript 1511;", "FeatureScript 1549;", 1).replace( + 'version : "1511.0"', 'version : "1549.0"', 2, + ) + version_deferred = lower_model(parse_featurescript(unsupported_version, "loft-cap-1549"), {}) + version_loft = next(item for item in version_deferred.cdsl["features"] if item["id"] == "f_F3") + version_selector = next(item for item in version_deferred.cdsl["features"] if item["id"] == "f_F4")["selectors"][0] + self.assertNotIn("initial_output_roles", version_loft["params"]) + self.assertNotIn("output_role", version_selector) + + def test_initial_direct_sweep_shell_cap_requires_profile_and_path_endpoint_pair(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0033/00330012.txt" + if not source_path.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(source_path.read_text(encoding="utf-8"), "00330012"), {}) + + self.assertEqual(result.status, "converted_complete") + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F2"]["params"]["cap_output_contract"], { + "profile_source": "F0", "profile_entity": "E0", + "path_source": "F1", "path_entity": "E1", "path_reversed": False, + }) + selector = features["f_F3"]["selectors"][0] + self.assertNotIn("geometry", selector) + self.assertNotIn("stable_id", selector) + self.assertEqual(selector["output_role"], "sweep.end") + self.assertEqual(selector["selector_intent"]["disambiguation"], { + "type": "sweep_profile_path_endpoint", + "profile_source": "F0", "profile_entity": "E0", + "path_source": "F1", "path_entity": "E1", "path_reversed": False, + "path_endpoint": "end", + }) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "sweep-shell.step") + self.assertEqual(rebuilt["status"], "rebuilt") + resolution = next(item for item in rebuilt["result"]["selector_resolution"] if item["feature_id"] == "f_F3") + self.assertEqual(resolution["resolution_mode"], "operation_role") + self.assertEqual(resolution["selected"]["output_roles"], ["sweep.end"]) + self.assertEqual(resolution["evidence"]["relations"][0]["operation"], "sweep") + + rejected_path = root / "featurescript_rp/0065/00658358.txt" + if not rejected_path.exists(): self.skipTest("CADFS source-pair rejection sample is not installed") + rejected = lower_model(parse_featurescript(rejected_path.read_text(encoding="utf-8"), "00658358"), {}) + # F4 is an attached sketch, not the immediate shell consumer allowed + # by this CAP_FACE tuple. It must not inherit a static sweep cap + # frame merely because its query contains the producer's endpoint. + self.assertNotIn("f_F4", {item["id"] for item in rejected.cdsl["features"]}) + self.assertTrue(any( + item["feature_id"] == "F4" + and item["code"] == "feature_deferred" + and item["message"] == "cap face source frame is unresolved" + for item in rejected.diagnostics + )) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(rejected.cdsl, Path(directory) / "sweep-shell-rejected.step") + self.assertEqual(outcome["status"], "rebuilt") + self.assertEqual(outcome["result"]["feature_results"][-1]["feature_id"], "f_F3") + + reversed_path = root / "featurescript_rp/0026/00260523.txt" + if not reversed_path.exists(): self.skipTest("CADFS path-reversal source sample is not installed") + reversed_source = reversed_path.read_text(encoding="utf-8").rsplit(" });", 1)[0] + ''' + { + var Q0; + Q0=makeQuery(id+"F3.opSweep","CAP_FACE",FACE,{"disambiguationData":[OSD([sQuery(id+"F2.wireOp",EDGE,"E1"),sQuery(id+"F0.wireOp",VERTEX,"E0.end")])],"isStart":false}); + shell(context, id + "F4", {"entities" : qUnion([Q0]), "thickness" : 0.5 * mm}); + } + });''' + reversed_result = lower_model(parse_featurescript(reversed_source, "00260523-sweep-cap-reversed"), {}) + reversed_features = {item["id"]: item for item in reversed_result.cdsl["features"]} + self.assertTrue(reversed_features["f_F3"]["params"]["cap_output_contract"]["path_reversed"]) + reversed_selector = reversed_features["f_F4"]["selectors"][0] + self.assertEqual(reversed_selector["output_role"], "sweep.start") + self.assertEqual(reversed_selector["selector_intent"]["disambiguation"]["path_endpoint"], "end") + with tempfile.TemporaryDirectory() as directory: + reversed_rebuilt = rebuild_candidate(reversed_result.cdsl, Path(directory) / "sweep-shell-reversed.step") + self.assertEqual(reversed_rebuilt["status"], "rebuilt") + + def test_initial_direct_sweep_cap_edge_requires_a_single_profile_edge_and_path_endpoint_pair(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0033/00330012.txt" + if not source_path.exists(): self.skipTest("CADFS sample is not installed") + source = source_path.read_text(encoding="utf-8") + cap_edge_source = source.replace( + 'makeQuery(id+"F2.opSweep","CAP_FACE",FACE,', + 'makeQuery(id+"F2.opSweep","CAP_EDGE",EDGE,', + ).replace( + 'shell(context, id + "F3", {"entities" : qUnion([Q0]), "thickness" : 1.27 * mm});', + 'fillet(context, id + "F3", {"entities" : qUnion([Q0]), "radius" : 1.27 * mm, "tangentPropagation" : true, "allowEdgeOverflow" : false});', + ) + result = lower_model(parse_featurescript(cap_edge_source, "00330012-sweep-cap-edge"), {}) + self.assertEqual(result.status, "converted_complete") + features = {item["id"]: item for item in result.cdsl["features"]} + selector = features["f_F3"]["selectors"][0] + self.assertNotIn("geometry", selector) + self.assertNotIn("stable_id", selector) + self.assertEqual(selector["selector_intent"]["query_family"], "CAP_EDGE") + self.assertEqual(selector["selector_intent"]["lineage_role"], "sweep.end") + self.assertEqual(selector["selector_intent"]["source_entity"], {"sketch_id": "F0", "entity_id": "E0"}) + self.assertEqual(selector["selector_intent"]["disambiguation"]["path_endpoint"], "end") + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "sweep-cap-edge.step") + self.assertEqual(rebuilt["status"], "rebuilt") + resolution = next(item for item in rebuilt["result"]["selector_resolution"] if item["feature_id"] == "f_F3") + self.assertEqual(resolution["resolution_mode"], "kernel_lineage") + self.assertEqual(resolution["evidence"]["relations"][0]["output_role"], "sweep.end") + self.assertEqual(resolution["evidence"]["relations"][0]["source_kind"], "edge") + self.assertEqual(resolution["evidence"]["relations"][0]["result_kind"], "edge") + + mismatched_endpoint = cap_edge_source.replace('E1.end")])],"isStart":false', 'E1.start")])],"isStart":false') + rejected = lower_model(parse_featurescript(mismatched_endpoint, "00330012-sweep-cap-edge-endpoint-mismatch"), {}) + self.assertEqual(rejected.status, "converted_partial") + self.assertNotIn("f_F3", {item["id"] for item in rejected.cdsl["features"]}) + + def test_initial_direct_sweep_swept_face_requires_profile_and_path_edge_pair(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0033/00330012.txt" + if not source_path.exists(): self.skipTest("CADFS sample is not installed") + source = source_path.read_text(encoding="utf-8") + swept_face_source = source.replace( + 'makeQuery(id+"F2.opSweep","CAP_FACE",FACE,', + 'makeQuery(id+"F2.opSweep","SWEPT_FACE",FACE,', + ).replace( + 'sQuery(id+"F1.wireOp",VERTEX,"E1.end")', + 'sQuery(id+"F1.wireOp",EDGE,"E1")', + ).replace( + '])],"isStart":false})', '])]})', + ).replace( + 'shell(context, id + "F3", {"entities" : qUnion([Q0]), "thickness" : 1.27 * mm});', + 'fillet(context, id + "F3", {"entities" : qUnion([Q0]), "radius" : 1.27 * mm, "tangentPropagation" : true, "allowEdgeOverflow" : false});', + ) + result = lower_model(parse_featurescript(swept_face_source, "00330012-sweep-swept-face"), {}) + self.assertEqual(result.status, "converted_complete") + selector = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")["selectors"][0] + self.assertNotIn("geometry", selector) + self.assertNotIn("stable_id", selector) + self.assertEqual(selector["selector_intent"]["query_family"], "SWEPT_FACE") + self.assertEqual(selector["selector_intent"]["source_entity"], {"sketch_id": "F0", "entity_id": "E0"}) + self.assertEqual(selector["selector_intent"]["disambiguation"], { + "type": "sweep_profile_path", + "profile_source": "F0", "profile_entities": ["E0"], + "path_source": "F1", "path_entity": "E1", "path_reversed": False, + }) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "sweep-swept-face.step") + self.assertEqual(rebuilt["status"], "rebuilt") + resolution = next(item for item in rebuilt["result"]["selector_resolution"] if item["feature_id"] == "f_F3") + self.assertEqual(resolution["resolution_mode"], "kernel_lineage") + self.assertEqual(resolution["evidence"]["relations"][0]["source_kind"], "edge") + self.assertEqual(resolution["evidence"]["relations"][0]["result_kind"], "face") + self.assertEqual(resolution["evidence"]["relations"][0]["coverage"], "complete") + + query_prefix, query_suffix = swept_face_source.rsplit('sQuery(id+"F1.wireOp",EDGE,"E1")', 1) + mismatched_path = query_prefix + 'sQuery(id+"F0.wireOp",EDGE,"E0")' + query_suffix + rejected = lower_model(parse_featurescript(mismatched_path, "00330012-sweep-swept-face-path-mismatch"), {}) + rejected_selector = next(item for item in rejected.cdsl["features"] if item["id"] == "f_F3")["selectors"][0] + self.assertNotIn("geometry", rejected_selector) + self.assertNotIn("stable_id", rejected_selector) + self.assertEqual(rejected_selector["selector_intent"]["evidence"], "feature_script_query") + + def test_initial_direct_sweep_swept_face_accepts_complete_direct_multi_edge_profile(self): + """Generated(profile_edge) remains exact for every edge of one direct contour.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0033/00330012.txt" + if not source_path.exists(): self.skipTest("CADFS sample is not installed") + source = source_path.read_text(encoding="utf-8") + source = source.replace( + 'skCircle(sketch, "E0", {"center": v(0, 0) * mm, "radius": 6.35 * mm});', + '''skLineSegment(sketch, "E0", {"start": v(-4, -4) * mm, "end": v(4, -4) * mm}); + skLineSegment(sketch, "E1", {"start": v(4, -4) * mm, "end": v(4, 4) * mm}); + skLineSegment(sketch, "E2", {"start": v(4, 4) * mm, "end": v(-4, 4) * mm}); + skLineSegment(sketch, "E3", {"start": v(-4, 4) * mm, "end": v(-4, -4) * mm});''', + ) + source = re.sub( + r'skFitSpline\(sketch, "E1", \{.*?\}\);', + 'skLineSegment(sketch, "E1", {"start": v(0, 0) * mm, "end": v(100, 0) * mm});', + source, + ).replace( + 'Q0=makeQuery(id+"F0.imprint","IMPRINT",FACE,{"disambiguationData":[TD([[makeQuery(id+"F0.imprint","IMPRINT",EDGE,{"derivedFrom":sQuery(id+"F0.wireOp",EDGE,"E0")}),1.0]])]});', + 'Q0=qSketchRegion(id+"F0",true);', + ).replace( + 'makeQuery(id+"F2.opSweep","CAP_FACE",FACE,', + 'makeQuery(id+"F2.opSweep","SWEPT_FACE",FACE,', + ).replace( + 'sQuery(id+"F0.wireOp",EDGE,"E0"),sQuery(id+"F1.wireOp",VERTEX,"E1.end")', + 'sQuery(id+"F0.wireOp",EDGE,"E2"),sQuery(id+"F1.wireOp",EDGE,"E1")', + ).replace( + '])],"isStart":false})', '])]})', + ).replace( + 'shell(context, id + "F3", {"entities" : qUnion([Q0]), "thickness" : 1.27 * mm});', + 'fillet(context, id + "F3", {"entities" : qUnion([Q0]), "radius" : 0.5 * mm, "tangentPropagation" : true, "allowEdgeOverflow" : false});', + ) + result = lower_model(parse_featurescript(source, "00330012-sweep-swept-face-multi-edge"), {}) + self.assertEqual(result.status, "converted_complete") + features = {item["id"]: item for item in result.cdsl["features"]} + contract = features["f_F2"]["params"]["swept_face_contract"] + self.assertEqual(contract, { + "profile_source": "F0", "profile_entities": ["E0", "E1", "E2", "E3"], + "path_source": "F1", "path_entity": "E1", "path_reversed": False, + }) + self.assertNotIn("cap_output_contract", features["f_F2"]["params"]) + selector = features["f_F3"]["selectors"][0] + self.assertEqual(selector["selector_intent"]["source_entity"], {"sketch_id": "F0", "entity_id": "E2"}) + self.assertEqual(selector["selector_intent"]["disambiguation"]["profile_entities"], ["E0", "E1", "E2", "E3"]) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "sweep-swept-face-multi-edge.step") + self.assertEqual(rebuilt["status"], "rebuilt") + resolution = next(item for item in rebuilt["result"]["selector_resolution"] if item["feature_id"] == "f_F3") + self.assertEqual(resolution["resolution_mode"], "kernel_lineage") + self.assertEqual(resolution["evidence"]["relations"][0]["source_kind"], "edge") + self.assertEqual(resolution["evidence"]["relations"][0]["result_kind"], "face") + + query_prefix, query_suffix = source.rsplit('sQuery(id+"F1.wireOp",EDGE,"E1")', 1) + source_pair_mismatch = query_prefix + 'sQuery(id+"F0.wireOp",EDGE,"E0")' + query_suffix + rejected = lower_model(parse_featurescript(source_pair_mismatch, "00330012-sweep-swept-face-multi-edge-mismatch"), {}) + rejected_selector = next(item for item in rejected.cdsl["features"] if item["id"] == "f_F3")["selectors"][0] + self.assertEqual(rejected_selector["selector_intent"]["evidence"], "feature_script_query") + self.assertNotIn("geometry", rejected_selector) + self.assertNotIn("stable_id", rejected_selector) + + def test_initial_direct_sweep_swept_face_rejects_a_multi_segment_path(self): + """A qUnion path must not inherit an arbitrary representative source edge.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0095/00954785.txt" + if not source_path.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(source_path.read_text(encoding="utf-8"), "00954785"), {}) + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(result.status, "converted_partial") + self.assertNotIn("swept_face_contract", features["f_F2"]["params"]) + self.assertNotIn("f_F3", features) + self.assertTrue(any( + diagnostic["feature_id"] == "F3" and diagnostic["code"] == "feature_deferred" + for diagnostic in result.diagnostics + )) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "sweep-swept-face-segmented-path.step") + # The complete four-segment source path is preserved instead of + # falling back to one representative leaf. OCC rejects this actual + # PipeShell as an invalid solid, which is a modelling/kernel boundary + # and must remain distinct from selector resolution success. + self.assertEqual(rebuilt["status"], "rebuild_failed") + self.assertEqual(rebuilt["error"]["message"], "OCC sweep operation did not produce a valid solid") + + def test_initial_direct_sweep_swept_edge_requires_an_adjacent_profile_vertex_and_path(self): + """PipeShell Generated(vertex) binds one explicit pair of profile edges.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0033/00330012.txt" + if not source_path.exists(): self.skipTest("CADFS sample is not installed") + source = source_path.read_text(encoding="utf-8") + source = source.replace( + 'skCircle(sketch, "E0", {"center": v(0, 0) * mm, "radius": 6.35 * mm});', + '''skLineSegment(sketch, "E0", {"start": v(-4, -4) * mm, "end": v(4, -4) * mm}); + skLineSegment(sketch, "E1", {"start": v(4, -4) * mm, "end": v(4, 4) * mm}); + skLineSegment(sketch, "E2", {"start": v(4, 4) * mm, "end": v(-4, 4) * mm}); + skLineSegment(sketch, "E3", {"start": v(-4, 4) * mm, "end": v(-4, -4) * mm});''', + ) + source = re.sub( + r'skFitSpline\(sketch, "E1", \{.*?\}\);', + 'skLineSegment(sketch, "E1", {"start": v(0, 0) * mm, "end": v(100, 0) * mm});', + source, + ).replace( + 'Q0=makeQuery(id+"F0.imprint","IMPRINT",FACE,{"disambiguationData":[TD([[makeQuery(id+"F0.imprint","IMPRINT",EDGE,{"derivedFrom":sQuery(id+"F0.wireOp",EDGE,"E0")}),1.0]])]});', + 'Q0=qSketchRegion(id+"F0",true);', + ).replace( + 'Q0=makeQuery(id+"F2.opSweep","CAP_FACE",FACE,{"disambiguationData":[OSD([sQuery(id+"F0.wireOp",EDGE,"E0"),sQuery(id+"F1.wireOp",VERTEX,"E1.end")])],"isStart":false});', + 'Q0=makeQuery(id+"F2.opSweep","SWEPT_EDGE",EDGE,{"disambiguationData":[OSD([sQuery(id+"F0.wireOp",EDGE,"E1"),sQuery(id+"F0.wireOp",EDGE,"E2"),sQuery(id+"F1.wireOp",EDGE,"E1")])]});', + ).replace( + 'shell(context, id + "F3", {"entities" : qUnion([Q0]), "thickness" : 1.27 * mm});', + 'fillet(context, id + "F3", {"entities" : qUnion([Q0]), "radius" : 0.5 * mm, "tangentPropagation" : true, "allowEdgeOverflow" : false});', + ) + result = lower_model(parse_featurescript(source, "00330012-sweep-swept-edge"), {}) + self.assertEqual(result.status, "converted_complete") + features = {item["id"]: item for item in result.cdsl["features"]} + self.assertEqual(features["f_F2"]["params"]["swept_edge_contract"], { + "profile_source": "F0", "profile_entities": ["E0", "E1", "E2", "E3"], + "path_source": "F1", "path_entity": "E1", "path_reversed": False, + }) + selector = features["f_F3"]["selectors"][0] + self.assertNotIn("geometry", selector) + self.assertNotIn("stable_id", selector) + self.assertEqual(selector["selector_intent"]["source_entities"], [ + {"sketch_id": "F0", "entity_id": "E1"}, + {"sketch_id": "F0", "entity_id": "E2"}, + ]) + self.assertEqual(selector["selector_intent"]["disambiguation"], { + "type": "sweep_profile_vertex_path", + "profile_source": "F0", "profile_entities": ["E0", "E1", "E2", "E3"], + "path_source": "F1", "path_entity": "E1", "path_reversed": False, + "profile_vertex_entities": ["E1", "E2"], + }) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "sweep-swept-edge.step") + self.assertEqual(rebuilt["status"], "rebuilt") + resolution = next(item for item in rebuilt["result"]["selector_resolution"] if item["feature_id"] == "f_F3") + self.assertEqual(resolution["resolution_mode"], "kernel_lineage") + self.assertEqual(resolution["evidence"]["relations"][0]["source_kind"], "vertex") + self.assertEqual(resolution["evidence"]["relations"][0]["result_kind"], "edge") + self.assertEqual(resolution["evidence"]["relations"][0]["coverage"], "complete") + + rejected_source = source.replace( + 'sQuery(id+"F0.wireOp",EDGE,"E1"),sQuery(id+"F0.wireOp",EDGE,"E2")', + 'sQuery(id+"F0.wireOp",EDGE,"E0"),sQuery(id+"F0.wireOp",EDGE,"E2")', + 1, + ) + rejected = lower_model(parse_featurescript(rejected_source, "00330012-sweep-swept-edge-non-adjacent"), {}) + rejected_selector = next(item for item in rejected.cdsl["features"] if item["id"] == "f_F3")["selectors"][0] + self.assertEqual(rejected_selector["selector_intent"]["evidence"], "feature_script_query") + self.assertNotIn("geometry", rejected_selector) + self.assertNotIn("stable_id", rejected_selector) + + def test_direct_prism_swept_edge_dressup_allows_proven_continuation_only(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0009/00094474.txt" + if not source_path.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(source_path.read_text(encoding="utf-8"), "00094474"), {}) + selector = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")["selectors"][0] + self.assertEqual(selector["selector_intent"]["query_family"], "SWEPT_EDGE") + self.assertEqual(selector["selector_intent"]["derivation_policy"], { + "allowed": ["boundary", "continuation"], "multiplicity": "one", + }) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "swept-edge-continuation.step") + self.assertEqual(rebuilt["status"], "rebuilt") + resolution = next(item for item in rebuilt["result"]["selector_resolution"] if item["feature_id"] == "f_F3") + self.assertEqual(resolution["resolution_mode"], "kernel_lineage") + self.assertTrue(resolution["evidence"]["relations"]) + + cut_path = root / "featurescript_rp/0037/00370320.txt" + if not cut_path.exists(): self.skipTest("CADFS continuation-through-cut sample is not installed") + cut = lower_model(parse_featurescript(cut_path.read_text(encoding="utf-8"), "00370320"), {}) + with tempfile.TemporaryDirectory() as directory: + cut_rebuilt = rebuild_candidate(cut.cdsl, Path(directory) / "swept-edge-cut-continuation.step") + self.assertEqual(cut_rebuilt["status"], "rebuilt") + cut_resolution = next( + item for item in cut_rebuilt["result"]["selector_resolution"] if item["feature_id"] == "f_F4" + ) + self.assertEqual(cut_resolution["resolution_mode"], "kernel_lineage") + self.assertEqual( + [relation["derivation"] for relation in cut_resolution["evidence"]["relations"]], + ["boundary", "continuation"], + ) + + ambiguous_path = root / "featurescript_rp/0002/00027017.txt" + if not ambiguous_path.exists(): self.skipTest("CADFS continuation boundary sample is not installed") + ambiguous = lower_model(parse_featurescript(ambiguous_path.read_text(encoding="utf-8"), "00027017"), {}) + with tempfile.TemporaryDirectory() as directory: + rejected = rebuild_candidate(ambiguous.cdsl, Path(directory) / "swept-edge-ambiguous.step") + self.assertEqual(rejected["status"], "rebuild_failed") + self.assertEqual(rejected["error"]["message"], "f_F3: selector_relation_non_unique during incremental replay") + self.assertEqual(rejected["last_executable_prefix"]["last_feature_id"], "f_F2") + + def test_direct_swept_edge_query_set_uses_its_producer_source_anchors(self): + """A later use of the same sketch cannot duplicate an F1 source anchor.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0040/00407468.txt" + if not source_path.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(source_path.read_text(encoding="utf-8"), "00407468"), {}) + selector = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")["selectors"][0] + self.assertEqual(selector["selector_intent"]["query_family"], "QUERY_SET") + self.assertEqual(len(selector["query_operands"]), 4) + self.assertTrue(all(item["owner_feature_id"] == "f_F1" for item in selector["query_operands"])) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "producer-source-anchor.step") + self.assertEqual(rebuilt["status"], "rebuilt") + resolution = next(item for item in rebuilt["result"]["selector_resolution"] if item["feature_id"] == "f_F3") + self.assertEqual(resolution["resolution_mode"], "query_set_union") + self.assertEqual( + [item["record_id"] for item in resolution["records"]], + ["body:f_F2:edge:19", "body:f_F2:edge:22", "body:f_F2:edge:25", "body:f_F2:edge:28"], + ) + + def test_2491_two_sided_prism_cap_edges_use_exact_far_cap_history(self): + """The reverse prism's far cap is the FeatureScript start cap.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0004/00049094.txt" + if not source_path.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(source_path.read_text(encoding="utf-8"), "00049094"), {}) + feature = next(item for item in result.cdsl["features"] if item["id"] == "f_F2") + self.assertEqual(feature["selectors"][0]["selector_intent"]["query_family"], "QUERY_SET") + self.assertEqual(len(feature["selectors"][0]["query_operands"]), 4) + self.assertTrue(all( + item["selector_intent"]["query_family"] == "CAP_EDGE" + and item["selector_intent"]["derivation_policy"] == { + "allowed": ["boundary"], "multiplicity": "one", + } + for item in feature["selectors"][0]["query_operands"] + )) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "two-sided-cap-edges.step") + self.assertEqual(rebuilt["status"], "rebuilt") + resolution = next(item for item in rebuilt["result"]["selector_resolution"] if item["feature_id"] == "f_F2") + self.assertEqual(resolution["resolution_mode"], "query_set_union") + self.assertEqual(len(resolution["records"]), 4) + roles = { + relation["output_role"] + for operand in resolution["evidence"]["operand_resolutions"] + for relation in operand["relations"] + } + self.assertEqual(roles, {"extrude.start", "extrude.end"}) + + rejected_path = root / "featurescript_rp/0040/00404726.txt" + if not rejected_path.exists(): self.skipTest("CADFS negative sample is not installed") + rejected = lower_model(parse_featurescript(rejected_path.read_text(encoding="utf-8"), "00404726"), {}) + selectors = next(item for item in rejected.cdsl["features"] if item["id"] == "f_F2")["selectors"] + self.assertTrue(all( + item["selector_intent"]["evidence"] == "feature_script_query" + and item["selector_intent"]["derivation_policy"] == { + "allowed": ["continuation"], "multiplicity": "none", + } + for item in selectors + )) + + def test_two_sided_circle_shell_uses_exact_far_cap_roles(self): + """A symmetric circle shell consumes both far prism caps, not their seam.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + for sample_id, producer_id, shell_id, roles in ( + ("00019252", "f_F1", "f_F2", ["extrude.end", "extrude.start"]), + ("00000316", "f_F2", "f_F3", ["extrude.start", "extrude.end"]), + ): + source_path = root / f"featurescript_rp/{sample_id[:4]}/{sample_id}.txt" + if not source_path.exists(): + self.skipTest("CADFS two-sided circle shell samples are not installed") + result = lower_model(parse_featurescript(source_path.read_text(encoding="utf-8"), sample_id), {}) + shell = next(item for item in result.cdsl["features"] if item["id"] == shell_id) + self.assertEqual( + [item["output_role"] for item in shell["selectors"]], + roles, + ) + self.assertTrue(all( + item["owner_feature_id"] == producer_id + and item["selector_intent"]["query_family"] == "CAP_FACE" + and item["selector_intent"]["derivation_policy"] == { + "allowed": ["boundary"], "multiplicity": "one", + } + for item in shell["selectors"] + )) + shell_index = next( + index for index, item in enumerate(result.cdsl["features"]) + if item["id"] == shell_id + ) + prefix = deepcopy(result.cdsl) + prefix["features"] = prefix["features"][:shell_index + 1] + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(prefix, Path(directory) / f"{sample_id}-two-sided-shell.step") + self.assertEqual(rebuilt["status"], "rebuilt") + resolutions = [ + item for item in rebuilt["result"]["selector_resolution"] + if item["feature_id"] == shell_id + ] + self.assertEqual( + [item["record"]["record_id"] for item in resolutions], + [ + f"body:{producer_id}:face:{2 if role == 'extrude.end' else 3}" + for role in roles + ], + ) + + def test_typed_new_body_add_cap_edge_uses_exact_union_continuation(self): + """NewBodyOperationType.ADD resolves only through its exact active union successor.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + for sample_id, consumer_id in (("00953397", "f_F7"), ("00957101", "f_F4")): + with self.subTest(sample_id=sample_id): + source_path = root / f"featurescript_rp/{sample_id[:4]}/{sample_id}.txt" + if not source_path.exists(): self.skipTest("CADFS primary-add cap-edge samples are not installed") + result = lower_model(parse_featurescript(source_path.read_text(encoding="utf-8"), sample_id), {}) + selector = next(item for item in result.cdsl["features"] if item["id"] == consumer_id)["selectors"][0] + self.assertEqual(selector["selector_intent"]["query_family"], "CAP_EDGE") + self.assertEqual(selector["selector_intent"]["derivation_policy"], { + "allowed": ["boundary", "continuation"], "multiplicity": "one", + }) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(result.cdsl, Path(directory) / f"{sample_id}-primary-add-cap-edge.step") + self.assertEqual(outcome["status"], "rebuilt") + resolution = next(item for item in outcome["result"]["selector_resolution"] if item["feature_id"] == consumer_id) + self.assertEqual(resolution["status"], "resolved") + self.assertEqual(resolution["resolution_mode"], "kernel_lineage") + self.assertEqual( + [relation["operation"] for relation in resolution["evidence"]["relations"]], + ["extrude", "union"], + ) + + rejected_path = root / "featurescript_rp/0040/00406939.txt" + if not rejected_path.exists(): self.skipTest("CADFS multi-solid primary-add sample is not installed") + rejected = lower_model(parse_featurescript(rejected_path.read_text(encoding="utf-8"), "00406939"), {}) + selectors = next(item for item in rejected.cdsl["features"] if item["id"] == "f_F3")["selectors"] + self.assertTrue(all( + item["selector_intent"]["evidence"] == "feature_script_query" + and item["selector_intent"]["derivation_policy"]["multiplicity"] == "none" + for item in selectors[:2] + )) + + def test_typed_new_body_add_cap_face_shell_uses_exact_union_continuation(self): + """A typed ADD CAP face needs an exact active union successor for shell use.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + for sample_id, consumer_id in (("00074047", "f_F4"), ("00350698", "f_F7")): + with self.subTest(sample_id=sample_id): + source_path = root / f"featurescript_rp/{sample_id[:4]}/{sample_id}.txt" + if not source_path.exists(): self.skipTest("CADFS primary-add cap-face samples are not installed") + result = lower_model(parse_featurescript(source_path.read_text(encoding="utf-8"), sample_id), {}) + selector = next(item for item in result.cdsl["features"] if item["id"] == consumer_id)["selectors"][0] + self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE") + self.assertEqual(selector["selector_intent"]["derivation_policy"], { + "allowed": ["boundary", "continuation"], "multiplicity": "one", + }) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(result.cdsl, Path(directory) / f"{sample_id}-primary-add-cap-face.step") + self.assertIn(outcome["status"], {"rebuilt", "rebuild_failed"}) + payload = outcome.get("result") or outcome["error"] + resolution = next( + item for item in payload["selector_resolution" if "result" in outcome else "selector_resolutions"] + if item["feature_id"] == consumer_id + ) + self.assertEqual(resolution["status"], "resolved") + self.assertEqual(resolution["resolution_mode"], "operation_role") + self.assertEqual( + [relation["operation"] for relation in resolution["evidence"]["relations"]], + ["extrude", "union"], + ) + + rejected_path = root / "featurescript_rp/0029/00293014.txt" + if not rejected_path.exists(): self.skipTest("CADFS primary-add cap-face ambiguity sample is not installed") + rejected = lower_model(parse_featurescript(rejected_path.read_text(encoding="utf-8"), "00293014"), {}) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(rejected.cdsl, Path(directory) / "00293014-primary-add-cap-face.step") + self.assertEqual(outcome["status"], "rebuild_failed") + self.assertEqual(outcome["error"]["message"], "f_F4: selector_output_role_ambiguous during incremental replay") + self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F3") + + multi_solid_path = root / "featurescript_rp/0059/00590599.txt" + if not multi_solid_path.exists(): self.skipTest("CADFS multi-solid primary-add cap-face sample is not installed") + multi_solid = lower_model(parse_featurescript(multi_solid_path.read_text(encoding="utf-8"), "00590599"), {}) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(multi_solid.cdsl, Path(directory) / "00590599-primary-add-cap-face.step") + self.assertEqual(outcome["status"], "rebuild_failed") + self.assertEqual(outcome["error"]["message"], "f_F3: selector_query_unsupported during incremental replay") + self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F2") + + def test_typed_new_body_add_cap_face_up_to_surface_uses_exact_union_continuation(self): + """A typed ADD CAP face needs its exact active union successor for an extent.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0000/00007973.txt" + if not source_path.exists(): self.skipTest("CADFS primary-add up-to-surface sample is not installed") + result = lower_model(parse_featurescript(source_path.read_text(encoding="utf-8"), "00007973"), {}) + target = next(item for item in result.cdsl["features"] if item["id"] == "f_F7") + selector = target["params"]["end_condition"]["reference"] + self.assertEqual( + selector["selector_intent"].get("consumer_contract"), + "primary_add_up_to_surface_union_continuation", + ) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(result.cdsl, Path(directory) / "00007973-primary-add-up-to-surface.step") + self.assertEqual(outcome["status"], "rebuilt") + resolution = next(item for item in outcome["result"]["selector_resolution"] if item["feature_id"] == "f_F7") + self.assertEqual(resolution["status"], "resolved") + self.assertEqual(resolution["resolution_mode"], "operation_role") + self.assertEqual( + [relation["operation"] for relation in resolution["evidence"]["relations"]], + ["extrude", "union"], + ) + + ambiguous_path = root / "featurescript_rp/0063/00638700.txt" + if not ambiguous_path.exists(): self.skipTest("CADFS ambiguous primary-add up-to-surface sample is not installed") + ambiguous = lower_model(parse_featurescript(ambiguous_path.read_text(encoding="utf-8"), "00638700"), {}) + cut_index = next(index for index, item in enumerate(ambiguous.cdsl["features"]) if item["id"] == "f_F5") + prefix = deepcopy(ambiguous.cdsl) + prefix["features"] = prefix["features"][:cut_index + 1] + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(prefix, Path(directory) / "00638700-primary-add-up-to-surface.step") + self.assertEqual(outcome["status"], "rebuild_failed") + self.assertEqual(outcome["error"]["message"], "f_F5: selector_output_role_ambiguous during incremental replay") + self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F3") + + def test_two_sided_up_to_surface_requires_one_direct_opposite_cap_pair(self): + """Symmetric extent targets are admitted only as the two far CAP roles.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0021/00215642.txt" + if not source_path.exists(): self.skipTest("CADFS two-sided CAP-pair sample is not installed") + result = lower_model(parse_featurescript(source_path.read_text(encoding="utf-8"), "00215642"), {}) + target_index = next(index for index, item in enumerate(result.cdsl["features"]) if item["id"] == "f_F3") + target = result.cdsl["features"][target_index] + forward = target["params"]["end_condition"]["reference"] + reverse = target["params"]["reverse_end_condition"]["reference"] + self.assertEqual(forward["owner_feature_id"], "f_F1") + self.assertEqual(reverse["owner_feature_id"], "f_F1") + self.assertEqual({forward["output_role"], reverse["output_role"]}, {"extrude.start", "extrude.end"}) + self.assertTrue(all( + item["selector_intent"]["consumer_contract"] == "symmetric_direct_prism_two_sided_up_to_surface_cap_pair" + and item["selector_intent"]["derivation_policy"] == {"allowed": ["boundary"], "multiplicity": "one"} + and item["selector_intent"]["disambiguation"]["source_profile_entity_ids"] == ["E0", "E1", "E2"] + for item in (forward, reverse) + )) + prefix = deepcopy(result.cdsl) + prefix["features"] = prefix["features"][:target_index + 1] + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(prefix, Path(directory) / "00215642-two-sided-cap-pair.step") + self.assertEqual(rebuilt["status"], "rebuilt") + resolutions = [ + item for item in rebuilt["result"]["selector_resolution"] + if item["feature_id"] == "f_F3" + ] + self.assertEqual(len(resolutions), 2) + self.assertEqual({item["selected"]["output_roles"][0] for item in resolutions}, {"extrude.start", "extrude.end"}) + + rejected_path = root / "featurescript_rp/0093/00935255.txt" + if not rejected_path.exists(): self.skipTest("CADFS different-owner CAP-pair sample is not installed") + rejected = lower_model(parse_featurescript(rejected_path.read_text(encoding="utf-8"), "00935255"), {}) + self.assertNotIn("f_F7", {item["id"] for item in rejected.cdsl["features"]}) + self.assertIn(("F7", "extrude_extent_face_selector"), { + (item.get("feature_id"), item.get("capability")) for item in rejected.diagnostics + }) + + def test_two_sided_up_to_surface_can_follow_one_shell_with_proven_swept_faces(self): + """A paired extent may consume only two retained direct prism walls.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0018/00180262.txt" + if not source_path.exists(): self.skipTest("CADFS shell swept-face extent sample is not installed") + result = lower_model(parse_featurescript(source_path.read_text(encoding="utf-8"), "00180262"), {}) + target_index = next(index for index, item in enumerate(result.cdsl["features"]) if item["id"] == "f_F4") + target = result.cdsl["features"][target_index] + forward = target["params"]["end_condition"]["reference"] + reverse = target["params"]["reverse_end_condition"]["reference"] + self.assertTrue(all( + item.get("output_role") is None + and item["owner_feature_id"] == "f_F1" + and item["selector_intent"]["consumer_contract"] == "symmetric_direct_prism_shell_swept_face_up_to_surface_pair" + and item["selector_intent"]["derivation_policy"] == {"allowed": ["boundary", "continuation"], "multiplicity": "one"} + for item in (forward, reverse) + )) + self.assertEqual( + {item["selector_intent"]["source_entity"]["entity_id"] for item in (forward, reverse)}, + {"E0.left", "E0.right"}, + ) + prefix = deepcopy(result.cdsl) + prefix["features"] = prefix["features"][:target_index + 1] + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(prefix, Path(directory) / "00180262-shell-swept-pair.step") + self.assertEqual(rebuilt["status"], "rebuilt") + resolutions = [ + item for item in rebuilt["result"]["selector_resolution"] + if item["feature_id"] == "f_F4" + ] + self.assertEqual(len(resolutions), 2) + self.assertTrue(all(item["resolution_mode"] == "kernel_lineage" for item in resolutions)) + self.assertEqual( + {item["selected"]["output_roles"][0] for item in resolutions}, + {"shell.offset_face"}, + ) + + # A closing descendant of a removed target side is not an offset + # face. The source mutation keeps all surrounding feature topology + # intact while making F2 remove F4's right-side target. + removed_target_source = source_path.read_text(encoding="utf-8").replace( + 'sQuery(id+"F0.wireOp",EDGE,"E0.bottom")', + 'sQuery(id+"F0.wireOp",EDGE,"E0.right")', + 1, + ) + rejected = lower_model(parse_featurescript(removed_target_source, "00180262-removed-target"), {}) + self.assertNotIn("f_F4", {item["id"] for item in rejected.cdsl["features"]}) + self.assertIn(("F4", "extrude_extent_face_selector"), { + (item.get("feature_id"), item.get("capability")) for item in rejected.diagnostics + }) + + def test_typed_new_body_add_cap_face_dressup_uses_exact_union_contract(self): + """The typed ADD producer is selectable only through exact union lineage.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source_path = root / "featurescript_rp/0005/00051494.txt" + if not source_path.exists(): self.skipTest("CADFS primary-add CAP_FACE dress-up sample is not installed") + result = lower_model(parse_featurescript(source_path.read_text(encoding="utf-8"), "00051494"), {}) + target = next(item for item in result.cdsl["features"] if item["id"] == "f_F4") + selector = target["selectors"][0] + self.assertEqual( + selector["selector_intent"].get("consumer_contract"), + "primary_add_dressup_union_continuation", + ) + self.assertEqual( + selector["selector_intent"]["derivation_policy"], + {"allowed": ["boundary", "continuation"], "multiplicity": "one"}, + ) + + ambiguous_path = root / "featurescript_rp/0066/00660816.txt" + if not ambiguous_path.exists(): self.skipTest("CADFS ambiguous primary-add CAP_FACE dress-up sample is not installed") + ambiguous = lower_model(parse_featurescript(ambiguous_path.read_text(encoding="utf-8"), "00660816"), {}) + cut_index = next(index for index, item in enumerate(ambiguous.cdsl["features"]) if item["id"] == "f_F4") + prefix = deepcopy(ambiguous.cdsl) + prefix["features"] = prefix["features"][:cut_index + 1] + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(prefix, Path(directory) / "00660816-primary-add-dressup.step") + self.assertEqual(outcome["status"], "rebuild_failed") + self.assertEqual( + outcome["error"]["message"], + "f_F4: selector_output_role_ambiguous during incremental replay", + ) + self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F3") + def test_closed_fit_splines_lower_to_periodic_contours_and_join_split_imprints(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0061/00612529.txt" @@ -1391,15 +4315,18 @@ class LoweringTests(unittest.TestCase): 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. + # Typed ADD operations fuse into the one active member. F12 must use + # that active F11 successor while retaining F1 as a source alias. self.assertEqual(features["f_F12"]["params"]["source_feature_ids"], ["f_F11"]) + self.assertEqual(features["f_F12"]["params"]["source_member_aliases"], [{ + "source_feature_id": "f_F1", + "active_member_feature_id": "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"])) + self.assertFalse(f12_analysis["blockers"]) # Later chamfers have a separate selector gap. The retained prefix # proves the two CAP_EDGE profile unions themselves execute through @@ -1566,6 +4493,7 @@ class LoweringTests(unittest.TestCase): "operation": "union", "target_feature_ids": ["f_F1"], "keep_tools": False, + "targetless_body_set": True, "tool_pattern_instance_refs": [{ "pattern_feature_id": "f_F5", "source_feature_id": "f_F1", @@ -1595,12 +4523,49 @@ class LoweringTests(unittest.TestCase): }], ) + def test_immediate_direct_prism_cap_and_swept_faces_lower_as_mirror_datums(self): + """A mirror may use only its current direct-prism source face as a datum.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test/featurescript_rp" + cases = { + "00094392": ("0009/00094392.txt", "CAP_FACE", [0.0, -204.44, 0.0]), + "00051481": ("0005/00051481.txt", "SWEPT_FACE", [0.0, 0.0, 0.0]), + } + for sample_id, (relative_path, family, expected_origin) in cases.items(): + with self.subTest(sample_id=sample_id): + source = root / relative_path + if not source.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(source.read_text(encoding="utf-8"), sample_id), {}) + features = {item["id"]: item for item in result.cdsl["features"]} + mirror = features["f_F2"] + plane = features["f_F2_plane"] + self.assertEqual(mirror["atomic_id"], "pattern_mirror") + self.assertEqual(mirror["depends_on"], ["f_F1", "f_F2_plane"]) + self.assertEqual(plane["params"]["plane"]["origin_mm"], expected_origin) + self.assertEqual( + mirror["params"]["mirror_plane"]["selector_intent"]["query_family"], + "GEOMETRIC", + ) + self.assertFalse(any(item.get("feature_id") == "F2" for item in result.diagnostics)) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "mirror.step") + feature_result = next(item for item in rebuilt["result"]["feature_results"] if item["feature_id"] == "f_F2") + self.assertEqual(feature_result["status"], "executed") + + # A drafted, non-1511 prism's side plane has no admission under this + # source-datum contract, even though its query spelling is identical. + rejected_source = root / "0035/00356398.txt" + if rejected_source.exists(): + rejected = lower_model(parse_featurescript(rejected_source.read_text(encoding="utf-8"), "00356398"), {}) + self.assertIn(("F2", "mirror plane is not a default or reference plane"), { + (item.get("feature_id"), item.get("message")) for item in rejected.diagnostics + }) + 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") + self.assertEqual(result.status, "converted_partial") 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"], { @@ -1608,13 +4573,74 @@ class LoweringTests(unittest.TestCase): "target_feature_ids": ["f_F1"], "tool_feature_ids": ["f_F3"], "keep_tools": False, + "targetless_body_set": True, }) + self.assertEqual( + [(item["feature_id"], item["message"]) for item in result.diagnostics], + [ + ("F7", "MERGE(FACE) workplane requires a dedicated complete/proven runtime face relation"), + ("F8", "extrude sketch query is unresolved"), + ("F9", "MERGE(FACE) workplane requires a dedicated complete/proven runtime face relation"), + ("F10", "extrude sketch query is unresolved"), + ], + ) 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_targetless_direct_body_intersection_preserves_source_order(self): + """A targetless intersection has an explicit left operand, not current body.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0007/00073309.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), "00073309"), {}) + self.assertEqual(result.status, "converted_complete") + boolean = next(item for item in result.cdsl["features"] if item["id"] == "f_F7") + self.assertEqual(boolean["params"], { + "operation": "intersect", + "target_feature_ids": ["f_F5"], + "tool_feature_ids": ["f_F1"], + "keep_tools": False, + "targetless_body_set": True, + }) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "targetless-intersection.step") + self.assertEqual(rebuilt["status"], "rebuilt") + self.assertEqual( + next(item for item in rebuilt["result"]["feature_results"] if item["feature_id"] == "f_F7")["status"], + "executed", + ) + + def test_targetless_body_set_keeps_the_chosen_left_member_when_requested(self): + """keepTools applies to the complete original targetless input set.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0021/00215642.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), "00215642"), {}) + boolean = next(item for item in result.cdsl["features"] if item["id"] == "f_F6") + self.assertEqual(boolean["params"], { + "operation": "union", + "target_feature_ids": ["f_F3"], + "tool_feature_ids": ["f_F1"], + "keep_tools": True, + "targetless_body_set": True, + }) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_candidate(result.cdsl, Path(directory) / "targetless-keep-tools.step") + self.assertEqual(rebuilt["status"], "runtime_ineligible") + prefix = rebuilt["last_executable_prefix"] + self.assertEqual(prefix["last_feature_id"], "f_F12") + self.assertIn("f_F6", [item["feature_id"] for item in prefix["result"]["feature_results"]]) + + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + invalid = deepcopy(result.cdsl) + invalid_feature = next(item for item in invalid["features"] if item["id"] == "f_F6") + invalid_feature["params"]["operation"] = "subtract" + with self.assertRaisesRegex(ValueError, "targetless_body_set requires one qualified body"): + validate_semantic_cdsl(invalid) + 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" @@ -1644,7 +4670,7 @@ class LoweringTests(unittest.TestCase): ["E14", "E14"], ) - def test_direct_imprint_profile_preserves_selector_intent_and_f1_checkpoint(self): + def test_direct_imprint_profile_reaches_the_dressup_after_exact_selector_resolution(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0035/00354246.txt" if not feature.exists(): self.skipTest("CADFS sample is not installed") @@ -1661,21 +4687,32 @@ class LoweringTests(unittest.TestCase): ]) selectors = features["f_F2"]["selectors"] - self.assertEqual(len(selectors), 7) + self.assertEqual(len(selectors), 1) + selector = selectors[0] + self.assertEqual(selector["selector_intent"]["query_family"], "QUERY_SET") + self.assertEqual(selector["selector_intent"]["query_set_contract"], "proven_operand_union") + self.assertEqual(selector["selector_intent"]["query_expr"]["root"]["operator"], "union") + self.assertEqual(len(selector["query_operands"]), 7) self.assertTrue(all( - selector["selector_intent"]["query_family"] == "SWEPT_FACE" - and selector["selector_intent"]["derivation_policy"] == { - "allowed": ["continuation"], "multiplicity": "none", + item["selector_intent"]["query_family"] == "SWEPT_FACE" + and item["selector_intent"]["derivation_policy"] == { + "allowed": ["boundary", "fragment"], "multiplicity": "all_fragments", } - for selector in selectors + and "geometry" not in item + for item in selector["query_operands"] )) - self.assertTrue(any("geometry" not in selector for selector in selectors)) with tempfile.TemporaryDirectory() as directory: rebuilt = Path(directory) / "rebuild.step" outcome = rebuild_candidate(result.cdsl, rebuilt) self.assertEqual(outcome["status"], "rebuild_failed") - self.assertEqual(outcome["error"]["message"], "f_F2: selector_query_unsupported during incremental replay") + self.assertIn("Failed creating a fillet with radius of 5.08", outcome["error"]["message"]) + resolutions = outcome["error"].get("selector_resolutions") or [] + self.assertEqual(len(resolutions), 1) + self.assertEqual(resolutions[0]["feature_id"], "f_F2") + self.assertEqual(resolutions[0]["status"], "resolved") + self.assertEqual(resolutions[0]["resolution_mode"], "query_set_union") + self.assertEqual(len(resolutions[0]["evidence"]["operand_resolutions"]), 7) self.assertTrue(rebuilt.exists()) self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F1") @@ -1853,6 +4890,47 @@ class LoweringTests(unittest.TestCase): diagnostics = [item for item in result.diagnostics if item.get("operation") == "cPlane"] self.assertFalse(diagnostics) + def test_cap_vertex_datum_keeps_source_positions_across_a_direct_cap_shell(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0021/00212904.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + source = feature.read_text(encoding="utf-8").replace( + '"isStart":true});\n shell(context, id + "F2"', + '"isStart":false});\n shell(context, id + "F2"', + 1, + ) + result = lower_model(parse_featurescript(source, "00212904-shell-removes-datum-cap"), {}) + self.assertIn("f_F3", {item["id"] for item in result.cdsl["features"]}) + self.assertFalse(any(item.get("feature_id") == "F3" for item in result.diagnostics)) + + def test_plane_point_distinguishes_direct_cap_face_from_source_vertex(self): + """PLANE_POINT roles come from query kinds, not qCreatedBy spelling.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0022/00228556.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(feature.read_text(), "00228556"), {}) + plane = next(item for item in result.cdsl["features"] if item["id"] == "f_F3") + self.assertEqual(plane["atomic_id"], "reference_plane") + self.assertEqual(set(plane["params"]["plane"]), {"origin_mm", "x_dir", "normal"}) + self.assertFalse(any( + item.get("operation") == "cPlane" and item.get("feature_id") == "F3" + for item in result.diagnostics + )) + + ambiguous = feature.read_text().replace( + 'Q1=makeQuery(id+"F1.opExtrude","CAP_FACE",FACE,', + 'Q1=sQuery(id+"F2.wireOp",VERTEX,', + 1, + ) + rejected = lower_model(parse_featurescript(ambiguous, "00228556-ambiguous-plane-point"), {}) + self.assertIn({ + "code": "feature_deferred", + "feature_id": "F3", + "operation": "cPlane", + "message": "plane-point requires exactly one face and one vertex", + }, rejected.diagnostics) + def test_curve_point_plane_uses_an_explicit_bspline_endpoint_tangent(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0082/00827798.txt" @@ -1990,7 +5068,7 @@ class LoweringTests(unittest.TestCase): "normal": [1.0, 0.0, 0.0], }) - def test_shell_preserves_pattern_copy_cap_faces_and_offset_edge_fillet(self): + def test_shell_defers_pattern_copy_cap_faces_without_an_instance_member(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") @@ -1998,34 +5076,14 @@ class LoweringTests(unittest.TestCase): result = lower_model(parse_featurescript(feature.read_text(), "00542223"), {}) features = {item["id"]: item for item in result.cdsl["features"]} sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} - shell = features["f_F7"] - fillet = features["f_F8"] - self.assertEqual(sketches["sketch_F4"]["workplane"]["origin_mm"], [0.0, 0.0, -50.0]) self.assertEqual(features["f_F5"]["params"]["path"]["segment"]["points"][0], [-40.0, -50.0]) self.assertEqual(features["f_F5"]["params"]["path"]["segment"]["start_tangent"], [27.94, 92.49]) - self.assertEqual(shell["selectors"][1]["geometry"], { - "normal": [0.0, 0.0, 1.0], "plane_offset_mm": -50.0, - "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.assertNotIn("f_F7", features) self.assertEqual( - [selector["owner_feature_id"] for selector in shell["selectors"][:3]], - ["f_F6.c1.f_F5", "f_F5", "f_F6.c2.f_F5"], + next(item for item in result.diagnostics if item.get("feature_id") == "F7")["message"], + "pattern copy source feature is not replayed by its owner", ) - self.assertEqual(fillet["atomic_id"], "fillet") - self.assertEqual(fillet["selectors"][0]["geometry"]["source_circle_radius_mm"], 17.5) - 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_resolves_direct_prism_swept_faces_through_kernel_lineage(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -2060,6 +5118,50 @@ class LoweringTests(unittest.TestCase): for item in resolutions )) + def test_shell_resolves_a_direct_prism_swept_face_after_one_primary_cut(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0002/00020670.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(feature.read_text(), "00020670"), {}) + self.assertEqual(result.status, "converted_partial") + self.assertIn(("F10", "extrude_extent_face_selector"), { + (item.get("feature_id"), item.get("capability")) for item in result.diagnostics + }) + shell_index = next(index for index, item in enumerate(result.cdsl["features"]) if item["id"] == "f_F4") + shell = result.cdsl["features"][shell_index] + self.assertEqual(shell["atomic_id"], "shell") + self.assertEqual(len(shell["selectors"]), 1) + selector = shell["selectors"][0] + self.assertEqual(selector["owner_feature_id"], "f_F1") + self.assertEqual(selector["selector_intent"]["query_family"], "SWEPT_FACE") + self.assertEqual(selector["selector_intent"]["derivation_policy"], { + "allowed": ["boundary", "continuation"], "multiplicity": "one", + }) + self.assertNotIn("geometry", selector) + self.assertNotIn("stable_id", selector) + + prefix = deepcopy(result.cdsl) + prefix["features"] = prefix["features"][:shell_index + 1] + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(prefix, Path(directory) / "swept-face-primary-cut-shell.step") + self.assertEqual(outcome["status"], "rebuilt") + resolution = next(item for item in outcome["result"]["selector_resolution"] if item["feature_id"] == "f_F4") + self.assertEqual(resolution["resolution_mode"], "kernel_lineage") + self.assertEqual(len(resolution["evidence"]["relations"]), 2) + self.assertEqual(resolution["evidence"]["relations"][1]["operation"], "subtract") + + def test_shell_does_not_treat_an_add_as_a_primary_cut_swept_face_continuation(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0002/00020670.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + source = feature.read_text().replace("NewBodyOperationType.REMOVE", "NewBodyOperationType.ADD", 1) + result = lower_model(parse_featurescript(source, "00020670-no-primary-cut"), {}) + self.assertNotIn("f_F4", {item["id"] for item in result.cdsl["features"]}) + diagnostic = next(item for item in result.diagnostics if item.get("feature_id") == "F4") + self.assertEqual(diagnostic["capability"], "shell_face_selector") + def test_shell_resolves_immediate_direct_prism_cap_faces_through_output_roles(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" cases = (("00212904", "extrude.start"), ("00789939", "extrude.end")) @@ -2068,7 +5170,12 @@ class LoweringTests(unittest.TestCase): 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(encoding="utf-8"), sample_id), {}) - self.assertEqual(result.status, "converted_complete") + expected_status = "converted_complete" if sample_id == "00212904" else "converted_partial" + self.assertEqual(result.status, expected_status) + if sample_id == "00789939": + self.assertIn(("F14", "extrude_extent_face_selector"), { + (item.get("feature_id"), item.get("capability")) for item in result.diagnostics + }) 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") @@ -2165,13 +5272,13 @@ class LoweringTests(unittest.TestCase): 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"], "ValueError") - self.assertEqual(outcome["error"]["message"], "f_F5: selector_query_unsupported during incremental replay") - 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) + self.assertEqual(outcome["status"], "rebuilt") + self.assertEqual( + [item["feature_id"] for item in outcome["result"]["feature_results"]], + ["f_F1", "f_F2", "f_F4", "f_F5", "f_F7", "f_F9", "f_F11", "f_F13"], + ) + resolution = next(item for item in outcome["result"]["selector_resolution"] if item["feature_id"] == "f_F5") + self.assertEqual(resolution["resolution_mode"], "kernel_lineage") def test_pattern_copy_intersection_query_preserves_its_prefix_without_instance_geometry_binding(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -2179,6 +5286,13 @@ class LoweringTests(unittest.TestCase): if not feature.exists(): self.skipTest("CADFS sample is not installed") result = lower_model(parse_featurescript(feature.read_text(), "00423838"), {}) + f6_profile = next( + sketch for sketch in result.cdsl["geometry"]["sketches"] + if sketch["id"] == "sketch_F5__f_F6" + )["profile"] + # F6 unions the E4 disk with its adjacent annulus. The actual outer + # boundary is the untouched E5 source circle, not an inferred radius. + self.assertEqual(f6_profile["source_entity_id"], "E5") with tempfile.TemporaryDirectory() as directory: rebuilt = Path(directory) / "selector-prefix.step" outcome = rebuild_candidate(result.cdsl, rebuilt) @@ -2186,6 +5300,31 @@ class LoweringTests(unittest.TestCase): self.assertEqual(outcome["status"], "rebuild_failed") self.assertEqual(outcome["error"]["message"], "f_F7: selector_query_unsupported during incremental replay") self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F6") + cut_delta = next( + item for item in outcome["last_executable_prefix"]["result"]["topology_deltas"] + if item["feature_id"] == "f_F6" and item["operation"] == "subtract" + ) + self.assertEqual(cut_delta["operation"], "subtract") + self.assertEqual(cut_delta["history_status"], "proven") + self.assertEqual(cut_delta["history_reason"], "per_member_exact_cut_history") + # The F6 tool is retained only as direct-prism source evidence. Its + # transient records cannot be selected as active topology, and F7's + # three-face vertex INTERSECT remains unsupported below. + transient_tool_records = [ + item for item in outcome["last_executable_prefix"]["result"]["topology_records"] + if item["feature_id"] == "f_F6" and item.get("transient") + ] + self.assertTrue(transient_tool_records) + self.assertTrue(any( + item.get("body_id") == "transient:f_F6" for item in transient_tool_records + )) + self.assertTrue(all( + item.get("body_id") in {None, "transient:f_F6"} for item in transient_tool_records + )) + self.assertFalse(any( + item["feature_id"] == "f_F7" and item["status"] == "resolved" + for item in outcome["error"].get("selector_resolutions") or () + )) reference = next(item for item in result.cdsl["features"] if item["id"] == "f_F7")["params"]["end_condition"]["reference"] self.assertEqual(reference["selector_intent"]["query_family"], "INTERSECT") self.assertEqual( @@ -2258,6 +5397,22 @@ class LoweringTests(unittest.TestCase): self.assertEqual(selector["selector_intent"]["source_query"]["featurescript_version"], "1511") self.assertEqual(selector["selector_intent"]["source_query"]["standard_library"], "onshape/std/geometry.fs") self.assertEqual(selector["selector_intent"]["source_query"]["standard_library_version"], "1511.0") + self.assertEqual(selector["selector_intent"]["query_expr"], { + "version": "1.0", + "root": { + "node": "topology_query", + "owner": {"node": "opaque_call", "name": "__binary__", "arguments": [ + {"node": "literal", "value": "id"}, + {"node": "literal", "value": "+"}, + {"node": "literal", "value": "F1.opExtrude"}, + ]}, + "topology_type": {"node": "literal", "value": "CAP_FACE"}, + "entity_type": {"node": "literal", "value": "FACE"}, + "arguments": [{"node": "map", "entries": [{ + "key": "isStart", "value": {"node": "literal", "value": "false"}, + }]}], + }, + }) self.assertEqual(selector["selector_intent"]["evidence"], "operation_role") from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) @@ -2347,12 +5502,105 @@ class LoweringTests(unittest.TestCase): "query_family": "SWEPT_FACE", "owner_feature_id": "f_F3", "source_entity": {"sketch_id": "F2", "entity_id": "E1"}, }) + through_all = deepcopy(kwargs) + through_all["feature_by_id"] = { + **kwargs["feature_by_id"], + "f_F3": { + **tool, + "params": {"distance_mm": 10, "end_condition": {"type": "through_all"}}, + }, + } + self.assertIsNone(_direct_primary_cut_intersection_selector(query, **through_all)) deferred = Call("makeQuery", [ "F3.boolean.opBoolean", "INTERSECT", "EntityType.EDGE", {"derivedFrom": [cap, swept], "disambiguationData": [Call("OD", [0.0])]}, ]) self.assertIsNone(_direct_primary_cut_intersection_selector(deferred, **kwargs)) + def test_deferred_blend_edge_preserves_explicit_boolean_intersection_history(self): + """A later unsupported blend query must not discard the F12 section intent.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0078/00789417.txt" + if not feature.exists(): + self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), "00789417"), {}) + + self.assertEqual(result.status, "converted_partial") + f12 = next(item for item in result.cdsl["features"] if item["id"] == "f_F12") + section = f12["selectors"][0]["selector_intent"] + self.assertEqual(section["query_family"], "INTERSECT") + self.assertEqual(section["evidence"], "kernel_history") + self.assertEqual(section["disambiguation"], {"type": "source_qualified_boolean_section"}) + f13 = next(item for item in result.cdsl["features"] if item["id"] == "f_F13") + deferred = f13["selectors"][0]["selector_intent"] + self.assertEqual(deferred["query_family"], "BLEND_EDGE") + self.assertEqual(deferred["evidence"], "feature_script_query") + self.assertEqual(deferred["derivation_policy"]["multiplicity"], "none") + self.assertNotIn("blend_sources", deferred) + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + invalid = deepcopy(result.cdsl) + invalid_f13 = next(item for item in invalid["features"] if item["id"] == "f_F13") + invalid_f13["selectors"][0]["selector_intent"]["blend_sources"] = { + "edge": { + "query_family": "CAP_EDGE", + "owner_feature_id": "f_F3", + "source_entity": {"sketch_id": "F2", "entity_id": "E1"}, + "lineage_role": "extrude.end", + }, + "face": { + "query_family": "CAP_FACE", + "owner_feature_id": "f_F3", + "output_role": "extrude.end", + }, + } + with self.assertRaisesRegex(ValueError, "deferred BLEND_EDGE cannot declare transition sources"): + validate_semantic_cdsl(invalid) + + def test_explicit_boolean_intersect_resolves_from_the_unique_cap_builder_snapshot(self): + """A propagated CAP role must not shadow its original boolean input face.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0078/00789417.txt" + if not feature.exists(): + self.skipTest("CADFS sample is not installed") + + from cadfs_to_cdsl.selector_binding import _bind_feature_selectors + from engine.cdsl_engine.runtime import prepare_cdsl_execution + + candidate = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), "00789417"), {}).cdsl + execution = prepare_cdsl_execution(candidate) + self.assertTrue(execution.analysis.runtime_eligible) + body_ids = {"__current__": None} + + for node in execution.analysis.plan: + if node.feature_id == "f_F12": + break + _bind_feature_selectors( + node.source_feature, + registry=execution.session.topology, + body_id_for_feature=body_ids, + ) + execution.execute_next(strict=True) + body_ids[node.feature_id] = execution.session.body_id + body_ids["__current__"] = execution.session.body_id + + selector = next(feature for feature in candidate["features"] if feature["id"] == "f_F12")["selectors"][0] + resolution = execution.session.topology.resolve( + selector, + active_body_id=body_ids["f_F10"], + ) + self.assertEqual(resolution.status, "resolved") + self.assertEqual(resolution.resolution_mode, "kernel_intersection") + self.assertIsNotNone(resolution.record) + self.assertEqual( + len([item for item in resolution.evidence["relations"] if item["derivation"] == "intersection"]), + 1, + ) + inactive = execution.session.topology.resolve(selector, active_body_id=body_ids["__current__"]) + self.assertEqual(inactive.status, "not_found") + self.assertEqual(inactive.diagnostic.code, "selector_body_member_inactive") + def test_deferred_primary_intersect_keeps_the_convertible_f3_checkpoint(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0002/00020311.txt" @@ -2375,6 +5623,22 @@ class LoweringTests(unittest.TestCase): self.assertEqual(outcome["error"]["message"], "f_F4: selector_query_unsupported during incremental replay") self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F3") + def test_remove_sweep_lowers_to_a_cut_tool_without_selector_lineage(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0025/00259897.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00259897"), {}) + sweep = next(item for item in result.cdsl["features"] if item["id"] == "f_F7") + self.assertEqual(sweep["atomic_id"], "sweep_cut") + self.assertNotIn("result_mode", sweep["params"]) + self.assertNotIn("swept_face_contract", sweep["params"]) + self.assertNotIn("swept_edge_contract", sweep["params"]) + self.assertNotIn({ + "code": "unsupported_engine_capability", "capability": "sweep_remove", + "feature_id": "F7", "operation": "sweep", + "message": "current CDSL sweep supports additive solid results only", + }, result.diagnostics) + def test_conversion_writes_status_and_sidecars(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp); source = root / "00000173.txt"; source.write_text(SOURCE) @@ -2386,6 +5650,126 @@ class LoweringTests(unittest.TestCase): self.assertTrue((directory / name).exists(), name) self.assertEqual(json.loads((directory / "candidate.cdsl.json").read_text())["part_id"], "00000173") + def test_conversion_separates_semantic_validation_failure_from_parse_failure(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); source = root / "00000173.txt"; source.write_text(SOURCE) + sample = Sample("00000173", {"featurescript": str(source)}, {"featurescript": "x"}) + with patch( + "engine.cdsl_engine.semantic_validation.validate_semantic_cdsl", + side_effect=ValueError("semantic fixture rejection"), + ): + status = convert_one(sample, root / "out", force=True) + directory = root / "out/samples/00000173" + self.assertEqual(status["conversion_status"], "semantic_validation_failed") + self.assertEqual(status["semantic_validation"]["error"]["message"], "semantic fixture rejection") + self.assertFalse((directory / "candidate.cdsl.json").exists()) + self.assertTrue((directory / "candidate.invalid.cdsl.json").exists()) + self.assertTrue((directory / "history.json").exists()) + diagnostics = json.loads((directory / "diagnostics.json").read_text()) + self.assertIn({ + "code": "semantic_validation_failed", + "message": "semantic fixture rejection", + "type": "ValueError", + }, diagnostics) + + def test_semantic_failure_preserves_and_rebuilds_a_separate_valid_prefix(self): + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test/featurescript_rp/0096/00965724.txt" + if not source.exists(): self.skipTest("CADFS sample is not installed") + sample = Sample("00965724", {"featurescript": str(source)}, {"featurescript": "x"}) + validation_calls = 0 + + def reject_complete_then_validate(cdsl): + nonlocal validation_calls + validation_calls += 1 + if validation_calls == 1: + raise ValueError("semantic fixture rejection") + return validate_semantic_cdsl(cdsl) + + with patch( + "engine.cdsl_engine.semantic_validation.validate_semantic_cdsl", + side_effect=reject_complete_then_validate, + ): + status = convert_one(sample, root / "out", force=True) + directory = root / "out/samples/00965724" + self.assertEqual(status["conversion_status"], "semantic_validation_failed") + self.assertFalse((directory / "candidate.cdsl.json").exists()) + self.assertTrue((directory / "candidate.invalid.cdsl.json").exists()) + self.assertTrue((directory / "candidate.prefix.cdsl.json").exists()) + self.assertEqual(status["semantic_valid_prefix"]["feature_count"], 1) + + rebuilt = rebuild_one(sample, root / "out") + self.assertEqual(rebuilt["status"], "semantic_validation_failed") + self.assertEqual(rebuilt["prefix_rebuild_status"], "rebuilt") + self.assertTrue((directory / "prefix.rebuild.step").exists()) + + def test_direct_blind_cap_face_dressup_role_is_not_misclassified_as_imprint_00998718(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source = root / "featurescript_rp/0099/00998718.txt" + if not source.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(source.read_text(), "00998718"), {}) + cap = next(item for item in result.cdsl["features"] if item["id"] == "f_F8")["selectors"][1] + self.assertEqual(cap["owner_feature_id"], "f_F7") + self.assertEqual(cap["output_role"], "extrude.end") + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + + def test_query_set_cap_face_output_roles_inherit_the_dressup_selector_slot_00965724(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source = root / "featurescript_rp/0096/00965724.txt" + if not source.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(source.read_text(), "00965724"), {}) + chamfer = next(item for item in result.cdsl["features"] if item["id"] == "f_F2") + query_set = chamfer["selectors"][0] + self.assertEqual(query_set["selector_intent"]["query_family"], "QUERY_SET") + self.assertEqual( + [item["output_role"] for item in query_set["query_operands"]], + ["extrude.start", "extrude.end"], + ) + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + + invalid = deepcopy(result.cdsl) + invalid["features"][1]["selectors"][0]["query_operands"][1]["owner_feature_id"] = "f_missing" + with self.assertRaisesRegex(ValueError, "forward or missing owner_feature_id"): + validate_semantic_cdsl(invalid) + + def test_dressup_with_an_omitted_selector_owner_defers_before_semantic_validation_00508029(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source = root / "featurescript_rp/0050/00508029.txt" + if not source.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(source.read_text(), "00508029"), {}) + self.assertEqual(result.status, "converted_partial") + self.assertNotIn("f_F16", {item["id"] for item in result.cdsl["features"]}) + self.assertIn({ + "code": "unsupported_engine_capability", + "capability": "selector_owner_unavailable", + "feature_id": "F16", + "operation": "chamfer", + "message": "selector owner F14 has no executable CDSL producer", + }, result.diagnostics) + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + + def test_unproven_extent_face_is_deferred_instead_of_stable_id_bound_00002892(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + source = root / "featurescript_rp/0000/00002892.txt" + if not source.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(source.read_text(), "00002892"), {}) + self.assertNotIn("f_F3", {feature["id"] for feature in result.cdsl["features"]}) + self.assertIn({ + "code": "unsupported_engine_capability", + "capability": "extrude_extent_face_selector", + "feature_id": "F3", + "operation": "extrude", + "message": "current CDSL face extent requires a complete/proven active selector reference", + }, result.diagnostics) + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + def test_force_rebuild_refreshes_the_candidate_from_source(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp); source = root / "00000173.txt"; source.write_text(SOURCE) diff --git a/cadfs_to_cdsl/tests/test_parser.py b/cadfs_to_cdsl/tests/test_parser.py index ac0aabd2..2136792d 100644 --- a/cadfs_to_cdsl/tests/test_parser.py +++ b/cadfs_to_cdsl/tests/test_parser.py @@ -4,8 +4,8 @@ import unittest from cadfs_to_cdsl.featurescript_lexer import lex from cadfs_to_cdsl.featurescript_parser import parse_featurescript from cadfs_to_cdsl.ir import Call -from cadfs_to_cdsl.lowering import _queries, _source_refs -from cadfs_to_cdsl.query_parser import parse_query +from cadfs_to_cdsl.lowering import _number, _queries, _source_refs +from cadfs_to_cdsl.query_parser import parse_query, query_expr from cadfs_to_cdsl.units import length_mm @@ -28,6 +28,14 @@ TRANSFORM_SOURCE = SOURCE.replace( ''', ) +UNKNOWN_FEATURE_SOURCE = SOURCE.replace( + '\n});\n', + ''' + assignVariable(context, id + "F2", {"name" : "diameter", "value" : 10 * mm}); +}); +''', +) + class ParserTests(unittest.TestCase): def test_lexer_ignores_comments_and_preserves_lines(self): @@ -47,6 +55,10 @@ class ParserTests(unittest.TestCase): self.assertEqual(model.features[-1].operation, "transform") self.assertEqual(model.features[-1].params["transformType"], "TransformType.TRANSLATION_3D") + def test_unknown_direct_context_feature_is_preserved_for_capability_diagnostics(self): + model = parse_featurescript(UNKNOWN_FEATURE_SOURCE, "unknown-feature") + self.assertEqual([(feature.feature_id, feature.operation) for feature in model.features], [("F1", "extrude"), ("F2", "assignVariable")]) + def test_query_parser(self): query = Call("makeQuery", [Call("__binary__", ["id", "+", "F1.opExtrude"]), "CAP_EDGE", "EDGE", {"isStart": False, "x": Call("sQuery", [Call("__binary__", ["id", "+", "F0.wireOp"]), "EDGE", "E0"])}]) value = parse_query(query) @@ -55,6 +67,43 @@ class ParserTests(unittest.TestCase): self.assertEqual(value.ast["call"], "makeQuery") self.assertEqual(value.ast["args"][0]["call"], "__binary__") + def test_signed_parenthesized_scalar_preserves_unit_expression_semantics(self): + model = parse_featurescript(SOURCE.replace( + '"depth":120 * mm', '"depth":-(10 + 2) / 2 * mm', 1, + ), "signed-scalar") + depth = model.features[-1].params["depth"] + self.assertEqual((depth.name, depth.args[1]), ("__binary__", "*")) + self.assertEqual(_number(depth), -6.0) + + def test_query_expression_preserves_nested_set_and_filter_boundaries(self): + source = Call("qUnion", [[ + Call("qConstructionFilter", [ + Call("qBodyType", [ + Call("qCreatedBy", [Call("__binary__", ["id", "+", "F1"]), "EDGE"]), + "BodyType.WIRE", + ]), + "ConstructionObject.NO", + ]), + Call("qSubtraction", [ + Call("sQuery", [Call("__binary__", ["id", "+", "F0.wireOp"]), "EDGE", "E0"]), + Call("sQuery", [Call("__binary__", ["id", "+", "F0.wireOp"]), "EDGE", "E1"]), + ]), + ]]) + expression = query_expr(source) + self.assertEqual(expression["version"], "1.0") + self.assertEqual(expression["root"]["node"], "set") + self.assertEqual(expression["root"]["operator"], "union") + filtered, subtraction = expression["root"]["operands"] + self.assertEqual((filtered["node"], filtered["filter"]), ("filter", "construction")) + self.assertEqual((filtered["input"]["node"], filtered["input"]["filter"]), ("filter", "body_type")) + self.assertEqual((subtraction["node"], subtraction["operator"]), ("set", "subtraction")) + parsed = parse_query(source) + self.assertEqual(parsed.query_combinators, ["qUnion", "qSubtraction"]) + self.assertEqual( + parsed.filters, + ["qConstructionFilter", "qBodyType"], + ) + def test_source_version_and_standard_library_are_retained(self): source = '''FeatureScript 1511; import(path : "onshape/std/geometry.fs", version : "1511.0"); diff --git a/cadfs_to_cdsl/tests/test_reports.py b/cadfs_to_cdsl/tests/test_reports.py index f89b5a46..a5f17904 100644 --- a/cadfs_to_cdsl/tests/test_reports.py +++ b/cadfs_to_cdsl/tests/test_reports.py @@ -3,7 +3,9 @@ from __future__ import annotations import json, tempfile, unittest from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from cadfs_to_cdsl.reports import write_json +from cadfs_to_cdsl.dataset import Sample +from cadfs_to_cdsl.operation_registry import build_operation_registry +from cadfs_to_cdsl.reports import generate_markdown_report, write_json class ReportTests(unittest.TestCase): @@ -17,5 +19,36 @@ class ReportTests(unittest.TestCase): self.assertIn(payload["index"], range(64)) self.assertEqual(list(path.parent.glob(".summary.json.*.tmp")), []) + def test_operation_registry_distinguishes_absent_roadmap_work_items(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "00000001.txt" + source.write_text( + 'FeatureScript 1511; export const f = defineFeature(function(context, id, definition) {' + ' extrude(context, id + "F1", {"depth": 1 * mm}); });', + encoding="utf-8", + ) + registry = build_operation_registry([Sample("00000001", files={"featurescript": str(source)})]) + + self.assertEqual(registry["schema"], "cadfs_to_cdsl.operation_registry.v1") + self.assertEqual(registry["operations"]["extrude"]["feature_count"], 1) + self.assertEqual(registry["operations"]["transform"]["state"], "not_observed_in_current_source") + self.assertEqual(registry["operations"]["thicken"]["feature_count"], 0) + + def test_markdown_report_uses_source_registry_instead_of_static_operation_claims(self): + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) + write_json(output / "operation_registry.json", { + "operations": { + "transform": {"roadmap_work_item": True, "state": "observed"}, + "thicken": {"roadmap_work_item": True, "state": "not_observed_in_current_source"}, + }, + }) + report = generate_markdown_report(output, []) + text = report.read_text(encoding="utf-8") + + self.assertIn("thicken", text) + self.assertNotIn("draft, thicken, split", text) + if __name__ == "__main__": unittest.main() diff --git a/cadfs_to_cdsl/tests/test_selector_candidate_demo.py b/cadfs_to_cdsl/tests/test_selector_candidate_demo.py index 0cb18996..cdf73a45 100644 --- a/cadfs_to_cdsl/tests/test_selector_candidate_demo.py +++ b/cadfs_to_cdsl/tests/test_selector_candidate_demo.py @@ -6,7 +6,13 @@ from pathlib import Path from cadfs_to_cdsl.dataset import Sample from cadfs_to_cdsl.reports import read_json -from cadfs_to_cdsl.selector_candidate_demo import _search_candidate_records, run_geometry_probe, strip_provenance_intents +from cadfs_to_cdsl.selector_candidate_demo import ( + _query_candidate_groups, + _search_candidate_records, + _strip_selector_search_tokens, + run_geometry_probe, + strip_provenance_intents, +) def _contains_selector_intent(value: object) -> bool: @@ -18,6 +24,39 @@ def _contains_selector_intent(value: object) -> bool: class SelectorCandidateDemoTests(unittest.TestCase): + @staticmethod + def _query_group_candidate(query_family: str, source_entity: str | None = None, source_entities: list[str] | None = None) -> dict: + intent = { + "version": "1.0", + "kind": "edge" if query_family == "SWEPT_EDGE" else "face", + "query_family": query_family, + "source_query": {"ast": {"call": "makeQuery", "args": ["owner", query_family]}}, + "derivation_policy": {"allowed": ["boundary"], "multiplicity": "all_fragments" if query_family == "SWEPT_FACE" else "one"}, + } + if source_entity is not None: + intent["source_entity"] = {"sketch_id": "S", "entity_id": source_entity} + if source_entities is not None: + intent["source_entities"] = [{"sketch_id": "S", "entity_id": entity_id} for entity_id in source_entities] + return {"kind": intent["kind"], "owner_feature_id": "f_extrude", "selector_intent": intent} + + def _query_group_provenance(self, selector: dict) -> dict: + return { + "geometry": { + "sketches": [{ + "id": "sketch_S", "source_sketch_id": "S", + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "planar_imprint", "source_entities": [ + {"id": "E1", "curve": {"type": "line", "start": [0, 0], "end": [3, 0]}}, + {"id": "E2", "curve": {"type": "line", "start": [3, 0], "end": [3, 2]}}, + ]}, + }], + }, + "features": [ + {"id": "f_extrude", "atomic_id": "extrude_add_blind", "sketch_id": "sketch_S", "params": {"distance_mm": 5}}, + {"id": "f_consumer", "selectors": [selector]}, + ], + } + def test_search_candidates_excludes_records_below_normal_resolution_threshold(self) -> None: resolution = type("Resolution", (), { "candidates": ( @@ -34,11 +73,85 @@ class SelectorCandidateDemoTests(unittest.TestCase): def test_strip_provenance_intents_removes_nested_selector_metadata(self) -> None: value = { "selector_intent": {"version": "1.0"}, + "output_role": "extrude.end", + "output_role_source": {"owner_feature_id": "f0", "output_role": "extrude.end"}, "params": {"reference": {"selector_intent_version": "1.0"}}, } self.assertEqual(strip_provenance_intents(value), 2) self.assertEqual(value, {"params": {"reference": {}}}) + def test_query_group_keeps_all_ranked_swept_face_fragments_together(self) -> None: + selector = self._query_group_candidate("SWEPT_FACE", source_entity="E1") + provenance = self._query_group_provenance(selector) + resolution = type("Resolution", (), {"candidates": ( + {"record_id": "fragment-a", "score": 1.0, "geometry": {"surface_type": "plane", "plane_normal": [0, -1, 0], "plane_offset_mm": 0}}, + {"record_id": "fragment-b", "score": 1.0, "geometry": {"surface_type": "plane", "plane_normal": [0, 1, 0], "plane_offset_mm": 0}}, + {"record_id": "wrong-wall", "score": 1.0, "geometry": {"surface_type": "plane", "plane_normal": [1, 0, 0], "plane_offset_mm": 3}}, + )})() + descriptor, groups, count = _query_candidate_groups( + provenance, "f_consumer:selectors[0]", selector, resolution, maximum=8, + ) + self.assertEqual(count, 1) + self.assertEqual(descriptor["ranking_status"], "source_geometry_ranked") + self.assertEqual(descriptor["multiplicity"], "all_fragments") + self.assertEqual([record["record_id"] for record in groups[0]["records"]], ["fragment-a", "fragment-b"]) + self.assertEqual(groups[0]["ranking"]["method"], "all_fragments_source_geometry") + + def test_query_group_ranks_the_source_pair_vertical_edge(self) -> None: + selector = self._query_group_candidate("SWEPT_EDGE", source_entities=["E1", "E2"]) + provenance = self._query_group_provenance(selector) + resolution = type("Resolution", (), {"candidates": ( + {"record_id": "source-vertex", "score": 1.0, "geometry": {"curve_type": "line", "start_mm": [3, 0, 0], "end_mm": [3, 0, 5]}}, + {"record_id": "other-vertex", "score": 1.0, "geometry": {"curve_type": "line", "start_mm": [0, 0, 0], "end_mm": [0, 0, 5]}}, + )})() + descriptor, groups, count = _query_candidate_groups( + provenance, "f_consumer:selectors[0]", selector, resolution, maximum=8, + ) + self.assertEqual(count, 1) + self.assertEqual(descriptor["query_family"], "SWEPT_EDGE") + self.assertEqual(groups[0]["records"][0]["record_id"], "source-vertex") + self.assertEqual(groups[0]["ranking"]["method"], "source_pair_extrusion_vertex") + + def test_query_group_ranks_the_line_arc_intersection_edge(self) -> None: + selector = self._query_group_candidate("SWEPT_EDGE", source_entities=["E1", "E2"]) + provenance = self._query_group_provenance(selector) + provenance["geometry"]["sketches"][0]["profile"]["source_entities"][0]["curve"] = { + "type": "arc", "start": [-1, 0], "end": [1, 0], "center": [0, 0], "radius_mm": 1, "clockwise": True, + } + provenance["geometry"]["sketches"][0]["profile"]["source_entities"][1]["curve"] = { + "type": "line", "start": [0, -2], "end": [0, 2], + } + resolution = type("Resolution", (), {"candidates": ( + {"record_id": "arc-line-intersection", "score": 1.0, "geometry": {"curve_type": "line", "start_mm": [0, 1, 0], "end_mm": [0, 1, 5]}}, + {"record_id": "unrelated-vertical", "score": 1.0, "geometry": {"curve_type": "line", "start_mm": [0, -1, 0], "end_mm": [0, -1, 5]}}, + )})() + _descriptor, groups, count = _query_candidate_groups( + provenance, "f_consumer:selectors[0]", selector, resolution, maximum=8, + ) + self.assertEqual(count, 1) + self.assertEqual(groups[0]["records"][0]["record_id"], "arc-line-intersection") + + def test_query_group_ignores_a_candidate_missing_cylinder_axis_evidence(self) -> None: + selector = self._query_group_candidate("SWEPT_FACE", source_entity="E1") + provenance = self._query_group_provenance(selector) + provenance["geometry"]["sketches"][0]["profile"]["source_entities"][0]["curve"] = { + "type": "arc", "start": [-1, 0], "end": [1, 0], "center": [0, 0], "radius_mm": 1, "clockwise": True, + } + resolution = type("Resolution", (), {"candidates": ( + {"record_id": "missing-axis", "score": 1.0, "geometry": {"surface_type": "cylinder", "radius_mm": 1}}, + )})() + descriptor, groups, count = _query_candidate_groups( + provenance, "f_consumer:selectors[0]", selector, resolution, maximum=8, + ) + self.assertEqual(count, 1) + self.assertEqual(descriptor["ranking_status"], "source_geometry_unavailable") + self.assertEqual(groups[0]["records"][0]["record_id"], "missing-axis") + + def test_experiment_search_tokens_are_not_persisted(self) -> None: + value = {"selectors": [{"_selector_search_token": "f:selectors[0]"}]} + self.assertEqual(_strip_selector_search_tokens(value), 1) + self.assertEqual(value, {"selectors": [{}]}) + def test_geometry_probe_keeps_heuristic_success_separate_from_provenance(self) -> None: root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0000/00002243.txt"