diff --git a/backend/agent/skills/cad-authoring/SKILL.md b/backend/agent/skills/cad-authoring/SKILL.md index 5644d30b..b777186d 100644 --- a/backend/agent/skills/cad-authoring/SKILL.md +++ b/backend/agent/skills/cad-authoring/SKILL.md @@ -13,24 +13,30 @@ snapshot, owner identifier, selector token, or `host_face`/`mirror_plane` inside `params`. The server allocates identities and injects selector values into the destination stated by the operation contract. +Literal request language such as `max_z`, `min_z`, `host face`, or a face name +is design intent, never a CDSL field or output role. Never emit `host_face`, +`max_z`, `min_z`, `max_z_face`, `min_z_face`, `top_face`, or `bottom_face` in +any JSON value, including `selector.source`. A selector is only `{kind, +source, match}`, and `source` must use an exact role advertised by the producer +operation. + +Treat `requirements`, prior documents, and diagnostics as semantic input, not +CDSL templates. Their keys are descriptive labels. Copy geometry, values, and +relations only into fields allowed by the active schema and operation contract. +Thus `host_face: "max_z"` conveys placement intent, never output strings; +derive a selector from advertised roles or use explicit coordinates. + Use millimetres and a right-handed coordinate system unless the request says otherwise. Put every reasonable but unstated design choice in `assumptions`. Do not turn an assumed dimension into a deterministic acceptance target. ## Modeling Brief -Before authoring, derive this internal brief from the requirements: - -- part or multi-body intent; explicit dimensions and units; -- functional datums, origin, base plane, and positive directions; -- primary volumes, holes, pockets, bosses, ribs, patterns, and finishing; -- explicitly verifiable targets versus manual targets; -- assumptions that do not affect fit, safety, or compliance. - -Dimensioned request facts take precedence over proportions inferred from an -image. Ask for clarification only when a missing interface, scale, safety, or -compliance value makes construction impossible. Otherwise choose a practical -engineering default and record it as an assumption. +Identify explicit dimensions, datums, directions, primary volumes, removals, +repetitions, finish features, targets, and noncritical assumptions. Explicit +dimensions take precedence over inferred proportions. Clarify only when an +omitted interface, scale, safety, or compliance value prevents construction; +otherwise record a practical default in `assumptions`. ## Construction Order @@ -52,16 +58,74 @@ For through cuts, choose the operation's through extent and make the tool cross the material; never rely on coincident faces or a guessed nearby face. Delay dress-up operations because they can alter downstream topology. -The selector-source rule is an intentional exception to a generic “all adds, -then all cuts” sequence. For example, a bolt circle hosted on an original -flange cap must be placed immediately after the base when later additive -fusions replace that exact cap. A required final through-cut can still follow -all additive features and the earlier bolt operation. +## Operation Semantics -Use only the supplied operation list. Follow each operation's parameter schema -exactly, including required sketch state. Do not invent unsupported operation -parameters, implicit booleans, or a substitute operation after a capability -error. +Apply the following rules from the supplied operation contracts. They describe +the CDSL language itself and do not prescribe a particular part design. + +### Hard Emission Boundaries + +Do not emit `boolean_bodies` in `cad.author.v1`. Its body operand lifecycle is +not expressed by document-local names, so it cannot provide a reliable +model-facing construction path. Express additions with an additive operation +whose declared result mode supports the intended connected result, and express +removals with a cut operation and a sketch. + +Prefer self-contained operations. An unavailable selector makes a hosted +operation unavailable even if requested; use a selector-free construction that +preserves geometry and record the method limitation. Every selector must pass +the exact-role and active-source checks. + +- A primitive axis starts at `origin_mm` and grows in the supplied positive + `direction`. Heights and distances are positive magnitudes. A direction of + `[0,0,-1]` therefore models travel toward `-Z` without a negative height. +- A sketch has exactly one `profile`. A circle is one closed contour and a + polygon is one simple, non-self-intersecting closed loop. Never concatenate + disconnected circles or multiple polygon loops into one profile, and never + repeat a loop's start point to begin another loop. +- Model repeats as one valid seed plus `pattern_circular` or `pattern_linear`; + `source_feature_ids` contains local names and the count includes the seed. + If a repeated removal has no live host selector, use a selector-free cut seed + plus circular `operation_mode: "remove"`. Otherwise use one valid feature + per unrelated instance with its true dependency. +- Treat every operation schema as independent. Do not copy a parameter name, + enum value, or field shape from a similar operation into another operation; + emit only fields present in that feature's supplied `params_schema`. +- To extend an already connected solid, use an additive operation with the + explicit `result_mode: "fuse"` when its contract provides that mode. The new + profile must intersect the prior solid, and the feature depends on the prior + feature. Do not turn touching or overlapping primitive additions into + independent bodies followed by a boolean union. +- A primitive operation whose authoring schema has no `result_mode: "fuse"` + must be treated as a body-producing operation, not as a guaranteed extension + of an existing connected result. Use such an operation as an initial or + explicitly independent construction only. When the requested result must be + one connected body, continue it through an additive operation that explicitly + supports fusion rather than joining primitive outputs later with a boolean. +- Use a through-cut operation with a profile that intersects the material for + a through removal. Build all intended material before a removal that must + cross it. Do not make a temporary tool body and call `boolean_bodies` for a + removal expressible as a cut operation. +- Do not introduce an auxiliary feature solely to construct or delete a tool + for an operation that the contract already represents directly. A deletion + is a state-changing operation, never a no-op cleanup step, and downstream + dependencies must not rely on deleted geometry. +- Use a selector-hosted operation only when its required source output role is + available and the target geometry unambiguously lies on that output. Supply + every required selector and parameter from the operation contract; otherwise + choose a self-contained operation whose coordinates express the intent. +- A face-hosted extrusion reuses its host boundary. For any different boundary, + use a sketch-profile operation at an explicit workplane. +- Selector provenance is live. Do not select a source changed by an intervening + fuse, cut, shell, pattern, or finish. A through-cut has no cap role for a + later host; schedule host-dependent features before it or use coordinates. + +For `max_z`/`min_z` placement intent, use an owner role such as +`source_feature.top_planar_face` or `source_feature.bottom_planar_face`, never +`max_z_face` or `min_z_face`; prefer explicit coordinates when available. + +Use only supplied operations and their exact parameter schemas. Do not invent +parameters, implicit booleans, or substitutes after a capability error. ## Sketches And Coordinates @@ -95,16 +159,15 @@ other key inside an Authoring sketch. The compiler derives Runtime radius and sketch identity. Use a primitive such as `cylinder_add` when its axis, radius, and height directly express the requested geometry and no sketch is needed. -Name features for their manufacturing role, for example `base_plate`, -`front_hub_boss`, `center_bore`, and `bolt_holes`. Names make dependencies and +Name features for their geometric role, for example `primary_add`, +`secondary_add`, `through_cut`, and `repeated_cut`. Names make dependencies and repair diagnostics readable; they are not server identities. ## Selectors -The operation metadata states whether `selectors` are required, their kind, -cardinality, and server-side destination. Put only declarative selectors in a -feature's `selectors` array. Never write the destination field itself inside -`params`. +Operation metadata defines selector need, kind, cardinality, and server-side +destination. Put only declarative selectors in `selectors`, never the +destination field in `params`. For an output face, use an exact local role embedded in `source` and state a unique match: @@ -112,54 +175,59 @@ unique match: ```json { "kind": "face", - "source": "front_hub_boss.top_planar_face", + "source": "source_feature.top_planar_face", "match": "unique" } ``` -Do not add `role`, `query`, `host_face`, a face index, a coordinate selector, -or a Runtime selector token. The `source` value is the complete local intent. -The compiler adds its source feature as an auditable graph dependency; include -other true construction dependencies in `depends_on` yourself. +Do not add `role`, `query`, `host_face`, a face index, coordinate selector, or +Runtime token. The compiler adds the selector source dependency; declare other +true construction dependencies yourself. -`top_planar_face` and `end_face` mean the positive-direction cap of a supported -extrude, sweep, loft, or cylinder. `bottom_planar_face` and `start_face` mean -the opposite cap. Select the most recent feature whose output is known to be -the required host; do not select a similar face by location, face index, or -proximity. +`top_planar_face`/`end_face` mean a positive-direction cap; +`bottom_planar_face`/`start_face` mean the opposite cap. Select an exact known +host, never a similar face by location, index, or proximity. -Translate a request's descriptive `max_z`/`min_z` wording into these output -roles before writing CDSL. Never emit `base.max_z_face` or `base.min_z_face`. -For a cylinder built in `+Z`, the top cap is `top_planar_face` and the bottom -cap is `bottom_planar_face`; reverse-direction features swap their world-Z -position but retain their own start/end roles. +For a cap, `source` MUST match +`^[a-z][a-z0-9_]{0,63}\.(top_planar_face|bottom_planar_face|end_face|start_face)$`. +No other role spelling exists in Authoring CDSL. If the producer does not +advertise one of these roles, it cannot be the selector source. -`hole_wizard` requires exactly one `face` selector. It uses that selector as -its host face, so a central bore on a hub should select the hub cap, while a -bolt circle on an exposed flange should select the flange cap. A selector must -name a host that contains every requested hole position. If that cannot be -made unique, redesign the feature sequence or omit the unsupported feature; -never guess a face. +Translate descriptive maximum/minimum placement to an appropriate cap role; +never copy it into `source`. A `+Z` cylinder uses `top_planar_face` for its +top cap and `bottom_planar_face` for its bottom cap. -Before creating a hosted hole, calculate each position against the actual host -face. A local boss can be the global highest face while being too small to host -a larger bolt circle. In that case use the exposed flange cap at the bolt -radius, with its own plane height, rather than the global maximum-Z cap. Place -the hosted holes while that source cap's exact provenance is still active: -before a later fusion or cut would split, remove, or replace it. A final -through-cut may still follow all additive features, so a bolt circle can be -hosted before an unrelated final boss and before that final cut. +`hole_wizard` requires one `face` selector. It must be unique, planar, active, +and contain every position. Execute it before its source is replaced; otherwise +use a supported self-contained construction or report the limitation. ## Acceptance And Repair -Describe only user-requested, measurable acceptance targets in -`acceptance_targets`; leave inferred dimensions in `assumptions`. On repair, -return a complete replacement document. Preserve feature names and every -feature listed in `executed_feature_ids`, except a feature explicitly named by -the diagnostic. Features that were never executed may be changed freely to -repair an invalid selector, parameter, dependency, or geometry construction. +Put only user-requested measurable targets in `acceptance_targets`; leave +inferred dimensions in `assumptions`. On repair return a complete document, +preserve executed features unless diagnosed, and change unexecuted features +only as needed. Read diagnostics literally; do not add IDs, weaken requested +values, silently delete a failed feature, or guess a selector. -Read structured diagnostics literally. Fix their named cause with the smallest -document change, then return the entire document. Do not add internal IDs, -weaken a requested value, silently delete a failed feature, or replace a -failed selector with an arbitrary topology element. +## Pre-Emission Check + +Before returning the document, check every feature against these invariants: + +1. The operation is in the supplied whitelist, its params use only the + supplied closed schema, and its sketch/selector presence matches the + operation contract. The document contains no `boolean_bodies` feature. +2. Each reference is a local name, each dependency is necessary, and no + operation relies on a deleted, transient, or merely assumed body. +3. Each sketch contains one valid profile and a complete right-handed + workplane; each dimension is positive where the contract requires a + magnitude. +4. Each selector source has the exact `.` form. Its role + is one advertised by that producer operation, not a descriptive synonym, + coordinate extreme, or inferred face name. For a cap, verify the required + four-role source regex character-for-character. +5. Every requested removal intersects its intended material, every repeated + feature has a valid seed reference, and every connected addition uses an + operation whose contract explicitly supports the chosen result mode. + +If any invariant is false, revise the construction before emitting the single +complete `cad.author.v1` document. diff --git a/backend/app/cad_agent/application/workflow.py b/backend/app/cad_agent/application/workflow.py index b081fc59..f59f5068 100644 --- a/backend/app/cad_agent/application/workflow.py +++ b/backend/app/cad_agent/application/workflow.py @@ -172,7 +172,16 @@ class WorkflowCoordinator: for atomic_id in self.runtime.supported_atomic_ids() } repair_instruction = "" if state.repair_count == 0 else "Return a complete replacement document. Preserve only features confirmed in executed_feature_ids unless the diagnostic identifies that feature. Features that did not execute may be corrected. Keep local names unless the diagnostic identifies a name conflict. Never add IDs, stable selectors, snapshots, or tokens." - content = json.dumps({"requirements": requirements, "supported_operations": operation_schemas, "previous_authoring": previous, "diagnostics": diagnostics}, ensure_ascii=False) + content = json.dumps({ + "requirements": self._authoring_requirements_context(requirements), + "supported_operations": operation_schemas, + "authoring_selector_contract": { + "face_cap_source_pattern": r"^[a-z][a-z0-9_]{0,63}\.(top_planar_face|bottom_planar_face|end_face|start_face)$", + "face_cap_roles": ["top_planar_face", "bottom_planar_face", "end_face", "start_face"], + }, + "previous_authoring": previous, + "diagnostics": diagnostics, + }, ensure_ascii=False) try: raw = await self._tool_call( task_id, author, "write_authoring_cdsl", AuthoringDocument.model_json_schema(), @@ -343,6 +352,20 @@ class WorkflowCoordinator: }, } + @staticmethod + def _authoring_requirements_context(requirements: dict[str, Any]) -> dict[str, list[str]]: + """Pass requirement meaning to the author without leaking analysis field names. + + ``acceptance_targets.expected`` is intentionally an open-ended + reporting record. It may contain descriptive keys from a user request, + while Authoring CDSL has a closed protocol. Passing it through verbatim + invites a model to treat analysis labels as output fields. + """ + return { + key: [item for item in requirements.get(key, []) if isinstance(item, str)] + for key in ("explicit_requirements", "assumptions", "manual_targets") + } + @staticmethod def _validate_repair_document( previous: dict[str, Any], replacement: dict[str, Any], diagnostics: dict[str, Any] | None, diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 7d3d8086..7577a913 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -9,17 +9,18 @@ from build123d import AngularDirection, Axis, Compound, Edge, Face, Location, Pl from OCP.BOPAlgo import BOPAlgo_Splitter from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse from OCP.BRep import BRep_Tool +from OCP.BRepAdaptor import BRepAdaptor_Curve from OCP.BRepExtrema import BRepExtrema_DistShapeShape from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet from OCP.BRepOffset import BRepOffset_Skin from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections -from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform +from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakeWire, BRepBuilderAPI_Transform from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol from OCP.Geom import Geom_SurfaceOfRevolution from OCP.GeomAbs import GeomAbs_Arc from OCP.LocOpe import LocOpe_DPrism from OCP.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle -from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL +from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_SHELL from OCP.TopExp import TopExp_Explorer from OCP.TopTools import TopTools_ListOfShape from OCP.TopoDS import TopoDS @@ -30,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, Vector3, + TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologySectionRelation, Vector3, canonical_plane_signature, ) from .topology_export import ( @@ -45,6 +46,74 @@ def _vector(value: list[float] | tuple[float, float, float]) -> Vector: return Vector(float(value[0]), float(value[1]), float(value[2])) +def make_interpolated_bspline_edge( + points: list[Vector3], + *, + start_tangent: Vector3 | None = None, + end_tangent: Vector3 | None = None, + periodic: bool = False, + parameters: list[float] | None = None, +) -> Edge: + """Build the exact non-scaling interpolator used by CDSL sketch edges. + + FeatureScript ``skFitSpline`` lowering retains its centripetal parameters + and endpoint derivatives. Keep this construction in one kernel helper so + geometry execution and consumers that need a source-curve differential do + not accidentally use a chord or a separately parameterized interpolator. + Callers remain responsible for their own CDSL contract validation. + """ + if (start_tangent is None) != (end_tangent is None): + raise ValueError("interpolated B-spline requires both endpoint tangents") + return Edge.make_spline( + [_vector(point) for point in points], + tangents=( + [_vector(start_tangent), _vector(end_tangent)] + if start_tangent is not None else None + ), + periodic=periodic, + parameters=parameters, + scale=False, + ) + + +def interpolated_bspline_point_and_tangent( + points: list[Vector3], + *, + start_tangent: Vector3, + end_tangent: Vector3, + parameters: list[float], + interpolation_index: int, +) -> tuple[Vector3, Vector3]: + """Evaluate a CDSL interpolation point with the runtime's OCC curve. + + ``Edge.tangent_at`` accepts normalized edge positions, which need not be + the explicit interpolation parameters. Use ``BRepAdaptor_Curve.D1`` at + the source parameter and return the actual point as well, allowing the + caller to prove that OCC still interpolated the named source vertex. + """ + if interpolation_index < 0 or interpolation_index >= len(points): + raise ValueError("B-spline interpolation index is out of range") + if len(parameters) != len(points): + raise ValueError("B-spline interpolation parameters must match points") + if not all(math.isfinite(float(value)) for value in parameters): + raise ValueError("B-spline interpolation parameters must be finite") + if any(float(right) <= float(left) for left, right in zip(parameters, parameters[1:])): + raise ValueError("B-spline interpolation parameters must be strictly increasing") + edge = make_interpolated_bspline_edge( + points, + start_tangent=start_tangent, + end_tangent=end_tangent, + parameters=parameters, + ) + point = gp_Pnt() + tangent = gp_Vec() + BRepAdaptor_Curve(edge.wrapped).D1(float(parameters[interpolation_index]), point, tangent) + return ( + (float(point.X()), float(point.Y()), float(point.Z())), + (float(tangent.X()), float(tangent.Y()), float(tangent.Z())), + ) + + def _arc_midpoint(edge: dict[str, Any], start: Vector, end: Vector, center: Vector) -> Vector: # 计算圆弧中点(配合 Edge.make_three_point_arc 三点画弧),支持显式法向与顺时针/逆时针方向。 # 1. 半径:优先取 edge.radius_mm,缺省时由圆心到起点的距离推算。 @@ -96,7 +165,7 @@ class Build123dGeometryAdapter: return Axis(origin=_vector(spec.origin_mm), direction=_vector(spec.direction)) @staticmethod - def _wire(edges: list[dict[str, Any]]) -> Wire: + def _wire_edges(edges: list[dict[str, Any]]) -> list[Edge]: # 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。 built: list[Edge] = [] for edge in edges: @@ -137,12 +206,12 @@ class Build123dGeometryAdapter: raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing") else: parameter_values = [float(value) for value in parameters] if parameters is not None else None - built.append(Edge.make_spline( - points, - tangents=[_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None, + built.append(make_interpolated_bspline_edge( + [(point.X, point.Y, point.Z) for point in points], + start_tangent=start_tangent, + end_tangent=end_tangent, periodic=bool(edge.get("periodic")), parameters=parameter_values, - scale=False, )) continue if edge.get("type") == "ellipse": @@ -163,7 +232,203 @@ class Build123dGeometryAdapter: else: # 直线边:直接连接首尾。 built.append(Edge.make_line(start, end)) - return Wire(built) + return built + + @staticmethod + def _wire(edges: list[dict[str, Any]]) -> Wire: + return Wire(Build123dGeometryAdapter._wire_edges(edges)) + + @staticmethod + def _wire_with_source_edges(edges: list[dict[str, Any]]) -> tuple[Wire, list[tuple[str, Edge]]]: + """Construct one wire and retain only explicit one-to-one source edges.""" + built = Build123dGeometryAdapter._wire_edges(edges) + return Wire(built), [ + (str(edge["source_entity_id"]), built[index]) + for index, edge in enumerate(edges) + if isinstance(edge.get("source_entity_id"), str) and edge["source_entity_id"] + ] + + @staticmethod + def _direct_wire_with_source_edges(edges: list[dict[str, Any]]) -> tuple[Any, list[tuple[str, Edge]]]: + """Build one direct wire and retain the builder's exact source edges. + + ``Wire(built)`` may repair shared vertices by replacing individual + edges. ``BRepBuilderAPI_MakeWire.Edge()`` exposes each repaired edge at + insertion time. The caller must still prove those handles are members + of the final face before registering a source anchor. + """ + wire_builder = BRepBuilderAPI_MakeWire() + source_edges: list[tuple[str, Edge]] = [] + for definition, edge in zip(edges, Build123dGeometryAdapter._wire_edges(edges)): + wire_builder.Add(edge.wrapped) + if not wire_builder.IsDone(): + raise ValueError("analytic contour wire construction failed") + source_entity_id = definition.get("source_entity_id") + if isinstance(source_entity_id, str) and source_entity_id: + source_edges.append((source_entity_id, Edge.cast(wire_builder.Edge()))) + return wire_builder.Wire(), source_edges + + def _face_from_direct_wires( + self, + outer: list[dict[str, Any]], + holes: Iterable[list[dict[str, Any]]] = (), + *, + logical_circle_sources: dict[str, dict[str, Any]] | None = None, + plane_spec: PlaneSpec | None = None, + ) -> tuple[Face, list[tuple[str, Edge]]]: + """Build one direct profile face with exact outer and hole wire handles. + + Constructing a face and then calling ``Face.make_holes`` can replace + linear outer-wire subshapes. A direct ``BRepBuilderAPI_MakeFace`` + instead gives the face builder every source wire, so a later ``IsSame`` + membership check can establish provenance without geometry matching. + """ + outer_wire, source_edges = self._direct_wire_with_source_edges(outer) + face_builder = BRepBuilderAPI_MakeFace(outer_wire, True) + for hole in holes: + hole_wire, hole_source_edges = self._direct_hole_wire_with_source_edges( + hole, + logical_circle_sources=logical_circle_sources, + plane_spec=plane_spec, + ) + # OCC requires an inner wire to have the opposite orientation to + # its outer boundary. ``Wire.Reversed`` returns a generic shape, + # so cast it back to TopoDS_Wire for the face-builder API. + face_builder.Add(TopoDS.Wire_s(hole_wire.Reversed())) + source_edges.extend(hole_source_edges) + if not face_builder.IsDone(): + raise ValueError("analytic contour face construction failed") + return Face.cast(face_builder.Face()), source_edges + + def _face_from_direct_wire(self, edges: list[dict[str, Any]]) -> tuple[Face, list[tuple[str, Edge]]]: + """Build a direct face with no inner wires for existing callers.""" + return self._face_from_direct_wires(edges) + + def _direct_hole_wire_with_source_edges( + self, + edges: list[dict[str, Any]], + *, + logical_circle_sources: dict[str, dict[str, Any]] | None, + plane_spec: PlaneSpec | None, + ) -> tuple[Any, list[tuple[str, Edge]]]: + """Restore one solver-split source circle only from explicit provenance. + + ``analytic_contours`` decomposes a circle into four arcs to classify + regions. Those arcs are not source edges. The solver records one + logical-circle source marker on every generated arc, so a complete + marked loop may be rebuilt as one native circle wire. Any missing, + mixed, or unregistered marker uses the ordinary direct wire path and + therefore retains no invented source edge. + """ + logical_ids = { + edge.get("logical_circle_source_entity_id") + for edge in edges + if isinstance(edge.get("logical_circle_source_entity_id"), str) + and edge["logical_circle_source_entity_id"] + } + if ( + len(edges) == 4 + and len(logical_ids) == 1 + and all(edge.get("logical_circle_source_entity_id") in logical_ids for edge in edges) + and logical_circle_sources is not None + and plane_spec is not None + ): + source_entity_id = next(iter(logical_ids)) + source = logical_circle_sources.get(source_entity_id) + center = source.get("center") if isinstance(source, dict) else None + radius = source.get("radius_mm") if isinstance(source, dict) else None + if ( + isinstance(center, list) + and len(center) >= 2 + and isinstance(radius, (int, float)) + and math.isfinite(float(radius)) + and float(radius) > 0 + ): + wire = self._circle_wire([float(center[0]), float(center[1])], float(radius), plane_spec) + return wire.wrapped, [(source_entity_id, edge) for edge in wire.edges()] + return self._direct_wire_with_source_edges(edges) + + @staticmethod + def _logical_circle_sources(profile: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Index uniquely named, unsplit circle source entities from one profile.""" + sources: dict[str, dict[str, Any]] = {} + duplicates: set[str] = set() + for contour in profile.get("contours") or []: + if not isinstance(contour, dict): + continue + for segment in contour.get("segments") or []: + if not isinstance(segment, dict) or segment.get("type") != "circle": + continue + source_entity_id = segment.get("source_entity_id") + if not isinstance(source_entity_id, str) or not source_entity_id: + continue + if source_entity_id in sources: + duplicates.add(source_entity_id) + else: + sources[source_entity_id] = segment + for source_entity_id in duplicates: + sources.pop(source_entity_id, None) + return sources + + @staticmethod + def _face_source_anchor_specs( + face: Face, + source_edges: list[tuple[str, Edge]], + source_sketch_id: str | None, + ) -> list[dict[str, Any]]: + """Bind direct source labels to the exact face boundary subshapes. + + The caller retains the wire edges created for this profile. A source + label is emitted only when it has one exact final face edge; there is + no geometry-based reconstruction when splitting, trimming, or wire + construction changes that one-to-one relationship. + """ + if not isinstance(source_sketch_id, str) or not source_sketch_id: + return [] + actual_edges = list(face.edges()) + mapped: list[tuple[str, Edge]] = [] + source_counts: dict[str, int] = {} + for source_entity_id, _edge in source_edges: + source_counts[source_entity_id] = source_counts.get(source_entity_id, 0) + 1 + for source_entity_id, source_edge in source_edges: + if source_counts[source_entity_id] != 1: + continue + matches = [ + edge for edge in actual_edges + if edge.wrapped.IsSame(source_edge.wrapped) + ] + if len(matches) == 1: + mapped.append((source_entity_id, matches[0])) + 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 _circle_wire(self, center: list[float], radius: float, plane_spec: PlaneSpec) -> Wire: # 在草图工作平面上,按局部二维圆心与半径生成整圆 Wire(圆心由工作平面原点 + x/y 方向线性组合得到)。 @@ -211,6 +476,59 @@ class Build123dGeometryAdapter: faces.append(face.make_holes(holes) if holes else face) return faces + def _faces_from_circles_with_source_anchors( + self, + entities: list[dict[str, Any]], + plane_spec: PlaneSpec, + source_sketch_id: str | None, + ) -> tuple[list[Face], list[dict[str, Any]]]: + """Build direct circular profile faces while retaining their wire edges.""" + circles = [item for item in entities if item.get("type") == "circle" and not item.get("construction")] + if not circles: + return [], [] + entries = [] + for item in circles: + radius = float(item.get("radius_mm") or 0) + if radius <= 0: + continue + center = [float(value) for value in item.get("center") or [0, 0]] + entries.append({ + "center": center, + "radius": radius, + "wire": self._circle_wire(center, radius, plane_spec), + "source_entity_id": item.get("source_entity_id"), + }) + faces: list[Face] = [] + anchors: list[dict[str, Any]] = [] + for entry in entries: + containing = sum( + math.dist(entry["center"], other["center"]) + entry["radius"] < other["radius"] - 1e-8 + for other in entries + if other is not entry + ) + if containing % 2: + continue + holes = [ + other for other in entries + if math.dist(entry["center"], other["center"]) + other["radius"] < entry["radius"] - 1e-8 + and sum( + math.dist(other["center"], candidate["center"]) + other["radius"] < candidate["radius"] - 1e-8 + for candidate in entries + if candidate is not other + ) == containing + 1 + ] + face = Face(entry["wire"]) + result = face.make_holes([item["wire"] for item in holes]) if holes else face + source_edges = [ + (str(item["source_entity_id"]), edge) + for item in [entry, *holes] + if isinstance(item.get("source_entity_id"), str) and item["source_entity_id"] + for edge in item["wire"].edges() + ] + anchors.extend(self._face_source_anchor_specs(result, source_edges, source_sketch_id)) + faces.append(result) + return faces, anchors + @staticmethod def _split_images(splitter: BOPAlgo_Splitter, edge: Edge) -> list[Edge]: """Return OCC split history, keeping an unchanged input as one image.""" @@ -476,6 +794,96 @@ class Build123dGeometryAdapter: plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) return self._faces_from_circles(sketch.get("entities") or [], plane) + def faces_for_sketch_with_source_anchors( + self, + sketch: dict[str, Any], + ) -> 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. + """ + 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): + 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( + isinstance(contour, dict) + and bool(contour.get("closed")) + and len(contour.get("segments") or []) == 1 + and (contour.get("segments") or [{}])[0].get("type") == "circle" + for contour in contours + ): + # Keep each original circular source edge before the sketch solver + # expands it into analytic arc fragments for region bookkeeping. + # ``_faces_from_circles_with_source_anchors`` verifies each wire + # against the resulting face by exact ``IsSame``; a missing source + # label remains unavailable rather than being inferred from its + # radius or centre. This also covers a direct annulus or multiple + # independent circular regions built by the same face constructor. + circles = [ + { + "type": "circle", + "center": segment.get("center"), + "radius_mm": segment.get("radius_mm"), + **( + {"source_entity_id": segment["source_entity_id"]} + if isinstance(segment.get("source_entity_id"), str) and segment["source_entity_id"] + else {} + ), + } + for contour in contours + for segment in contour.get("segments") or [] + ] + return self._faces_from_circles_with_source_anchors( + circles, PlaneSpec.from_mapping(sketch.get("workplane") or {}), source_sketch_id, + ) + regions = sketch.get("contour_regions_mm") or [] + if regions: + # ``Face.make_holes`` remains the authoritative geometry path for + # a mixed multi-region profile. OCC can give a valid direct face + # for each individual region while changing how those regions + # interact when their prisms are combined. The direct builder is + # therefore limited to one hole-bearing region; keep the existing + # executable profile and withhold anchors for the broader case. + if len(regions) > 1 and any(region.get("holes") for region in regions): + return self.faces_for_sketch(sketch), [] + logical_circle_sources = self._logical_circle_sources(profile) + plane_spec = PlaneSpec.from_mapping(sketch.get("workplane") or {}) if logical_circle_sources else None + faces: list[Face] = [] + anchors: list[dict[str, Any]] = [] + for region in regions: + outer = region.get("outer") or [] + if len(outer) < 1: + continue + # Build all direct boundary wires in one face builder. Each + # prospective source edge is verified below against the final + # face, so an OCC wire repair or an unsupported hole topology + # removes evidence rather than creating a geometric fallback. + try: + result, source_edges = self._face_from_direct_wires( + outer, + region.get("holes") or (), + logical_circle_sources=logical_circle_sources, + plane_spec=plane_spec, + ) + except ValueError: + # Keep the established executable profile path when the + # direct builder cannot represent this analytic region. + # It deliberately carries no provenance anchor. + return self.faces_for_sketch(sketch), [] + anchors.extend(self._face_source_anchor_specs(result, source_edges, source_sketch_id)) + faces.append(result) + return faces, anchors + if profile.get("type") == "circle": + plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) + return self._faces_from_circles_with_source_anchors( + sketch.get("entities") or [], plane, source_sketch_id, + ) + return self.faces_for_sketch(sketch), [] + @staticmethod def face_with_holes(outer: Face, holes: Iterable[Face]) -> Face: """Build one planar profile from a sketch outer wire and cap-face holes.""" @@ -582,7 +990,7 @@ class Build123dGeometryAdapter: @staticmethod def extrude_with_topology_delta(face: Face, direction: Vector3) -> tuple[Solid, TopologyDelta]: - """Extrude one B-rep face and retain its two builder-proven cap faces.""" + """Extrude one face with exact cap, side-wall, and swept-edge history.""" vector = _vector(direction) if vector.length <= 1e-9: raise ValueError("extrude direction must be non-zero") @@ -600,6 +1008,46 @@ class Build123dGeometryAdapter: relations.append(TopologyDeltaRelation( "generated", "face", face.wrapped, (output,), output_role=role, )) + for source_edge in face.edges(): + # ``Generated(edge)`` proves the lateral face only. Prism's + # source-edge overloads preserve the distinct start/end cap-edge + # mapping, verified against the final result snapshot here. + for role, cap_edge in ( + ("extrude.start", builder.FirstShape(source_edge.wrapped)), + ("extrude.end", builder.LastShape(source_edge.wrapped)), + ): + is_final_edge = ( + not cap_edge.IsNull() + and cap_edge.ShapeType() == TopAbs_EDGE + and any(cap_edge.IsSame(edge.wrapped) for edge in result.edges()) + ) + relations.append(TopologyDeltaRelation( + "generated", "edge", source_edge.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", + )) + generated = tuple(builder.Generated(source_edge.wrapped)) + side_faces = tuple(shape for shape in generated if shape.ShapeType() == TopAbs_FACE) + relations.append(TopologyDeltaRelation( + "generated", "edge", source_edge.wrapped, side_faces, + source_kind="edge", result_kind="face", derivation="boundary", + coverage="complete" if len(side_faces) == len(generated) and side_faces else "partial", + status="proven" if len(side_faces) == len(generated) and side_faces else "unknown", + )) + for source_vertex in source_edge.vertices(): + generated_edges = tuple(builder.Generated(source_vertex.wrapped)) + swept_edges = tuple(shape for shape in generated_edges if shape.ShapeType() == TopAbs_EDGE) + relations.append(TopologyDeltaRelation( + "generated", "vertex", source_vertex.wrapped, swept_edges, + source_kind="vertex", result_kind="edge", derivation="boundary", + 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", + )) return result, TopologyDelta(operation="extrude", relations=tuple(relations)) @staticmethod @@ -1241,8 +1689,11 @@ class Build123dGeometryAdapter: @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] = [] - for source in sources: + 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()) for kind, shapes in ( ("face", list(source.faces())), ("edge", list(source.edges())), @@ -1274,6 +1725,7 @@ class Build123dGeometryAdapter: if generated: relations.append(TopologyDeltaRelation("generated", kind, source_value, generated)) section_values: tuple[Any, ...] = () + section_relations: list[TopologySectionRelation] = [] section_edges = getattr(operation, "SectionEdges", None) if callable(section_edges): try: @@ -1283,10 +1735,37 @@ class Build123dGeometryAdapter: section_values = tuple(section_edges()) except (AttributeError, TypeError, ValueError): section_values = () + if section_values and len(source_bodies) >= 2: + # A section edge becomes source-qualified only when OCC returns + # that exact edge from Generated(face) for one face from each + # boolean input. Do not inspect BOPDS internals here: those + # Python bindings are unsafe for this traversal and ordinary + # SectionEdges remain useful unqualified diagnostics. + for section_edge in section_values: + generators: list[tuple[int, Any]] = [] + for source_index, source_face in source_faces: + try: + generated = tuple(operation.Generated(source_face)) + except (AttributeError, TypeError, ValueError): + generated = () + if any(candidate.IsSame(section_edge) for candidate in generated): + generators.append((source_index, source_face)) + if len(generators) != 2 or {item[0] for item in generators} != {0, 1}: + continue + section_relations.append(TopologySectionRelation( + source_values=(generators[0][1], generators[1][1]), + result_value=section_edge, + )) + qualified_section_values = tuple(item.result_value for item in section_relations) + unqualified_section_values = tuple( + value for value in section_values + if not any(value.IsSame(qualified) for qualified in qualified_section_values) + ) return TopologyDelta( operation=operation_name, relations=tuple(relations), - section_values=section_values, + section_values=unqualified_section_values, + section_relations=tuple(section_relations), ) @staticmethod @@ -1830,20 +2309,21 @@ class Build123dGeometryAdapter: end_tangent: Vector3 | None = None, parameters: list[float] | None = None, ) -> Edge | Wire: - # 两点路径保持直线;三个及以上插值点构造单段 B-spline。端切线是 - # FeatureScript skFitSpline 的约束,缺失时不能伪造,交给内核自动求解。 + # 两点无导数路径保持直线;两个点加两个端切线以及三个及以上插值点 + # 构造单段 B-spline。端切线是 FeatureScript skFitSpline 的约束, + # 缺失时不能伪造,交给内核自动求解。 vertices = [_vector(point) for point in points] if len(vertices) < 2: raise ValueError("sweep path needs at least two points") - if len(vertices) == 2: - if start_tangent is not None or end_tangent is not None or parameters is not None: - raise ValueError("line sweep path does not accept B-spline tangents") - return Edge.make_line(vertices[0], vertices[1]) if (start_tangent is None) != (end_tangent is None): raise ValueError("sweep B-spline path requires both endpoint tangents") tangents = [_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None if parameters is not None and len(parameters) != len(vertices): raise ValueError("sweep B-spline path parameters must match point count") + if len(vertices) == 2 and tangents is None: + if parameters is not None: + raise ValueError("line sweep path does not accept B-spline parameters") + return Edge.make_line(vertices[0], vertices[1]) direction = vertices[-1] - vertices[0] tolerance = 1e-9 * max(1.0, direction.length) collinear_points = direction.length > tolerance and all( diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index 67c664f1..6c3619f0 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -17,6 +17,7 @@ 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 _SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_") @@ -97,6 +98,24 @@ def _contract_selectors(node: FeaturePlanNode, contract: dict[str, Any] | None) return [value for value in values if isinstance(value, dict)] +def _up_to_surface_output_role_reference(node: FeaturePlanNode, contract: dict[str, Any] | None) -> dict[str, Any] | None: + """Return the one nested output-role slot supported by extrusion extents.""" + policy = ((contract or {}).get("nested_selector_policies") or {}).get("params.end_condition.reference") + if ( + 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("requires_immediate_owner") is not True + ): + return None + end_condition = node.params.get("end_condition") or {} + reference = end_condition.get("reference") if isinstance(end_condition, dict) else None + if end_condition.get("type") == "up_to_surface" and isinstance(reference, dict) and reference.get("output_role") is not None: + return reference + return None + + 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 ()} @@ -232,6 +251,8 @@ def _next_body_graph( return members, has_active_body if atomic_id in {"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "revolve_cut"} or ( + atomic_id in _HOLE_ATOMICS and node.params.get("scope_feature_id") is not None + ) or ( atomic_id == "extrude_from_face" and node.params.get("operation") == "cut" ): # Primary cuts execute per explicit member in the runtime so a later @@ -448,6 +469,7 @@ class CapabilityAnalyzer: completed: set[str] = set() body_available = False body_members: set[str] = set() + previous_node: FeaturePlanNode | None = None for node in plan: blockers: list[RuntimeDiagnostic] = [] contract = self.contracts.get(node.atomic_id) @@ -559,19 +581,38 @@ class CapabilityAnalyzer: sketch_id=sketch_id, )) contract_selectors = _contract_selectors(node, contract) - contract_selector_ids = {id(selector) for selector in contract_selectors} + extent_selector = _up_to_surface_output_role_reference(node, contract) + output_role_selectors = [ + *contract_selectors, + *([extent_selector] if extent_selector is not None else []), + ] + contract_selector_ids = {id(selector) for selector in output_role_selectors} + selector_intent_ids = { + id(selector.get("selector_intent")) + for selector in output_role_selectors + if isinstance(selector.get("selector_intent"), dict) + } for selector in _mappings(params): - if selector.get("output_role") is not None and id(selector) not in contract_selector_ids: + if ( + selector.get("output_role") is not None + and selector.get("kind") is not None + and id(selector) not in contract_selector_ids + and id(selector) not in selector_intent_ids + ): blockers.append(self._blocker( node.feature_id, "unsupported_output_role_selector_context", "Feature output role selector is outside the operation contract slot", )) - for selector_index, selector in enumerate(contract_selectors): + for selector_index, selector in enumerate(output_role_selectors): if selector.get("output_role") is None: continue required.append("selector:feature_output_role") - if contract is None or not contract.get("selector_slot") or contract.get("selector_token_kind") != "face": + 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", @@ -600,6 +641,39 @@ class CapabilityAnalyzer: selector_index=selector_index, )) role_source = selector.get("output_role_source") + selector_intent = selector.get("selector_intent") + 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"} + ) + if 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, + ) + ): + 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", + 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, + ) + ): + blockers.append(self._blocker( + node.feature_id, + "unsupported_cap_face_output_role_selector", + "CAP_FACE output roles require the immediately preceding direct new_body blind extrusion cap", + 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 @@ -647,11 +721,25 @@ class CapabilityAnalyzer: )) elif kind == "bspline" and ( not isinstance(segment.get("points"), list) - or len(segment.get("points") or []) < 3 + or len(segment.get("points") or []) < 2 ): blockers.append(self._blocker( node.feature_id, "invalid_sweep_path", - "Sweep B-spline path requires at least three interpolation points", + "Sweep B-spline path requires at least two interpolation points", + )) + elif ( + kind == "bspline" + and len(segment.get("points") or []) == 2 + and not ( + isinstance(segment.get("start_tangent"), list) + and len(segment["start_tangent"]) == 2 + and isinstance(segment.get("end_tangent"), list) + and len(segment["end_tangent"]) == 2 + ) + ): + blockers.append(self._blocker( + node.feature_id, "invalid_sweep_path", + "A two-point B-spline sweep path requires both endpoint tangents", )) if node.atomic_id == "boolean_bodies": target_ids = params.get("target_feature_ids") @@ -762,6 +850,27 @@ class CapabilityAnalyzer: "shell target no longer has an independently selectable body output", target_feature_id=target_feature_id, )) + if node.atomic_id in _HOLE_ATOMICS and params.get("scope_feature_id") is not None: + scope_feature_id = params.get("scope_feature_id") + required.append("hole:explicit_scope_body") + scope = nodes_by_id.get(str(scope_feature_id or "")) + if not isinstance(scope_feature_id, str) or not scope_feature_id: + blockers.append(self._blocker( + node.feature_id, "invalid_hole_scope_body", + "hole scope_feature_id must name one preceding body member", + )) + elif scope is None or scope.feature_id not in completed or scope.feature_id not in body_members: + blockers.append(self._blocker( + node.feature_id, "hole_scope_body_unavailable", + "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) @@ -1086,6 +1195,7 @@ 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 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", diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index fc597dba..0c9bac18 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -14,7 +14,6 @@ "geometry": { "type": "object", "properties": { - "selector_intent_version": {"const": "1.0"}, "sketches": {"type": "array", "items": {"$ref": "#/$defs/sketch"}} }, "required": ["sketches"], @@ -543,6 +542,7 @@ "end_condition": {"$ref": "#/$defs/endCondition"}, "positions": {"type": "array", "items": {"$ref": "#/$defs/holePosition"}}, "host_face": {"$ref": "#/$defs/hostFace"}, + "scope_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "thread": {"type": "object"}, "countersink": {"type": "object"}, "counterbore": {"type": "object"} @@ -570,9 +570,22 @@ "properties": { "ast": {}, "featurescript_version": {"type": "string", "pattern": "^[0-9]+(?:\\.[0-9]+)*$"}, - "standard_library": {"type": "string", "minLength": 1} + "standard_library": {"type": "string", "minLength": 1}, + "standard_library_version": {"type": "string", "minLength": 1}, + "standard_library_imports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": {"type": "string", "minLength": 1}, + "version": {"type": "string", "minLength": 1} + }, + "required": ["path"], + "additionalProperties": false + } + } }, - "required": ["ast", "featurescript_version"], + "required": ["ast"], "additionalProperties": false }, "selectorIntent": { @@ -580,7 +593,7 @@ "properties": { "version": {"const": "1.0"}, "kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]}, - "query_family": {"enum": ["CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "OFFSET_FACE", "INTERSECT", "COPY", "GEOMETRIC"]}, + "query_family": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,79}$"}, "source_query": {"$ref": "#/$defs/selectorIntentSourceQuery"}, "source_entity": { "type": "object", @@ -591,7 +604,47 @@ "required": ["sketch_id", "entity_id"], "additionalProperties": false }, + "source_entities": { + "type": "array", + "minItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "sketch_id": {"type": "string", "minLength": 1}, + "entity_id": {"type": "string", "minLength": 1} + }, + "required": ["sketch_id", "entity_id"], + "additionalProperties": false + } + }, + "intersection_sources": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "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 + } + }, "output_role": {"$ref": "#/$defs/featureOutputRole"}, + "lineage_role": {"enum": ["extrude.start", "extrude.end"]}, + "body_member_contract": {"enum": ["direct_new_body"]}, "derivation_policy": { "type": "object", "properties": { @@ -601,7 +654,7 @@ "required": ["allowed", "multiplicity"], "additionalProperties": false }, - "evidence": {"enum": ["kernel_history", "operation_role", "feature_script_query", "explicit_datum", "geometry_hint"]}, + "evidence": {"enum": ["kernel_history", "operation_role", "active_body_member", "feature_script_query", "explicit_datum", "geometry_hint"]}, "disambiguation": {"type": "object"} }, "required": ["version", "query_family", "source_query", "derivation_policy", "evidence"], @@ -628,7 +681,7 @@ "confidence": {"type": "number", "minimum": 0, "maximum": 1} }, "required": ["kind", "source", "confidence"], - "anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}], + "anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}, {"required": ["selector_intent"]}], "additionalProperties": false }, "analyticSegment": { @@ -649,6 +702,7 @@ "clockwise": {"type": "boolean"}, "start_tangent": {"$ref": "#/$defs/point2"}, "end_tangent": {"$ref": "#/$defs/point2"} + ,"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160} }, "required": ["type"], "allOf": [ @@ -798,7 +852,7 @@ "profile_type": {"enum": ["circle", "polygon", "analytic_contours", "planar_imprint"]}, "profile": { "oneOf": [ - {"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false}, + {"type": "object", "properties": {"type": {"const": "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/planarImprintProfile"} @@ -806,7 +860,7 @@ }, "sketch": { "type": "object", - "properties": {"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "name": {"type": "string"}, "workplane": {"$ref": "#/$defs/workplane"}, "profile": {"$ref": "#/$defs/profile"}, "role": {"enum": ["profile", "reference"]}, "attachment": {"$ref": "#/$defs/selectorRef"}, "profile_from": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, + "properties": {"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "name": {"type": "string"}, "source_sketch_id": {"type": "string", "minLength": 1, "maxLength": 160}, "workplane": {"$ref": "#/$defs/workplane"}, "profile": {"$ref": "#/$defs/profile"}, "role": {"enum": ["profile", "reference"]}, "attachment": {"$ref": "#/$defs/selectorRef"}, "profile_from": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, "required": ["id", "workplane", "profile"], "additionalProperties": false } diff --git a/backend/engine/cdsl_engine/executors/common.py b/backend/engine/cdsl_engine/executors/common.py index 22e738b7..c03afe1e 100644 --- a/backend/engine/cdsl_engine/executors/common.py +++ b/backend/engine/cdsl_engine/executors/common.py @@ -73,13 +73,50 @@ def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[s return members +def _can_register_primary_cut_tool_history( + session: "ExecutionSession", + tool: Any, + topology_delta: TopologyDelta | None, + topology_anchors: list[TopologyRecord] | None, +) -> bool: + """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. + """ + if ( + session.body is None + or topology_delta is None + or topology_delta.operation != "extrude" + 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 + return all( + anchor.kind in {"edge", "vertex"} + and (anchor.source_entity is not None or anchor.source_entities) + for anchor in topology_anchors + ) + + def _extruded_tool( node: FeaturePlanNode, faces: list[Any], profile_normal: Vector3, session: "ExecutionSession", + *, + record_multiface_prism_history: bool = False, ) -> tuple[Any, TopologyDelta | None]: - """Build one extrude tool, retaining caps only from one exact builder result.""" + """Build an extrude tool, retaining complete direct builder history.""" extents = _extent_vectors_from_normal(node, faces, profile_normal, session) draft = node.params.get("draft") taper_deg = 0.0 @@ -87,7 +124,7 @@ def _extruded_tool( taper_deg = float(draft["angle_deg"]) if not bool(draft["pull_direction"]): taper_deg = -taper_deg - topology_delta: TopologyDelta | None = None + topology_deltas: list[TopologyDelta] = [] solids: list[Any] = [] for face in faces: for extent in extents: @@ -96,11 +133,22 @@ def _extruded_tool( 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) solids.append(solid) else: solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg)) - elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1: + elif extent.trim_to is None and len(extents) == 1 and ( + len(faces) == 1 or record_multiface_prism_history + ): + # Each independently constructed profile face has its own OCC + # prism history only when the adapter retained direct source + # anchors for every participating profile path. Other complex + # 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) solids.append(solid) elif extent.trim_to is None: solids.append(session.adapter.extrude(face, extent.vector)) @@ -111,7 +159,12 @@ def _extruded_tool( tool = session.adapter.fuse(tool, solid) if tool is None: raise ValueError("extrude produced no solid") - return tool, topology_delta + if len(topology_deltas) != len(solids): + return tool, None + return tool, TopologyDelta( + operation="extrude", + relations=tuple(relation for delta in topology_deltas for relation in delta.relations), + ) def _apply_primary_tool( @@ -121,8 +174,10 @@ def _apply_primary_tool( *, cutting: bool, topology_delta: TopologyDelta | None = None, + topology_anchors: list[TopologyRecord] | None = None, ) -> FeatureResult: """Apply a profile-derived tool while preserving only final-snapshot topology evidence.""" + topology_predecessors: list[TopologyRecord] | None = None if cutting: if session.body is None: raise ValueError("cut feature has no body") @@ -130,8 +185,46 @@ def _apply_primary_tool( if not members: session.clear_body() return session.result(node) - body = session.adapter.cut(session.body, tool) - topology_delta = None + tool_delta = topology_delta + tool_anchors = list(topology_anchors or ()) + 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) + # 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 + # Modified/Preserved facts can still prove a unique continuation + # of the active target face or edge. + topology_anchors = None + if cut_delta is not 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: + # Retain cut history as partial diagnostic evidence. The + # absent transient source records prevent it from proving + # a source-qualified section edge. + topology_predecessors = None + 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 + 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} @@ -142,8 +235,10 @@ def _apply_primary_tool( # 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, ) return session.result(node) @@ -157,7 +252,7 @@ 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 = session.adapter.faces_for_sketch(selected_sketch) + faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(selected_sketch) if not faces: raise ValueError("sketch does not create a closed profile region") if node.atomic_id == "extrude_add_blind_with_hole": @@ -169,6 +264,16 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s raise ValueError("profile hole extrusion requires exactly one outer sketch region") faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])] topology_delta: TopologyDelta | None = None + topology_anchors: list[TopologyRecord] = [] + profile = selected_sketch.get("profile") or {} + contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None + direct_all_circle_profile = isinstance(contours, list) and bool(contours) and all( + isinstance(contour, dict) + and bool(contour.get("closed")) + and len(contour.get("segments") or []) == 1 + and (contour.get("segments") or [{}])[0].get("type") == "circle" + for contour in contours + ) # 3. 按特征类型生成子实体: if node.atomic_id.startswith("extrude_"): # 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量, @@ -177,7 +282,27 @@ 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), ) + 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, + )) else: # 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。 axis = _revolve_axis(node, session) @@ -199,6 +324,7 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s raise ValueError("revolve produced no solid") return _apply_primary_tool( node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta, + topology_anchors=topology_anchors, ) diff --git a/backend/engine/cdsl_engine/executors/holes.py b/backend/engine/cdsl_engine/executors/holes.py index 985e909a..bd6ad94d 100644 --- a/backend/engine/cdsl_engine/executors/holes.py +++ b/backend/engine/cdsl_engine/executors/holes.py @@ -31,6 +31,23 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard: # 1. 校验:孔是切除操作,必须先有主体。 if session.body is None: raise ValueError("hole feature has no body") + scope_feature_id = node.params.get("scope_feature_id") + 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") # 2. 确定宿主面 host_face: host_selector = node.params.get("host_face") if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict): @@ -74,5 +91,9 @@ 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, )) - session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node) + result_body = session.adapter.cut(session.body, tool) + members = {scope_feature_id: result_body} if scope_feature_id is not None else None + session.register_body( + node.feature_id, result_body, replay_node=node, body_members=members, + ) return session.result(node, diagnostics=diagnostics) diff --git a/backend/engine/cdsl_engine/operation_contracts.py b/backend/engine/cdsl_engine/operation_contracts.py index c5318216..fe4d0145 100644 --- a/backend/engine/cdsl_engine/operation_contracts.py +++ b/backend/engine/cdsl_engine/operation_contracts.py @@ -31,12 +31,14 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st params_schema = raw.get("author_params_schema") injected_paths = raw.get("server_injected_paths") selector_policy = raw.get("selector_policy") + nested_selector_policies = raw.get("nested_selector_policies") or {} runtime_capability = raw.get("runtime_capability") if ( not isinstance(shape, dict) or not isinstance(params_schema, dict) or not isinstance(injected_paths, list) or not isinstance(selector_policy, dict) + or not isinstance(nested_selector_policies, dict) ): raise ValueError(f"operation contract is incomplete for {atomic_id}") if not isinstance(runtime_capability, dict) or not all( @@ -52,6 +54,8 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st raise ValueError(f"operation author params are invalid for {atomic_id}") if not all(isinstance(name, str) and name in properties for name in required): raise ValueError(f"operation required params are invalid for {atomic_id}") + if not all(isinstance(path, str) and isinstance(policy, dict) for path, policy in nested_selector_policies.items()): + raise ValueError(f"operation nested selector policies are invalid for {atomic_id}") materialized_required = list(dict.fromkeys(required)) for path in injected_paths: if not isinstance(path, str): @@ -67,6 +71,7 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st "requires_sketch": shape.get("sketch") == "required", "selector_slot": selector_policy.get("slot"), "selector_token_kind": selector_policy.get("token_kind"), + "nested_selector_policies": dict(nested_selector_policies), "runtime_capability": dict(runtime_capability), } return derived diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index 15043830..407769c4 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -6,7 +6,7 @@ "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"], "operation_contracts": { - "extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"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_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}}, @@ -14,7 +14,7 @@ "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},"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_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}}, "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}}, diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index e01c20bd..53bcbbb2 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -20,6 +20,7 @@ working. from __future__ import annotations from copy import deepcopy +from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -27,6 +28,7 @@ from . import executors # noqa: F401 (importing performs executor registration from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract # Historical private name still imported by the test suite. from .executors.primitives import _execute_box # noqa: F401 +from .executors.common import _selector_edges # noqa: F401 from .extents import ( _extent_reference, _extent_vectors, @@ -73,6 +75,9 @@ from .topology import ( SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry, ) +# Historical private imports used by integration tests and local diagnostics. +_execute_node = execute_node + __all__ = [ "ALL_ATOMIC_IDS", "EXECUTORS", @@ -85,7 +90,10 @@ __all__ = [ "GeometryAdapter", "RuntimeDiagnostic", "RuntimeExecutionError", + "IncrementalCdslExecution", "analyze_cdsl", + "prepare_cdsl_execution", + "finalize_cdsl_execution", "execute_node", "rebuild_cdsl", ] @@ -101,8 +109,81 @@ def analyze_cdsl(cdsl: dict[str, Any]): return analyzer.analyze(resolved, sketch_errors=sketch_errors) -def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]: - """Rebuild CDSL through session-scoped atomic executors only.""" +def _preflight_error(analysis: Any) -> ValueError | None: + if analysis.runtime_eligible: + return None + first = next((result for result in analysis.feature_results if not result.executable), None) + if first is None: + return ValueError(analysis.document_blockers[0].code) + if any(blocker.code == "unknown_atomic" for blocker in first.blockers): + return ValueError(f"unsupported atomic_id: {first.atomic_id}") + detail = "; ".join(blocker.code for blocker in first.blockers) + return ValueError(f"Feature {first.feature_id} is not runtime eligible: {detail}") + + +def _execution_diagnostic( + error: Exception, + node: FeaturePlanNode, + session: ExecutionSession, +) -> RuntimeDiagnostic: + failed_resolution = next( + (item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None, + ) + if isinstance(error, FeatureExecutionError): + return RuntimeDiagnostic(error.code, str(error), feature_id=node.feature_id, detail=error.detail) + if failed_resolution and failed_resolution.get("diagnostic"): + diagnostic = failed_resolution["diagnostic"] + return RuntimeDiagnostic( + diagnostic["code"], diagnostic["message"], feature_id=node.feature_id, + detail=diagnostic.get("detail") or {}, + ) + return RuntimeDiagnostic("execution_failed", str(error), feature_id=node.feature_id) + + +@dataclass +class IncrementalCdslExecution: + """One prepared CDSL replay with feature-at-a-time execution. + + CADFS selector binding consumes this object before each feature executes. + It therefore observes the exact session topology snapshots generated by + the one real kernel replay rather than rebuilding a growing prefix for + every selector. The generic runtime also uses it through ``rebuild_cdsl``. + """ + + resolved_cdsl: dict[str, Any] + analysis: Any + session: ExecutionSession + diagnostics: list[RuntimeDiagnostic] = field(default_factory=list) + next_index: int = 0 + + def execute_next(self, *, strict: bool = True) -> FeatureResult | None: + if self.next_index >= len(self.analysis.plan): + raise ValueError("CDSL execution plan is already complete") + node = self.analysis.plan[self.next_index] + preflight = self.analysis.feature_results[self.next_index] + self.next_index += 1 + if not preflight.executable: + self.diagnostics.extend(preflight.blockers) + if strict: + detail = "; ".join(blocker.code for blocker in preflight.blockers) + raise ValueError(f"Feature {node.feature_id} is not runtime eligible: {detail}") + return None + try: + return execute_node(node, self.session) + except Exception as error: + diagnostic = _execution_diagnostic(error, node, self.session) + self.diagnostics.append(diagnostic) + if strict: + raise RuntimeExecutionError(diagnostic, list(self.session.selector_resolutions)) from error + return None + + def execute_all(self, *, strict: bool = True) -> None: + while self.next_index < len(self.analysis.plan): + self.execute_next(strict=strict) + + +def prepare_cdsl_execution(cdsl: dict[str, Any]) -> IncrementalCdslExecution: + """Resolve CDSL once and return a reusable sequential execution session.""" sketch_errors: dict[str, str] = {} resolved = resolve_required_sketches( deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors, @@ -110,45 +191,16 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) - analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS).analyze( resolved, sketch_errors=sketch_errors, ) - if strict and not analysis.runtime_eligible: - first = next((result for result in analysis.feature_results if not result.executable), None) - if first is None: - raise ValueError(analysis.document_blockers[0].code) - if any(blocker.code == "unknown_atomic" for blocker in first.blockers): - raise ValueError(f"unsupported atomic_id: {first.atomic_id}") - detail = "; ".join(blocker.code for blocker in first.blockers) - raise ValueError(f"Feature {first.feature_id} is not runtime eligible: {detail}") session = ExecutionSession( sketches={str(sketch.get("id")): sketch for sketch in (resolved.get("geometry") or {}).get("sketches") or []}, nodes={node.feature_id: node for node in analysis.plan}, ) - diagnostics: list[RuntimeDiagnostic] = [] - for node, preflight in zip(analysis.plan, analysis.feature_results): - if not preflight.executable: - diagnostics.extend(preflight.blockers) - if strict: - break - continue - try: - execute_node(node, session) - except Exception as error: - failed_resolution = next( - (item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None, - ) - diagnostic = ( - RuntimeDiagnostic(error.code, str(error), feature_id=node.feature_id, detail=error.detail) - if isinstance(error, FeatureExecutionError) - else - RuntimeDiagnostic( - failed_resolution["diagnostic"]["code"], failed_resolution["diagnostic"]["message"], - feature_id=node.feature_id, detail=failed_resolution["diagnostic"].get("detail") or {}, - ) - if failed_resolution and failed_resolution.get("diagnostic") - else RuntimeDiagnostic("execution_failed", str(error), feature_id=node.feature_id) - ) - diagnostics.append(diagnostic) - if strict: - raise RuntimeExecutionError(diagnostic, list(session.selector_resolutions)) from error + return IncrementalCdslExecution(resolved, analysis, session) + + +def finalize_cdsl_execution(execution: IncrementalCdslExecution, out_step: Path) -> dict[str, Any]: + """Export the current executable checkpoint from an incremental replay.""" + session = execution.session output = session.body surface_geometry: dict[str, Any] | None = None if output is None: @@ -177,8 +229,19 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) - "surface_face_count": int(surface_geometry["face_count"]) if surface_geometry is not None else 0, "surface_area_mm2": float(surface_geometry["area_mm2"]) if surface_geometry is not None else 0.0, "feature_results": [result.as_dict() for result in session.results.values()], - "runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics], + "runtime_diagnostics": [diagnostic.as_dict() for diagnostic in execution.diagnostics], "topology_records": [record.public_dict() for record in session.topology.records()], "topology_deltas": list(session.topology.topology_deltas()), "selector_resolution": session.selector_resolutions, } + + +def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]: + """Rebuild CDSL through session-scoped atomic executors only.""" + execution = prepare_cdsl_execution(cdsl) + if strict: + error = _preflight_error(execution.analysis) + if error is not None: + raise error + execution.execute_all(strict=strict) + return finalize_cdsl_execution(execution, out_step) diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index ffdc6b16..e29712a3 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, + TopologySectionRelation, TopologyLineage, TopologyRecord, TopologyRegistry, @@ -64,6 +65,7 @@ __all__ = [ "ThreadSpec", "TopologyDelta", "TopologyDeltaRelation", + "TopologySectionRelation", "TopologyLineage", "TopologyRecord", "TopologyRegistry", diff --git a/backend/engine/cdsl_engine/selector_capabilities.py b/backend/engine/cdsl_engine/selector_capabilities.py new file mode 100644 index 00000000..f841e3ba --- /dev/null +++ b/backend/engine/cdsl_engine/selector_capabilities.py @@ -0,0 +1,173 @@ +"""Verified FeatureScript query semantics available to the selector resolver. + +This is intentionally a small, explicit allow-list. A numeric source version +only identifies a FeatureScript release; it does not establish that this +runtime has verified a query family's source semantics for that release. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class SelectorQueryCapability: + query_family: str + featurescript_version: str + standard_library: str + standard_library_version: str + source: str + contract: str + + +# These entries correspond to the direct builder contracts exercised by the +# CADFS 1511 corpus. New versions and query families must be registered with +# their source/API evidence before the runtime accepts them. +_CAPABILITIES = { + ("CAP_FACE", "1511"): SelectorQueryCapability( + "CAP_FACE", + "1511", + "onshape/std/geometry.fs", + "1511.0", + "CADFS FeatureScript 1511 exported query history", + "direct extrude cap operation role", + ), + ("CAP_EDGE", "1511"): SelectorQueryCapability( + "CAP_EDGE", + "1511", + "onshape/std/geometry.fs", + "1511.0", + "CADFS FeatureScript 1511 exported query history", + "direct prism source edge to qualified start/end cap edge kernel lineage", + ), + ("OFFSET_FACE", "1511"): SelectorQueryCapability( + "OFFSET_FACE", + "1511", + "onshape/std/geometry.fs", + "1511.0", + "CADFS FeatureScript 1511 exported query history", + "shell offset-face operation role with true dependency qualification", + ), + # 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. + ("SWEPT_FACE", "1511"): SelectorQueryCapability( + "SWEPT_FACE", + "1511", + "onshape/std/geometry.fs", + "1511.0", + "CADFS FeatureScript 1511 exported query history", + "kernel-lineage resolver contract only", + ), + ("SWEPT_EDGE", "1511"): SelectorQueryCapability( + "SWEPT_EDGE", + "1511", + "onshape/std/geometry.fs", + "1511.0", + "CADFS FeatureScript 1511 exported query history", + "kernel-lineage resolver contract only", + ), + ("SWEPT_BODY", "1511"): SelectorQueryCapability( + "SWEPT_BODY", + "1511", + "onshape/std/geometry.fs", + "1511.0", + "CADFS FeatureScript 1511 exported query history; one-item qUnion identity", + "active direct-new-body member selected by its producing operation", + ), + ("INTERSECT", "1511"): SelectorQueryCapability( + "INTERSECT", + "1511", + "onshape/std/geometry.fs", + "1511.0", + "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", + ), +} + + +def selector_query_capability(intent: dict[str, Any]) -> SelectorQueryCapability | None: + """Return an explicitly verified query capability for ``intent`` only.""" + source_query = intent.get("source_query") + if not isinstance(source_query, dict): + return None + family = intent.get("query_family") + version = source_query.get("featurescript_version") + if not isinstance(family, str) or not isinstance(version, str): + return None + capability = _CAPABILITIES.get((family, version)) + if capability is None: + return None + if ( + source_query.get("standard_library") != capability.standard_library + or source_query.get("standard_library_version") != capability.standard_library_version + ): + return None + return capability + + +def known_selector_query_versions(query_family: str) -> tuple[str, ...]: + """Expose registered versions for deterministic unsupported diagnostics.""" + return tuple(sorted(version for family, version in _CAPABILITIES if family == query_family)) + + +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({ + (capability.standard_library, capability.standard_library_version) + for (family, _version), capability in _CAPABILITIES.items() + if family == query_family + })) + + +def is_direct_blind_extrude_cap_output_role( + selector: dict[str, Any], + producer: dict[str, Any] | None, + sketches: dict[str, dict[str, Any]], +) -> bool: + """Whether a selector names one cap from the direct prism contract. + + This is the consumer-side mirror of the lowerer's CAP_FACE constructor. + It deliberately describes a narrow builder contract, rather than treating + an output-role string as general permission to select arbitrary topology. + The runtime still has to prove the exact role in its active snapshot. + """ + 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 {} + if ( + producer.get("atomic_id") != "extrude_add_blind" + or params.get("result_mode") != "new_body" + or (params.get("end_condition") or {}).get("type") != "blind" + ): + return False + if params.get("draft") is None: + return True + sketch = sketches.get(str(producer.get("sketch_id") or "")) or {} + profile = sketch.get("profile") or {} + if profile.get("type") == "circle": + return True + contours = profile.get("contours") + return ( + profile.get("type") == "analytic_contours" + and isinstance(contours, list) + and len(contours) == 1 + and bool((contours[0] or {}).get("closed")) + ) diff --git a/backend/engine/cdsl_engine/semantic_validation.py b/backend/engine/cdsl_engine/semantic_validation.py index 3223a6db..8253b854 100644 --- a/backend/engine/cdsl_engine/semantic_validation.py +++ b/backend/engine/cdsl_engine/semantic_validation.py @@ -16,6 +16,7 @@ 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 _ID = re.compile(r"^[A-Za-z0-9_-]{1,80}$") @@ -45,6 +46,24 @@ def _contract_selectors(feature: dict[str, Any], contract: dict[str, Any]) -> li return [value for value in values if isinstance(value, dict)] +def _up_to_surface_output_role_reference(feature: dict[str, Any], contract: dict[str, Any]) -> dict[str, Any] | None: + """Return the one nested output-role slot that an extrusion may consume.""" + policy = (contract.get("nested_selector_policies") or {}).get("params.end_condition.reference") + if ( + 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("requires_immediate_owner") is not True + ): + return None + end_condition = (feature.get("params") or {}).get("end_condition") or {} + reference = end_condition.get("reference") if isinstance(end_condition, dict) else None + if end_condition.get("type") == "up_to_surface" and isinstance(reference, dict) and reference.get("output_role") is not None: + return reference + return None + + 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") @@ -52,14 +71,15 @@ def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index: if selector.get("selector_intent_version") is not None: raise ValueError(f"Feature {feature_id} selector {index} declares an intent version without selector_intent") return - if selector.get("selector_intent_version") not in {None, "1.0"}: - raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent version") if not isinstance(intent, dict) or intent.get("version") != "1.0": raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent") + legacy_version = selector.get("selector_intent_version") + if legacy_version is not None and legacy_version != intent["version"]: + raise ValueError(f"Feature {feature_id} selector {index} has conflicting selector intent versions") 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", "OFFSET_FACE", "INTERSECT", "COPY"} + derived = {"CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "SWEPT_BODY", "OFFSET_FACE", "INTERSECT", "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 {} @@ -76,10 +96,90 @@ def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index: raise ValueError(f"Feature {feature_id} selector {index} owner match cannot use geometry fallback") source_query = intent.get("source_query") or {} version = source_query.get("featurescript_version") if isinstance(source_query, dict) else None - if not isinstance(version, str) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+)*", version): - raise ValueError(f"Feature {feature_id} selector {index} has an unknown FeatureScript query version") + # An absent source version is a valid preservation state. The resolver, + # which owns execution eligibility, returns selector_query_version_unknown + # 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") 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") + source_entities = intent.get("source_entities") + if source_entity is not None and source_entities is not None: + 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") + 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") + pairs = [] + for source in source_entities: + if not isinstance(source, dict): + raise ValueError(f"Feature {feature_id} selector {index} source-vertex anchor is invalid") + sketch_id, entity_id = source.get("sketch_id"), source.get("entity_id") + if not isinstance(sketch_id, str) or not sketch_id or not isinstance(entity_id, str) or not entity_id: + raise ValueError(f"Feature {feature_id} selector {index} source-vertex anchor is incomplete") + pairs.append((sketch_id, entity_id)) + 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") + deferred_source_query = ( + policy.get("multiplicity") == "none" + and intent.get("evidence") == "feature_script_query" + ) + if family == "SWEPT_BODY" and not deferred_source_query: + if ( + selector.get("kind") != "body" + or selector.get("source") != "runtime_snapshot" + or intent.get("evidence") != "active_body_member" + or intent.get("body_member_contract") != "direct_new_body" + or policy.get("allowed") != ["boundary"] + or policy.get("multiplicity") != "one" + 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 SWEPT_BODY member contract") + if family == "INTERSECT": + if deferred_source_query: + # CADFS source preservation deliberately keeps unsupported outer + # query semantics in the candidate. The resolver rejects this + # non-executable state before any topology/geometry fallback; + # validation must not erase the preceding executable checkpoint. + if intersection_sources is not None: + raise ValueError(f"Feature {feature_id} selector {index} deferred INTERSECT cannot declare executable section sources") + else: + if ( + selector.get("kind") != "edge" + or policy.get("allowed") != ["intersection"] + or policy.get("multiplicity") != "one" + or not isinstance(intersection_sources, list) + or len(intersection_sources) != 2 + ): + raise ValueError(f"Feature {feature_id} selector {index} has an invalid INTERSECT section contract") + owners = set() + for source in intersection_sources: + if not isinstance(source, dict): + raise ValueError(f"Feature {feature_id} selector {index} INTERSECT source is invalid") + source_family = source.get("query_family") + source_owner = source.get("owner_feature_id") + if source_family not in {"CAP_FACE", "SWEPT_FACE"} or not isinstance(source_owner, str) or not source_owner: + raise ValueError(f"Feature {feature_id} selector {index} INTERSECT source is incomplete") + owners.add(source_owner) + if source_family == "CAP_FACE": + if source.get("output_role") not in {"extrude.start", "extrude.end"} or source.get("source_entity") is not None: + raise ValueError(f"Feature {feature_id} selector {index} CAP_FACE INTERSECT source is invalid") + elif not isinstance(source.get("source_entity"), dict): + raise ValueError(f"Feature {feature_id} selector {index} SWEPT_FACE INTERSECT source is invalid") + if len(owners) != 2: + 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") forbidden = {"runtime_id", "record_id", "topology_record_id", "task_id", "revision_id"} stack = [intent] while stack: @@ -166,6 +266,7 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: feature_ids: set[str] = set() preceding_features: dict[str, dict[str, Any]] = {} + previous_feature_id: str | None = None deferred: list[str] = [] unresolved: list[dict[str, Any]] = [] contracts = _operation_contracts() @@ -185,21 +286,26 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: deferred.append(fid) contract = contracts.get(str(feature.get("atomic_id") or "")) or {} feature_selectors = _contract_selectors(feature, contract) - output_role_selector_ids = {id(selector) for selector in feature_selectors if isinstance(selector, dict)} + extent_selector = _up_to_surface_output_role_reference(feature, contract) + output_role_selectors = [ + *feature_selectors, + *([extent_selector] if extent_selector is not None else []), + ] + output_role_selector_ids = {id(selector) for selector in output_role_selectors} selector_intent_ids = { id(selector.get("selector_intent")) - for selector in feature_selectors + for selector in output_role_selectors if isinstance(selector, dict) and isinstance(selector.get("selector_intent"), dict) } - for index, selector in enumerate(feature_selectors): + validated_selector_ids: set[int] = set() + for index, selector in enumerate(output_role_selectors): _validate_selector_intent(selector, fid, index) + validated_selector_ids.add(id(selector)) owner = selector.get("owner_feature_id") binding_owner = selector.get("binding_feature_id") if owner is not None and owner not in feature_ids and binding_owner not in feature_ids: raise ValueError(f"Feature {fid} selector {index} has a forward or missing owner_feature_id") if selector.get("output_role") is not None: - if 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 selector.get("kind") != "face" or not owner or owner not in feature_ids: raise ValueError(f"Feature {fid} selector {index} output role requires a preceding face owner_feature_id") if selector.get("source") != "runtime_snapshot": @@ -207,6 +313,29 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")): raise ValueError(f"Feature {fid} selector {index} output role cannot mix stable or geometry evidence") role_source = selector.get("output_role_source") + selector_intent = selector.get("selector_intent") + 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"} + ) + 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}, + ): + raise ValueError( + f"Feature {fid} up_to_surface output role requires the immediately preceding direct new_body blind extrusion cap" + ) + 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}, + ): + raise ValueError( + f"Feature {fid} CAP_FACE output role requires the immediately preceding direct new_body blind extrusion cap" + ) + 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: 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 @@ -234,6 +363,18 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: elif selector.get("output_role_source") is not None: raise ValueError(f"Feature {fid} selector {index} output role source requires output_role") for selector in _mappings(feature): + if ( + id(selector) not in validated_selector_ids + and (selector.get("selector_intent") is not None or selector.get("selector_intent_version") is not None) + ): + _validate_selector_intent(selector, fid, len(validated_selector_ids)) + validated_selector_ids.add(id(selector)) + owner = selector.get("owner_feature_id") + binding_owner = selector.get("binding_feature_id") + if owner is not None and owner not in feature_ids and binding_owner not in feature_ids: + raise ValueError( + f"Feature {fid} nested selector has a forward or missing owner_feature_id" + ) # ``output_role_source`` is provenance metadata nested inside a # feature selector, not a selector on its own. if ( @@ -251,6 +392,10 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: target_feature_id = (feature.get("params") or {}).get("target_feature_id") if target_feature_id is not None and target_feature_id not in feature_ids: raise ValueError(f"Feature {fid} shell target_feature_id requires a preceding body feature") + if feature.get("atomic_id") in {"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"}: + scope_feature_id = (feature.get("params") or {}).get("scope_feature_id") + if scope_feature_id is not None and scope_feature_id not in feature_ids: + 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 {} references = params.get("pattern_instance_refs") or [] @@ -354,6 +499,7 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: unresolved.append({"feature_id": fid, "reasons": list(feature["unresolved"])}) feature_ids.add(fid) preceding_features[fid] = feature + previous_feature_id = fid return { "schema_version": version, diff --git a/backend/engine/cdsl_engine/session.py b/backend/engine/cdsl_engine/session.py index 7b8c1775..a4a514a7 100644 --- a/backend/engine/cdsl_engine/session.py +++ b/backend/engine/cdsl_engine/session.py @@ -23,6 +23,7 @@ from .topology import ( TopologyDelta, TopologyRecord, TopologyRegistry, + validate_selector_provenance_intent, ) @@ -38,6 +39,7 @@ class GeometryAdapter(Protocol): 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 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]: ... @@ -115,6 +117,7 @@ class ExecutionSession: body_members: dict[str, Any] | None = None, topology_delta: TopologyDelta | None = None, topology_predecessors: list[TopologyRecord] | None = None, + topology_anchors: list[TopologyRecord] | None = None, ) -> None: # #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的 # 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id": @@ -123,12 +126,20 @@ class ExecutionSession: 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} + # Source-profile anchors are transient construction facts, but unlike + # generic role predecessors they must remain addressable by a later + # selector intent. They never receive a body id, so active selector + # scans cannot mistake them for current model topology. + anchors = list(topology_anchors or ()) + for anchor in anchors: + self.topology.register(anchor) + predecessors = [*(topology_predecessors or ()), *anchors] solids = self.adapter.body_solids(body) if len(solids) <= 1: self.topology.replace_body_topology( feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id), topology_delta=topology_delta, - additional_predecessors=topology_predecessors or (), + additional_predecessors=predecessors, ) else: # 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让 @@ -141,7 +152,7 @@ class ExecutionSession: ] self.topology.replace_body_topologies( feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta, - additional_predecessors=topology_predecessors or (), + additional_predecessors=predecessors, ) self.topology.register(TopologyRecord( record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id, @@ -150,6 +161,25 @@ class ExecutionSession: if replay_node is not None: self.replay_definitions[feature_id] = replay_node + def register_transient_prism_tool( + self, + feature_id: str, + tool: Any, + *, + topology_delta: TopologyDelta, + topology_anchors: list[TopologyRecord], + ) -> list[TopologyRecord]: + """Keep one direct-prism primary tool as boolean-input evidence only.""" + snapshot_id = f"transient:{feature_id}" + records = self.adapter.topology_records(tool, feature_id, snapshot_id) + return list(self.topology.register_transient_snapshot( + feature_id, + snapshot_id, + records, + topology_delta=topology_delta, + anchors=topology_anchors, + )) + def register_surface(self, feature_id: str, surface: Any) -> str: # 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body, # 否则 surface 会覆盖 active solid 并改变最终 STEP 的实体结果。 @@ -236,6 +266,17 @@ class ExecutionSession: def resolve(self, selector: dict[str, Any]) -> SelectorResolution: if selector.get("intersection_of") is not None: + # The session computes vertex intersections directly from resolved + # face components, so enforce the same outer provenance gate that + # TopologyRegistry.resolve applies before any geometry operation. + validation_error = validate_selector_provenance_intent(selector) + if validation_error is not None: + return self._record_selector_resolution(SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=validation_error, + )) return self._record_selector_resolution(self._resolve_intersection_vertex(selector)) owner = str(selector.get("owner_feature_id") or "") active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id diff --git a/backend/engine/cdsl_engine/sketch_solver.py b/backend/engine/cdsl_engine/sketch_solver.py index b21856c1..f749618d 100644 --- a/backend/engine/cdsl_engine/sketch_solver.py +++ b/backend/engine/cdsl_engine/sketch_solver.py @@ -17,12 +17,28 @@ _Ctx = dict[str, Any] _TOLERANCE_MM = 1e-5 -def _circle(center: list[float], radius_mm: float, construction: bool = False) -> _Ctx: - return {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction} +def _circle( + center: list[float], + radius_mm: float, + construction: bool = False, + source_entity_id: str | None = None, +) -> _Ctx: + output = {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction} + if source_entity_id is not None: + output["source_entity_id"] = source_entity_id + return output -def _line(start: list[float], end: list[float], construction: bool = False) -> _Ctx: - return {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction} +def _line( + start: list[float], + end: list[float], + construction: bool = False, + source_entity_id: str | None = None, +) -> _Ctx: + output = {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction} + if source_entity_id is not None: + output["source_entity_id"] = source_entity_id + return output def _point(point: list[float]) -> list[float]: @@ -118,7 +134,8 @@ def _gen_circle(profile: _Ctx, _: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: cx, cy = float(center[0]), float(center[1]) # 直接圆 profile 必须保留为一条完整的圆边。若拆成四条圆弧,后续按边 # 选择的圆角/倒角会把同一拓扑圆误解为四个独立目标。 - return [_circle([cx, cy], radius)], [] + source_entity_id = profile.get("source_entity_id") + return [_circle([cx, cy], radius, source_entity_id=source_entity_id if isinstance(source_entity_id, str) else None)], [] def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: @@ -215,7 +232,16 @@ def _circle_edges(segment: _Ctx) -> list[_Ctx]: clockwise = bool(segment.get("clockwise", False)) angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0] points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles] - return [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)] + edges = [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)] + source_entity_id = segment.get("source_entity_id") + if isinstance(source_entity_id, str) and source_entity_id: + # Region construction needs four arc segments, but this marker records + # that all four came from exactly one logical source circle. It is not + # an edge anchor: callers must rebuild one native circle wire and pass + # final-face identity checks before using it for lineage. + for edge in edges: + edge["logical_circle_source_entity_id"] = source_entity_id + return edges def _ellipse_edges(segment: _Ctx) -> list[_Ctx]: @@ -242,14 +268,14 @@ def _ellipse_edges(segment: _Ctx) -> list[_Ctx]: def _segment_edges(segment: _Ctx) -> list[_Ctx]: kind = segment.get("type") if kind == "line": - return [_contour_line(segment["start"], segment["end"])] - if kind == "arc": - return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))] - if kind == "circle": - return _circle_edges(segment) - if kind == "ellipse": - return _ellipse_edges(segment) - if kind == "bspline": + edges = [_contour_line(segment["start"], segment["end"])] + elif kind == "arc": + edges = [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))] + elif kind == "circle": + edges = _circle_edges(segment) + elif kind == "ellipse": + edges = _ellipse_edges(segment) + elif kind == "bspline": points = segment.get("points") or [] if len(points) < 2: raise ValueError("analytic_contours: bspline needs at least 2 interpolation points") @@ -304,8 +330,18 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]: raise ValueError("analytic_contours: periodic bspline does not accept endpoint tangents") output["start_tangent_mm"] = _point(start_tangent) output["end_tangent_mm"] = _point(end_tangent) - return [output] - raise ValueError(f"analytic_contours: unsupported segment type {kind!r}") + edges = [output] + else: + raise ValueError(f"analytic_contours: unsupported segment type {kind!r}") + source_entity_id = segment.get("source_entity_id") + # Only a one-edge construction has the direct, one-to-one source identity + # required by profile-to-prism lineage. Circles expanded to arcs carry a + # separate logical-circle marker, not a source edge identity; the adapter + # may use it only to reconstruct one native circle wire with exact final + # membership proof. Other multi-edge approximations remain unanchored. + if isinstance(source_entity_id, str) and len(edges) == 1: + edges[0]["source_entity_id"] = source_entity_id + return edges def _imprint_segment_edges(segment: _Ctx) -> list[_Ctx]: @@ -466,11 +502,18 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[ raw_edges: list[_Ctx] = [] for segment in contour.get("segments") or []: if segment.get("type") == "line": - entities.append(_line(segment["start"], segment["end"])) + entities.append(_line( + segment["start"], segment["end"], + source_entity_id=segment.get("source_entity_id") if isinstance(segment.get("source_entity_id"), str) else None, + )) elif segment.get("type") == "circle": if contour_open: raise ValueError(f"analytic_contours: open contour {index} cannot contain a full circle segment") - entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0))) + entities.append(_circle( + segment.get("center") or [0.0, 0.0], + float(segment.get("radius_mm") or 0.0), + source_entity_id=segment.get("source_entity_id") if isinstance(segment.get("source_entity_id"), str) else None, + )) raw_edges.extend(_segment_edges(segment)) if raw_edges: edges = _join(raw_edges, allow_open=contour_open) diff --git a/backend/engine/cdsl_engine/topology.py b/backend/engine/cdsl_engine/topology.py index a3e39147..39a2ec1b 100644 --- a/backend/engine/cdsl_engine/topology.py +++ b/backend/engine/cdsl_engine/topology.py @@ -11,6 +11,11 @@ from dataclasses import dataclass, field from math import sqrt from typing import Any, Iterable +from .selector_capabilities import ( + known_selector_query_standard_library_versions, + known_selector_query_versions, + selector_query_capability, +) from .specs import AxisSpec, PlaneSpec, Vector3, _length, _vector3, normalize_selector_geometry @@ -30,6 +35,91 @@ class RuntimeDiagnostic: return output +_SELECTOR_PROVENANCE_DERIVATIONS = frozenset({ + "continuation", "fragment", "merge", "intersection", "boundary", "replacement", +}) + + +def selector_has_provenance_intent(selector: dict[str, Any]) -> bool: + """Whether ``selector`` must use the FeatureScript provenance contract.""" + intent = selector.get("selector_intent") + return isinstance(intent, dict) and intent.get("query_family") != "GEOMETRIC" + + +def selector_intent_policy(intent: dict[str, Any]) -> tuple[set[str], str]: + """Return the supported derivations and requested cardinality policy.""" + policy = intent.get("derivation_policy") + if not isinstance(policy, dict): + policy = {} + return ( + { + value for value in policy.get("allowed") or () + if value in _SELECTOR_PROVENANCE_DERIVATIONS + }, + str(policy.get("multiplicity") or "one"), + ) + + +def validate_selector_provenance_intent(selector: dict[str, Any]) -> RuntimeDiagnostic | None: + """Return the common provenance gate failure before resolving topology.""" + if not selector_has_provenance_intent(selector): + return None + intent = selector["selector_intent"] + legacy_version = selector.get("selector_intent_version") + if legacy_version is not None and legacy_version != intent.get("version"): + return RuntimeDiagnostic( + "selector_query_unsupported", + "Selector intent has conflicting nested and legacy version fields", + detail={"nested_version": intent.get("version"), "legacy_version": legacy_version}, + ) + source_query = intent.get("source_query") + source_version = source_query.get("featurescript_version") if isinstance(source_query, dict) else None + if not isinstance(source_version, str) or not source_version or source_version == "0": + return RuntimeDiagnostic( + "selector_query_version_unknown", + "FeatureScript query source version is unavailable", + detail={"query_family": intent.get("query_family")}, + ) + standard_library = source_query.get("standard_library") if isinstance(source_query, dict) else None + standard_library_version = source_query.get("standard_library_version") if isinstance(source_query, dict) else None + if not isinstance(standard_library, str) or not standard_library or not isinstance(standard_library_version, str) or not standard_library_version: + return RuntimeDiagnostic( + "selector_query_version_unknown", + "FeatureScript query standard-library import or version is unavailable", + detail={"query_family": intent.get("query_family")}, + ) + capability = selector_query_capability(intent) + if capability is None: + family = str(intent.get("query_family") or "") + return RuntimeDiagnostic( + "selector_query_unsupported", + "FeatureScript query semantics are not registered for this source version", + detail={ + "query_family": family, + "featurescript_version": source_version, + "verified_versions": list(known_selector_query_versions(family)), + "standard_library": standard_library, + "standard_library_version": standard_library_version, + "verified_standard_libraries": [ + {"path": path, "version": version} + for path, version in known_selector_query_standard_library_versions(family) + ], + }, + ) + allowed, multiplicity = selector_intent_policy(intent) + if multiplicity == "none" or not allowed: + return RuntimeDiagnostic( + "selector_query_unsupported", + "Selector derivation policy does not permit execution", + detail={ + "query_family": intent.get("query_family"), + "multiplicity": multiplicity, + "allowed": sorted(allowed), + }, + ) + return None + + @dataclass(frozen=True) class CapabilityResult: feature_id: str @@ -120,6 +210,18 @@ class TopologyRecord: # operation transformed. This is semantic provenance, not a stable-id # shortcut: the resolver still requires the exact active result snapshot. output_role_sources: tuple[tuple[str, str, str], ...] = () + # A source profile anchor is a transient, exact B-rep subshape built from + # one declared FeatureScript sketch entity. It is not active topology and + # never participates in geometry fallback selection. + source_entity: tuple[str, str] | None = None + # A direct prism can generate an edge from a profile vertex jointly named + # by its incident source entities. The tuple is canonicalized by the + # adapter before registration; a partial set is intentionally not equal. + source_entities: tuple[tuple[str, str], ...] = () + # A transient record is retained solely as a historical boolean input. + # It can supply exact source handles to a later builder relation, but can + # never be returned as current/selectable model topology. + transient: bool = False @property def owners(self) -> tuple[str, ...]: @@ -144,6 +246,18 @@ class TopologyRecord: {"output_role": role, "owner_feature_id": owner, "source_output_role": source_role} for role, owner, source_role in self.output_role_sources ] + if self.source_entity is not None: + output["source_entity"] = { + "sketch_id": self.source_entity[0], + "entity_id": self.source_entity[1], + } + if self.source_entities: + output["source_entities"] = [ + {"sketch_id": sketch_id, "entity_id": entity_id} + for sketch_id, entity_id in self.source_entities + ] + if self.transient: + output["transient"] = True return output @@ -170,12 +284,19 @@ class TopologyDeltaRelation: result_slot: str | None = None coverage: str = "complete" status: str = "proven" + # ``kind`` remains the compatibility shorthand for same-type history. + # Builders may also generate edge -> face or vertex -> edge relations. + source_kind: str | None = None + result_kind: str | None = None def __post_init__(self) -> None: if self.event not in {"preserved", "modified", "generated", "deleted"}: raise ValueError(f"unsupported topology delta event {self.event!r}") if self.kind not in {"face", "edge", "vertex"}: raise ValueError(f"unsupported topology delta kind {self.kind!r}") + for name, value in (("source_kind", self.source_kind), ("result_kind", self.result_kind)): + if value is not None and value not in {"face", "edge", "vertex"}: + raise ValueError(f"unsupported topology delta {name} {value!r}") if self.event == "deleted" and (self.result_values or self.output_role is not None): raise ValueError("deleted topology delta relations cannot have result values or an output role") if self.output_role is not None and (not isinstance(self.output_role, str) or not self.output_role): @@ -197,6 +318,31 @@ class TopologyDeltaRelation: raise ValueError(f"unsupported topology relation status {self.status!r}") +@dataclass(frozen=True) +class TopologySectionRelation: + """One source-qualified boolean section edge. + + ``SectionEdges()`` alone proves that a boolean produced an edge, but not + which input faces produced it. OCC can provide that additional fact when + the exact section edge is returned by ``Generated(face)`` for each of two + input faces. Keep this as a separate relation because the ordinary delta + relation contract is intentionally one-source-to-many-results. + """ + + source_values: tuple[Any, Any] + result_value: Any + coverage: str = "complete" + status: str = "proven" + + def __post_init__(self) -> None: + if self.source_values[0] is self.source_values[1]: + raise ValueError("section relation requires two distinct source faces") + if self.coverage not in {"complete", "partial", "none"}: + raise ValueError(f"unsupported section relation coverage {self.coverage!r}") + if self.status not in {"proven", "unknown", "rejected"}: + raise ValueError(f"unsupported section relation status {self.status!r}") + + @dataclass(frozen=True) class TopologyDelta: """Kernel-backed topology history for one adapter operation. @@ -211,6 +357,16 @@ class TopologyDelta: # Boolean section edges are distinct from source continuations. INTERSECT # selectors may only rely on this exact builder evidence. section_values: tuple[Any, ...] = () + # A section value is executable provenance only when this relation proves + # both source faces and the exact final edge. Unqualified values remain + # diagnostic facts in ``section_values``. + section_relations: tuple[TopologySectionRelation, ...] = () + history_status: str = "proven" + history_reason: str | None = None + + def __post_init__(self) -> None: + if self.history_status not in {"proven", "partial", "unknown", "rejected"}: + raise ValueError(f"unsupported topology delta history status {self.history_status!r}") @dataclass(frozen=True) @@ -225,6 +381,9 @@ class TopologyLineage: status: str operation: str output_role: str | None = None + feature_id: str | None = None + source_kind: str | None = None + result_kind: str | None = None def as_dict(self) -> dict[str, Any]: result = { @@ -238,6 +397,12 @@ class TopologyLineage: } if self.output_role is not None: result["output_role"] = self.output_role + if self.feature_id is not None: + result["feature_id"] = self.feature_id + if self.source_kind is not None: + result["source_kind"] = self.source_kind + if self.result_kind is not None: + result["result_kind"] = self.result_kind return result @@ -251,18 +416,11 @@ class SelectorResolution: records: tuple[TopologyRecord, ...] = () candidates: tuple[dict[str, Any], ...] = () diagnostic: RuntimeDiagnostic | None = None - - @property - def resolution_mode(self) -> str: - if self.status == "resolved": - if self.records: - return "kernel_lineage" - if self.selector.get("output_role"): - return "operation_role" - if self.selector.get("selector_intent"): - return "kernel_lineage" - return "geometry" - return "unresolved" + # This is assigned by the resolution branch, never reconstructed from + # serialized selector fields. That keeps audit evidence honest when a + # legacy selector and a provenance selector share the same shape. + resolution_mode: str = "unresolved" + evidence: dict[str, Any] = field(default_factory=dict) def as_dict(self) -> dict[str, Any]: output = { @@ -284,11 +442,8 @@ class SelectorResolution: output["records"] = [record.public_dict() for record in self.records] if self.diagnostic is not None: output["diagnostic"] = self.diagnostic.as_dict() - if self.status == "resolved": - output["evidence"] = { - "source_records": [record.record_id for record in (self.records or ((self.record,) if self.record else ()))], - "result_records": [record.record_id for record in (self.records or ((self.record,) if self.record else ()))], - } + if self.evidence: + output["evidence"] = self.evidence return output @@ -325,6 +480,93 @@ class TopologyRegistry: """Return kernel-backed N:M lineage without heuristic successors.""" return tuple(self._lineage) + @classmethod + def from_public_snapshot( + cls, + records: Iterable[dict[str, Any]], + topology_deltas: Iterable[dict[str, Any]] = (), + ) -> "TopologyRegistry": + """Rehydrate resolver facts from a prior session report. + + Prefix binding uses this only as a diagnostic compatibility adapter. + It receives exported record IDs and already-recorded kernel relations, + never geometry guesses promoted to lineage. The live execution path + still owns the opaque OCC values and records the original facts. + """ + registry = cls() + for item in records: + if not isinstance(item, dict): + continue + role_sources = [] + for source in item.get("output_role_sources") or (): + if not isinstance(source, dict): + continue + role, owner, source_role = ( + source.get("output_role"), source.get("owner_feature_id"), source.get("source_output_role"), + ) + if all(isinstance(value, str) for value in (role, owner, source_role)): + role_sources.append((role, owner, source_role)) + record_id = str(item.get("record_id") or "") + kind = str(item.get("kind") or "") + # Old prefix reports did not export context feature IDs. Retain + # a synthetic report-local producer for legacy geometric binding; + # provenance selectors still require their own source relation. + feature_id = str(item.get("feature_id") or "report_snapshot") + if not record_id or not kind: + continue + source_entity = item.get("source_entity") + source_pair = ( + (str(source_entity["sketch_id"]), str(source_entity["entity_id"])) + if isinstance(source_entity, dict) + and isinstance(source_entity.get("sketch_id"), str) + and isinstance(source_entity.get("entity_id"), str) + else None + ) + source_pairs = tuple( + (str(source["sketch_id"]), str(source["entity_id"])) + for source in item.get("source_entities") or () + if isinstance(source, dict) + and isinstance(source.get("sketch_id"), str) + and isinstance(source.get("entity_id"), str) + ) + registry.register(TopologyRecord( + record_id=record_id, + kind=kind, + feature_id=feature_id, + body_id=str(item["body_id"]) if item.get("body_id") is not None else None, + geometry=dict(item.get("geometry") or {}), + # Record IDs are opaque-but-exact within one exported report. + value=record_id, + owner_feature_ids=tuple(str(value) for value in item.get("owner_feature_ids") or ()), + output_roles=tuple(str(value) for value in item.get("output_roles") or ()), + output_role_sources=tuple(role_sources), + source_entity=source_pair, + source_entities=source_pairs, + transient=bool(item.get("transient")), + )) + for delta in topology_deltas: + if not isinstance(delta, dict): + continue + feature_id = str(delta.get("feature_id") or "") + operation = str(delta.get("operation") or "") + for item in delta.get("lineage") or (): + if not isinstance(item, dict): + continue + registry._lineage.append(TopologyLineage( + source_record_ids=tuple(str(value) for value in item.get("source_record_ids") or ()), + result_record_ids=tuple(str(value) for value in item.get("result_record_ids") or ()), + derivation=str(item.get("derivation") or ""), + evidence=str(item.get("evidence") or "kernel_history"), + coverage=str(item.get("coverage") or "partial"), + status=str(item.get("status") or "unknown"), + operation=str(item.get("operation") or operation), + output_role=str(item["output_role"]) if item.get("output_role") is not None else None, + feature_id=str(item.get("feature_id") or feature_id) or None, + source_kind=str(item["source_kind"]) if item.get("source_kind") is not None else None, + result_kind=str(item["result_kind"]) if item.get("result_kind") is not None else None, + )) + return registry + def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord: kind = "plane" if isinstance(context, PlaneSpec) else "axis" record = TopologyRecord( @@ -337,6 +579,146 @@ class TopologyRegistry: self.register(record) return record + def register_transient_snapshot( + self, + feature_id: str, + snapshot_id: str, + records: Iterable[TopologyRecord], + *, + topology_delta: TopologyDelta, + anchors: Iterable[TopologyRecord], + ) -> tuple[TopologyRecord, ...]: + """Register a non-selectable direct-prism input for one following boolean. + + A primary ``REMOVE`` builds its tool only long enough to feed a cut; + unlike a ``new_body`` tool, that B-rep never becomes active model + topology. Its exact faces and direct profile anchors are nevertheless + needed to prove a source-qualified section edge. This records that + limited historical snapshot without advancing ``_active_body_id`` or + enabling geometry/stable-ID selection against the tool. + """ + if ( + topology_delta.operation != "extrude" + or topology_delta.history_status != "proven" + or not topology_delta.relations + ): + raise ValueError("transient snapshot requires complete direct-prism history") + raw_anchors = list(anchors) + raw_records = list(records) + if not snapshot_id or not raw_anchors or not raw_records: + raise ValueError("transient snapshot requires source anchors and result topology") + + transient_anchors = [ + TopologyRecord( + record_id=record.record_id, + kind=record.kind, + feature_id=feature_id, + body_id=None, + geometry=dict(record.geometry), + value=record.value, + owner_feature_ids=(feature_id,), + output_roles=record.output_roles, + output_role_sources=record.output_role_sources, + source_entity=record.source_entity, + source_entities=record.source_entities, + transient=True, + ) + for record in raw_anchors + ] + for anchor in transient_anchors: + self.register(anchor) + current = [ + TopologyRecord( + record_id=record.record_id, + kind=record.kind, + feature_id=feature_id, + body_id=snapshot_id, + geometry=dict(record.geometry), + value=record.value, + owner_feature_ids=(feature_id,), + output_roles=record.output_roles, + output_role_sources=record.output_role_sources, + source_entity=record.source_entity, + source_entities=record.source_entities, + transient=True, + ) + for record in raw_records + ] + ( + _exact_predecessors, + _exact_successors, + _kernel_covered_predecessors, + exact_output_roles, + exact_output_role_sources, + delta_evidence, + ) = self._exact_delta_links(topology_delta, transient_anchors, current) + registered = [ + TopologyRecord( + record_id=record.record_id, + kind=record.kind, + feature_id=feature_id, + body_id=snapshot_id, + geometry=dict(record.geometry), + value=record.value, + owner_feature_ids=(feature_id,), + output_roles=tuple(sorted({*record.output_roles, *exact_output_roles.get(record.record_id, ())})), + output_role_sources=tuple(sorted({*record.output_role_sources, *exact_output_role_sources.get(record.record_id, ())})), + source_entity=record.source_entity, + source_entities=record.source_entities, + transient=True, + ) + for record in current + ] + for record in registered: + self.register(record) + self._append_delta_evidence( + feature_id, topology_delta, transient_anchors, current, delta_evidence, + ) + return tuple(registered) + + def _append_delta_evidence( + self, + feature_id: str, + topology_delta: TopologyDelta, + previous: Iterable[TopologyRecord], + current: Iterable[TopologyRecord], + delta_evidence: list[dict[str, Any]] | None, + ) -> None: + """Persist exact adapter evidence after any registered snapshot.""" + if delta_evidence is None: + return + previous_records = list(previous) + current_records = list(current) + operation_lineage = [ + TopologyLineage( + source_record_ids=tuple(item["source_record_ids"]), + result_record_ids=tuple(item["result_record_ids"]), + derivation=str(item["derivation"]), + evidence="kernel_history", + coverage=str(item["coverage"]), + status=str(item["lineage_status"]), + operation=topology_delta.operation, + output_role=item.get("output_role"), + feature_id=feature_id, + source_kind=item.get("source_kind"), + result_kind=item.get("result_kind"), + ) + for item in delta_evidence + ] + self._lineage.extend(operation_lineage) + self._topology_deltas.append({ + "feature_id": feature_id, + "operation": topology_delta.operation, + "input_snapshot_ids": sorted({str(record.body_id) for record in previous_records if record.body_id}), + "output_snapshot_ids": sorted({str(record.body_id) for record in current_records if record.body_id}), + "input_body_members": sorted({str(record.body_id) for record in previous_records if record.body_id}), + "output_body_members": sorted({str(record.body_id) for record in current_records if record.body_id}), + "history_status": topology_delta.history_status, + **({"history_reason": topology_delta.history_reason} if topology_delta.history_reason else {}), + "relations": delta_evidence, + "lineage": [lineage.as_dict() for lineage in operation_lineage], + }) + def replace_body_topology( self, feature_id: str, body_id: str, records: Iterable[TopologyRecord], *, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None, @@ -450,6 +832,8 @@ class TopologyRegistry: owner_feature_ids=owners, output_roles=tuple(sorted(output_roles)), output_role_sources=tuple(sorted(output_role_sources)), + source_entity=record.source_entity, + source_entities=record.source_entities, )) for record in registered: self.register(record) @@ -458,27 +842,10 @@ class TopologyRegistry: for successor_id in successor_ids: if successor_id not in known: known.append(successor_id) - if delta_evidence is not None: - operation_lineage = [ - TopologyLineage( - source_record_ids=tuple(item["source_record_ids"]), - result_record_ids=tuple(item["result_record_ids"]), - derivation=str(item["derivation"]), - evidence="kernel_history", - coverage=str(item["coverage"]), - status=str(item["lineage_status"]), - operation=topology_delta.operation, - output_role=item.get("output_role"), - ) - for item in delta_evidence - ] - self._lineage.extend(operation_lineage) - self._topology_deltas.append({ - "feature_id": feature_id, - "operation": topology_delta.operation, - "relations": delta_evidence, - "lineage": [lineage.as_dict() for lineage in operation_lineage], - }) + if topology_delta is not None: + self._append_delta_evidence( + feature_id, topology_delta, previous, current_records, delta_evidence, + ) # #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续 # selector 的 stable_id 引用解析到 active body 内的新形态。多条演化 # 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段 @@ -552,24 +919,32 @@ class TopologyRegistry: relation_links: list[tuple[str, str] | None] = [] evidence: list[dict[str, Any]] = [] for relation in topology_delta.relations: + source_kind = relation.source_kind or relation.kind + result_kind = relation.result_kind or relation.kind sources = [ record for record in previous - if record.kind == relation.kind and cls._same_topology_value(record.value, relation.source_value) + if record.kind == source_kind and cls._same_topology_value(record.value, relation.source_value) ] outputs = [ record for record in current - if record.kind == relation.kind + if record.kind == result_kind and any(cls._same_topology_value(record.value, value) for value in relation.result_values) ] item = { "event": relation.event, + # Keep the legacy shorthand for same-kind consumers while + # exposing both sides of a cross-topology builder relation. "kind": relation.kind, + "source_kind": source_kind, + "result_kind": result_kind, "source_record_ids": [record.record_id for record in sources], "result_record_ids": [record.record_id for record in outputs], "proof": "kernel_history", "derivation": relation.derivation, "coverage": relation.coverage if len(outputs) == len(relation.result_values) else "partial", - "lineage_status": relation.status, + "lineage_status": ( + relation.status if topology_delta.history_status == "proven" else "unknown" + ), } for field_name in ("source_role", "result_role", "source_slot", "result_slot"): value = getattr(relation, field_name) @@ -604,6 +979,43 @@ class TopologyRegistry: else: relation_links.append(None) evidence.append(item) + for relation in topology_delta.section_relations: + sources = [ + [ + record for record in previous + if record.kind == "face" and cls._same_topology_value(record.value, source_value) + ] + for source_value in relation.source_values + ] + outputs = [ + record for record in current + if record.kind == "edge" and cls._same_topology_value(record.value, relation.result_value) + ] + source_records = [records[0] for records in sources if len(records) == 1] + complete = ( + len(source_records) == 2 + and len({record.record_id for record in source_records}) == 2 + and not cls._same_topology_value(source_records[0].value, source_records[1].value) + 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": "face", + "result_kind": "edge", + "source_record_ids": [record.record_id for record in source_records], + "result_record_ids": [record.record_id for record in outputs], + "proof": "kernel_history", + "derivation": "intersection", + "coverage": "complete" if complete else "partial", + "lineage_status": "proven" if complete else "unknown", + "section_edge": True, + "source_qualified": True, + "status": "source_qualified_section_edge" if complete else "incomplete_source_qualified_section_edge", + }) if topology_delta.section_values: section_outputs = [ record for record in current @@ -613,12 +1025,18 @@ class TopologyRegistry: evidence.append({ "event": "generated", "kind": "edge", + "source_kind": "face", + "result_kind": "edge", "source_record_ids": [], "result_record_ids": [record.record_id for record in section_outputs], "proof": "kernel_history", "derivation": "intersection", "coverage": "complete" if len(section_outputs) == len(topology_delta.section_values) else "partial", - "lineage_status": "proven" if len(section_outputs) == len(topology_delta.section_values) else "unknown", + "lineage_status": ( + "proven" + if topology_delta.history_status == "proven" and len(section_outputs) == len(topology_delta.section_values) + else "unknown" + ), "section_edge": True, "status": "recorded_section_edge", }) @@ -652,44 +1070,214 @@ class TopologyRegistry: evidence, ) + @staticmethod + def _record_is_active(record: TopologyRecord, active_body_id: str | None) -> bool: + return not record.transient and (active_body_id is None or ( + record.body_id == active_body_id + or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) + )) + + def _operation_component( + self, + seed: TopologyLineage, + *, + output_role: str | None = None, + unqualified_output_role: bool = False, + result_connected_only: bool = False, + ) -> tuple[TopologyLineage, ...]: + """Return the connected N:M relation component for one operation. + + A builder can report A -> R and B -> R as two API callbacks. Treating + either callback in isolation would mislabel the operation as 1:1 and + let a selector bypass merge policy. Components are connected through + exact final-snapshot records, never through geometric similarity. + + A single builder may also expose unrelated cross-topology facts, or + intermediate handles that do not survive in the registered output. + Those remain diagnostics in ``TopologyDelta`` but cannot turn one + face -> face continuation into an apparent face fragment. Cardinality + is therefore measured per complete/proven source/result topology pair. + """ + same_operation = [ + edge for edge in self._lineage + if edge.feature_id == seed.feature_id and edge.operation == seed.operation + and edge.evidence == "kernel_history" + and edge.coverage == "complete" and edge.status == "proven" + and edge.source_kind == seed.source_kind and edge.result_kind == seed.result_kind + and (output_role is None or edge.output_role == output_role) + and (not unqualified_output_role or edge.output_role is None) + ] + component: list[TopologyLineage] = [] + record_ids = set(seed.result_record_ids) if result_connected_only else ( + set(seed.source_record_ids) | set(seed.result_record_ids) + ) + pending = True + while pending: + pending = False + for edge in same_operation: + edge_ids = set(edge.result_record_ids) if result_connected_only else ( + set(edge.source_record_ids) | set(edge.result_record_ids) + ) + if edge not in component and record_ids.intersection(edge_ids): + component.append(edge) + before = len(record_ids) + record_ids.update(edge_ids) + pending = pending or len(record_ids) != before + return tuple(component) + + def _operation_cardinality_error( + self, + edge: TopologyLineage, + *, + allowed: set[str], + source_record_id: str, + output_role: str | None = None, + unqualified_output_role: bool = False, + result_connected_only: bool = False, + ) -> RuntimeDiagnostic | None: + """Reject relation components whose N:M semantics the selector disallows. + + Builder APIs commonly surface a merge as separate callbacks. This + applies the same component-level policy whether a selector starts at a + stable source or directly names an output role. + """ + component = self._operation_component( + edge, + output_role=output_role, + unqualified_output_role=unqualified_output_role, + result_connected_only=result_connected_only, + ) + component_sources = set().union(*(set(item.source_record_ids) for item in component)) + component_results = set().union(*(set(item.result_record_ids) for item in component)) + required = set() + if len(component_sources) > 1: + required.add("merge") + if len(component_results) > 1: + required.add("fragment") + if required.issubset(allowed): + return None + return RuntimeDiagnostic( + "selector_relation_non_unique", + "The topology operation's complete cardinality is not allowed by this selector policy", + detail={ + "source_record_id": source_record_id, + "operation": edge.operation, + "derivation": edge.derivation, + "required": sorted(required), + "allowed": sorted(allowed), + "source_count": len(component_sources), + "result_count": len(component_results), + }, + ) + def _intent_lineage_successors( self, source_record_id: str, *, allowed: set[str], active_body_id: str | None, - ) -> list[TopologyRecord]: - """Follow only complete, proven CDSL lineage edges to active records.""" + initial_output_role: str | None = None, + target_kind: str | None = None, + ) -> tuple[list[TopologyRecord], list[TopologyLineage], RuntimeDiagnostic | None]: + """Traverse complete kernel lineage and enforce operation cardinality. + + ``all_fragments`` is deliberately stricter than collecting whatever + active descendants happen to be registered: every branch reached from + the source must have complete, proven evidence. + """ pending = [source_record_id] visited = {source_record_id} result_ids: set[str] = set() + used: list[TopologyLineage] = [] while pending: - current = pending.pop() - for edge in self._lineage: - if ( - current not in edge.source_record_ids - or edge.derivation not in allowed - or edge.evidence != "kernel_history" - or edge.coverage != "complete" - or edge.status != "proven" - ): - continue + current = pending.pop(0) + outgoing = [ + edge for edge in self._lineage + if current in edge.source_record_ids + and (target_kind is None or edge.result_kind == target_kind) + ] + role_scope = initial_output_role if current == source_record_id else None + unqualified_role_scope = current == source_record_id and role_scope is None + if role_scope is not None: + outgoing = [edge for edge in outgoing if edge.output_role == role_scope] + elif unqualified_role_scope: + # A direct prism exposes distinct cap-edge mappings through + # explicit output roles. They are not fragments of the + # unqualified edge -> side-face SWEPT relation. + outgoing = [edge for edge in outgoing if edge.output_role is None] + # An adapter can report intermediate builder handles which are not + # members of the final snapshot. They carry ``partial`` evidence + # with no record id and are retained in the delta for diagnosis, + # but cannot be a reachable lineage branch. In contrast, a + # partial relation that does bind a final record, or a complete + # deletion, remains visible below and blocks resolution. + outgoing = [ + edge for edge in outgoing + if edge.result_record_ids or edge.coverage == "complete" + ] + if not outgoing: + if role_scope is not None: + return [], used, RuntimeDiagnostic( + "selector_kernel_history_missing", + "No complete builder relation proves the requested source-profile output role", + detail={ + "source_record_id": current, + "output_role": role_scope, + "allowed": sorted(allowed), + }, + ) + continue + for edge in outgoing: + if edge.evidence != "kernel_history" or edge.coverage != "complete" or edge.status != "proven": + return [], used, RuntimeDiagnostic( + "selector_kernel_history_missing", + "Topology lineage is incomplete or unavailable for a reachable source branch", + detail={"source_record_id": current, "operation": edge.operation, "coverage": edge.coverage, "status": edge.status}, + ) + cardinality_error = self._operation_cardinality_error( + edge, + allowed=allowed, + source_record_id=current, + output_role=role_scope, + unqualified_output_role=unqualified_role_scope, + ) + if edge.derivation not in allowed or cardinality_error is not None: + detail = cardinality_error.detail if cardinality_error is not None else { + "source_record_id": current, + "operation": edge.operation, + "derivation": edge.derivation, + "required": [], + "allowed": sorted(allowed), + } + return [], used, RuntimeDiagnostic( + "selector_relation_non_unique", + "The topology operation's complete cardinality is not allowed by this selector policy", + detail=detail, + ) + if edge not in used: + used.append(edge) for record_id in edge.result_record_ids: + result_ids.add(record_id) if record_id not in visited: visited.add(record_id) pending.append(record_id) - result_ids.add(record_id) - active: list[TopologyRecord] = [] - for record in self._records: - if record.record_id not in result_ids: - continue - if active_body_id is not None and not ( - record.body_id == active_body_id - or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) - ): - continue - active.append(record) - return active + if not used: + return [], used, RuntimeDiagnostic( + "selector_kernel_history_missing", + "No complete proven lineage starts at the selector source", + detail={"source_record_id": source_record_id, "allowed": sorted(allowed)}, + ) + active = [ + record for record in self._records + if record.record_id in result_ids and self._record_is_active(record, active_body_id) + ] + if not active: + return [], used, RuntimeDiagnostic( + "selector_body_member_inactive", + "The proven selector lineage does not reach an active body member", + detail={"source_record_id": source_record_id, "active_body_id": active_body_id}, + ) + return active, used, None @staticmethod def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: @@ -827,14 +1415,37 @@ class TopologyRegistry: error = _length(tuple(a - b for a, b in zip(left, right))) return max(0.0, 1.0 - error / tolerance) + @classmethod + def _undirected_vector_score(cls, expected: Any, actual: Any, tolerance: float = 1e-4) -> tuple[float | None, bool]: + """Score a geometric axis/plane normal independent of orientation.""" + try: + left = _vector3(expected, field_name="selector geometry") + right = _vector3(actual, field_name="record geometry") + except ValueError: + return None, False + direct = _length(tuple(a - b for a, b in zip(left, right))) + reversed_error = _length(tuple(a + b for a, b in zip(left, right))) + reversed_direction = reversed_error < direct + return max(0.0, 1.0 - min(direct, reversed_error) / tolerance), reversed_direction + @classmethod def _geometry_score(cls, selector_geometry: dict[str, Any], record_geometry: dict[str, Any]) -> float | None: if not selector_geometry: return 0.0 scores: list[float] = [] - for key in ("center_mm", "circle_center_mm", "normal", "origin_mm", "direction", "plane_normal", "start_mm", "end_mm"): + reversed_plane_normal = False + for key in ("center_mm", "circle_center_mm", "normal", "origin_mm", "direction", "plane_normal", "start_mm", "end_mm", "axis_origin_mm", "axis_direction"): if key in selector_geometry: - score = cls._vector_score(selector_geometry[key], record_geometry.get(key)) + # A cap source identifies a geometric plane, not OCC's face + # orientation. When plane offset is present, prefer the + # adapter's canonical plane normal for a consistent equation. + actual = record_geometry.get("plane_normal") if key == "normal" and "plane_offset_mm" in selector_geometry else record_geometry.get(key) + if key in {"normal", "plane_normal", "axis_direction", "direction"}: + score, reversed_direction = cls._undirected_vector_score(selector_geometry[key], actual) + if key == "normal" and "plane_offset_mm" in selector_geometry: + reversed_plane_normal = reversed_direction + else: + score = cls._vector_score(selector_geometry[key], actual) if score is None: return None scores.append(score) @@ -858,7 +1469,10 @@ class TopologyRegistry: scores.append(max(0.0, 1.0 - delta / 1e-4)) if "plane_offset_mm" in selector_geometry: try: - delta = abs(float(selector_geometry["plane_offset_mm"]) - float(record_geometry.get("plane_offset_mm"))) + actual_offset = float(record_geometry.get("plane_offset_mm")) + if reversed_plane_normal: + actual_offset = -actual_offset + delta = abs(float(selector_geometry["plane_offset_mm"]) - actual_offset) except (TypeError, ValueError): return None scores.append(max(0.0, 1.0 - delta / 1e-4)) @@ -876,8 +1490,299 @@ class TopologyRegistry: return None relative_delta = abs(expected_area - actual_area) / max(abs(expected_area), 1e-9) scores.append(max(0.0, 1.0 - relative_delta / 1e-4)) + if "minimum_area_mm2" in selector_geometry: + try: + if float(record_geometry.get("area_mm2")) + 1e-6 < float(selector_geometry["minimum_area_mm2"]): + return None + except (TypeError, ValueError): + return None return sum(scores) / len(scores) if scores else 0.0 + @staticmethod + def _dot(left: list[float], right: list[float]) -> float: + return sum(float(a) * float(b) for a, b in zip(left, right)) + + @classmethod + def _source_circle_candidates( + cls, + geometry: dict[str, Any], + candidates: Iterable[TopologyRecord], + ) -> list[TopologyRecord]: + """Resolve CADFS' explicit source-circle geometry without a heuristic. + + This is a declared geometric selector for ordinary CDSL, not a + FeatureScript provenance fallback. The same implementation is used + by live runtime and prefix-binding report rehydration. + """ + center = geometry.get("source_circle_center_mm") + normal = geometry.get("source_plane_normal") + try: + radius = float(geometry.get("source_circle_radius_mm") or 0) + except (TypeError, ValueError): + return [] + if not isinstance(center, list) or not isinstance(normal, list) or radius <= 0: + return [] + plane_offset = cls._dot(center, normal) + matches = [] + for record in candidates: + record_geometry = record.geometry + if record.kind != "edge" or record_geometry.get("curve_type") != "circle": + continue + points = [record_geometry.get("start_mm"), record_geometry.get("end_mm")] + if not all(isinstance(point, list) and len(point) == 3 for point in points): + continue + if any(abs(cls._dot(point, normal) - plane_offset) > 0.05 for point in points): + continue + radial = [] + for point in points: + delta = [float(point[index]) - float(center[index]) for index in range(3)] + axial = cls._dot(delta, normal) + radial.append(sqrt(max(0.0, sum(value * value for value in delta) - axial * axial))) + if all(abs(value - radius) <= max(0.05, radius * 1e-4) for value in radial): + matches.append(record) + return matches + + def _intersection_source_face( + self, + source: dict[str, Any], + ) -> tuple[TopologyRecord | None, list[TopologyLineage], RuntimeDiagnostic | None]: + """Resolve one historical face anchor for a qualified section query. + + This deliberately resolves the *input snapshot* of a boolean, rather + than whichever descendant happens to be active now. CAP faces are + identified by their exact builder role; direct-prism side faces start + at one source-profile edge anchor. Both paths require the runtime + facts registered when the input body was built. + """ + family = source.get("query_family") + owner = source.get("owner_feature_id") + if not isinstance(owner, str) or not owner: + return None, [], RuntimeDiagnostic( + "selector_source_unavailable", + "An INTERSECT source face has no owning feature", + ) + if family == "CAP_FACE": + role = source.get("output_role") + if role not in {"extrude.start", "extrude.end"}: + return None, [], RuntimeDiagnostic( + "selector_source_unavailable", + "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. + role_facts = [ + relation + for delta in self._topology_deltas + if delta.get("feature_id") == owner + 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" + ] + if len(role_facts) != 1: + return None, [], RuntimeDiagnostic( + "selector_kernel_history_missing", + "The CAP_FACE input has no unique complete builder-role fact", + detail={"owner_feature_id": owner, "output_role": role, "fact_count": len(role_facts)}, + ) + return candidates[0], [], None + if family == "SWEPT_FACE": + source_entity = source.get("source_entity") + if not isinstance(source_entity, dict): + return None, [], RuntimeDiagnostic( + "selector_source_unavailable", + "A SWEPT_FACE INTERSECT source requires one source-profile edge", + detail={"owner_feature_id": owner}, + ) + sketch_id, entity_id = source_entity.get("sketch_id"), source_entity.get("entity_id") + if not isinstance(sketch_id, str) or not sketch_id or not isinstance(entity_id, str) or not entity_id: + return None, [], RuntimeDiagnostic( + "selector_source_unavailable", + "The SWEPT_FACE source-profile edge is incomplete", + detail={"owner_feature_id": owner}, + ) + anchors = [ + record for record in self._records + if record.kind == "edge" and record.source_entity == (sketch_id, entity_id) + ] + if len(anchors) != 1: + return None, [], RuntimeDiagnostic( + "selector_source_unavailable" if not anchors else "selector_ambiguous", + "The SWEPT_FACE source-profile edge is unavailable or non-unique", + detail={"owner_feature_id": owner, "candidate_count": len(anchors)}, + ) + relations = [ + edge for edge in self._lineage + if edge.feature_id == owner + and edge.operation == "extrude" + and edge.derivation == "boundary" + and edge.coverage == "complete" + and edge.status == "proven" + and edge.source_kind == "edge" + and edge.result_kind == "face" + and edge.source_record_ids == (anchors[0].record_id,) + and len(edge.result_record_ids) == 1 + ] + result_ids = {record_id for edge in relations for record_id in edge.result_record_ids} + candidates = [ + record for record in self._records + if record.record_id in result_ids + and record.kind == "face" + and (owner in record.owners or record.feature_id == owner) + ] + if len(relations) != 1 or len(candidates) != 1: + return None, relations, RuntimeDiagnostic( + "selector_kernel_history_missing" if not candidates else "selector_relation_non_unique", + "The SWEPT_FACE input has no unique complete direct-prism relation", + detail={"owner_feature_id": owner, "relation_count": len(relations), "candidate_count": len(candidates)}, + ) + return candidates[0], relations, None + return None, [], RuntimeDiagnostic( + "selector_query_unsupported", + "INTERSECT currently supports only CAP_FACE and direct-prism SWEPT_FACE inputs", + detail={"query_family": family}, + ) + + def _resolve_intersection_intent( + self, + selector: dict[str, Any], + intent: dict[str, Any], + *, + allowed: set[str], + multiplicity: str, + active_body_id: str | None, + ) -> SelectorResolution: + """Resolve one two-face source-qualified boolean section edge.""" + if selector.get("kind") != "edge" or allowed != {"intersection"} or multiplicity != "one": + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic( + "selector_query_unsupported", + "INTERSECT requires exactly the intersection/one derivation policy", + detail={"kind": selector.get("kind"), "allowed": sorted(allowed), "multiplicity": multiplicity}, + ), + ) + sources = intent.get("intersection_sources") + if not isinstance(sources, list) or len(sources) != 2 or not all(isinstance(source, dict) for source in sources): + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic( + "selector_source_unavailable", + "INTERSECT requires exactly two explicit source face anchors", + ), + ) + source_records: list[TopologyRecord] = [] + source_relations: list[TopologyLineage] = [] + for source in sources: + record, relations, error = self._intersection_source_face(source) + if error is not None or record is None: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=error or RuntimeDiagnostic("selector_source_unavailable", "INTERSECT source face is unavailable"), + ) + source_records.append(record) + source_relations.extend(relations) + if self._same_topology_value(source_records[0].value, source_records[1].value): + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic( + "selector_source_unavailable", + "INTERSECT source anchors must resolve to two distinct faces", + detail={"source_record_ids": [record.record_id for record in source_records]}, + ), + ) + relations = [] + for edge in self._lineage: + if ( + edge.derivation != "intersection" + or edge.coverage != "complete" + or edge.status != "proven" + or edge.source_kind != "face" + or edge.result_kind != "edge" + or len(edge.source_record_ids) != 2 + or len(edge.result_record_ids) != 1 + ): + continue + relation_sources = [ + record for record in self._records if record.record_id in edge.source_record_ids and record.kind == "face" + ] + if len(relation_sources) != 2: + continue + # Boolean aggregation may give an unchanged input member a new + # snapshot record ID. Exact IsSame, not role copying or geometry, + # connects the historical source anchor to that input record. + matches = [ + [ + relation_source for relation_source in relation_sources + if self._same_topology_value(anchor.value, relation_source.value) + ] + for anchor in source_records + ] + if ( + len(matches[0]) == 1 + and len(matches[1]) == 1 + and matches[0][0].record_id != matches[1][0].record_id + ): + relations.append(edge) + if len(relations) != 1: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic( + "selector_kernel_history_missing" if not relations else "selector_relation_non_unique", + "No unique complete source-qualified section edge matches the two source faces", + detail={"source_record_ids": [record.record_id for record in source_records], "relation_count": len(relations)}, + ), + ) + result = next(( + record for record in self._records + if record.record_id == relations[0].result_record_ids[0] and record.kind == "edge" + ), None) + if result is None: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic("selector_kernel_history_missing", "The section edge is absent from the result snapshot"), + ) + if not self._record_is_active(result, active_body_id): + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic( + "selector_body_member_inactive", + "The source-qualified section edge is not in the active body member", + detail={"record_id": result.record_id, "active_body_id": active_body_id}, + ), + ) + return SelectorResolution( + selector=selector, + status="resolved", + record=result, + candidates=({"score": 1.0, **result.public_dict()},), + resolution_mode="kernel_intersection", + evidence={ + "source_query": intent.get("source_query"), + "semantic_anchor": {"type": "source_qualified_section", "sources": sources}, + "source_records": [record.record_id for record in source_records], + "result_records": [result.record_id], + "relations": [edge.as_dict() for edge in [*source_relations, relations[0]]], + "body_member": result.body_id, + "policy": intent.get("derivation_policy"), + }, + ) + def resolve( self, selector: dict[str, Any], @@ -888,8 +1793,100 @@ class TopologyRegistry: kind = selector.get("kind") owner = selector.get("owner_feature_id") intent = selector.get("selector_intent") - provenance_intent = isinstance(intent, dict) and intent.get("query_family") != "GEOMETRIC" - candidates = [record for record in self._records if record.kind == kind] + provenance_intent = selector_has_provenance_intent(selector) + + def unresolved(code: str, message: str, *, detail: dict[str, Any] | None = None, candidates: tuple[dict[str, Any], ...] = ()) -> SelectorResolution: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=candidates, + diagnostic=RuntimeDiagnostic(code, message, detail=detail or {}), + ) + + if provenance_intent: + validation_error = validate_selector_provenance_intent(selector) + if validation_error is not None: + return unresolved( + validation_error.code, + validation_error.message, + detail=validation_error.detail, + ) + allowed, multiplicity = selector_intent_policy(intent) + else: + allowed, multiplicity = set(), "one" + + 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") == "SWEPT_BODY": + mixed_evidence = any( + selector.get(key) is not None + for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id", "output_role") + ) + if ( + kind != "body" + or not owner + or selector.get("source") != "runtime_snapshot" + or intent.get("evidence") != "active_body_member" + or intent.get("body_member_contract") != "direct_new_body" + or allowed != {"boundary"} + or multiplicity != "one" + or mixed_evidence + ): + return unresolved( + "selector_source_unavailable", + "The SWEPT_BODY selector does not satisfy the direct active-member contract", + detail={"query_family": "SWEPT_BODY", "owner_feature_id": owner}, + ) + owner_records = [ + record for record in self._records + if record.kind == "body" + and not record.transient + and record.feature_id == owner + and owner in record.owners + ] + active_records = [ + record for record in owner_records + if active_body_id is not None and record.body_id == active_body_id + ] + public_candidates = tuple( + {"score": 1.0, **record.public_dict()} for record in active_records + ) + if len(active_records) == 1: + record = active_records[0] + return SelectorResolution( + selector=selector, + status="resolved", + record=record, + candidates=public_candidates, + resolution_mode="body_member", + evidence={ + "source_query": intent.get("source_query"), + "semantic_anchor": {"type": "direct_body_member", "owner_feature_id": owner}, + "source_records": [record.record_id], + "result_records": [record.record_id], + "relations": [], + "body_member": record.body_id, + "policy": intent.get("derivation_policy"), + }, + ) + if len(active_records) > 1: + return unresolved( + "selector_ambiguous", + "More than one active body member satisfies the SWEPT_BODY contract", + detail={"owner_feature_id": owner, "candidate_count": len(active_records)}, + candidates=public_candidates, + ) + return unresolved( + "selector_body_member_inactive" if owner_records else "selector_source_unavailable", + "The direct SWEPT_BODY member is not the active body" if owner_records else "The SWEPT_BODY producer has no body member record", + detail={"owner_feature_id": owner, "active_body_id": active_body_id}, + ) + + candidates = [record for record in self._records if record.kind == kind and not record.transient] if active_body_id and kind in {"face", "edge", "vertex", "body"}: # #7 multi-body:记录 body_id 可能是 body:{feature}:{index}(多体 # 成员),用前缀匹配把整个主体的记录纳入候选,同时保证旧 body 的 @@ -899,7 +1896,8 @@ class TopologyRegistry: if record.body_id == active_body_id or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) ] - if owner: + output_role = str(selector.get("output_role") or "").strip() + if owner and not output_role: candidates = [record for record in candidates if owner in record.owners] if kind == "plane" and selector.get("frame") is not None: # #6 pattern 引用重解析:pattern 重放 source(pattern_mirror)时, @@ -923,6 +1921,8 @@ class TopologyRegistry: status="resolved", record=record, candidates=({"score": 1.0, **record.public_dict()},), + resolution_mode="explicit_datum", + evidence={"source_records": [], "result_records": [record.record_id], "facts": ["inline_plane_frame"]}, ) return SelectorResolution( selector=selector, @@ -935,76 +1935,64 @@ class TopologyRegistry: ), ) geometry = normalize_selector_geometry(selector.get("geometry")) + source_circle_candidates = self._source_circle_candidates(geometry, candidates) + if source_circle_candidates: + candidates = source_circle_candidates + # The source-circle fields themselves were just checked exactly + # enough for this explicit geometric contract. They are not part + # of the generic score schema. + geometry = {} if selector.get("snapshot_id") and not owner: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_owner_required", - message="A snapshot selector requires owner_feature_id", - detail={"minimum_score": minimum_score}, - ), + return unresolved( + "selector_owner_required", + "A snapshot selector requires owner_feature_id", + detail={"minimum_score": minimum_score}, ) - output_role = str(selector.get("output_role") or "").strip() if output_role: if not owner: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_owner_required", - message="A feature output role selector requires owner_feature_id", - detail={"output_role": output_role}, - ), + return unresolved( + "selector_output_role_owner_required", + "A feature output role selector requires owner_feature_id", + detail={"output_role": output_role}, ) if active_body_id is None: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_active_body_required", - message="A feature output role selector requires an active body snapshot", - detail={"output_role": output_role}, - ), + return unresolved( + "selector_output_role_active_body_required", + "A feature output role selector requires an active body snapshot", + detail={"output_role": output_role}, ) if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")): - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_mixed_evidence", - message="A feature output role selector cannot mix stable or geometry evidence", - detail={"output_role": output_role}, - ), + return unresolved( + "selector_output_role_mixed_evidence", + "A feature output role selector cannot mix stable or geometry evidence", + detail={"output_role": output_role}, ) - role_candidates = [record for record in candidates if output_role in record.output_roles] + # Direct output roles are written to the relation evidence before + # the result snapshot is registered. The record's retained role + # is a convenient cache, not the sole proof source. + role_record_ids = { + record_id + for edge in self._lineage + if edge.output_role == output_role + for record_id in edge.result_record_ids + } + role_candidates = [ + record for record in candidates + if output_role in record.output_roles or record.record_id in role_record_ids + ] role_source = selector.get("output_role_source") if role_source is not None: source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None source_role = role_source.get("output_role") if isinstance(role_source, dict) else None if not isinstance(source_owner, str) or not isinstance(source_role, str): - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_source_invalid", - message="An output role selector source requires owner_feature_id and output_role", - ), + return unresolved( + "selector_output_role_source_invalid", + "An output role selector source requires owner_feature_id and output_role", ) if output_role != "shell.offset_face" or source_role not in {"extrude.start", "extrude.end"}: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_source_unsupported", - message="Output role sources are currently supported only for shell.offset_face from an extrusion cap", - ), + return unresolved( + "selector_output_role_source_unsupported", + "Output role sources are currently supported only for shell.offset_face from an extrusion cap", ) role_candidates = [ record for record in role_candidates @@ -1013,12 +2001,80 @@ class TopologyRegistry: public_candidates = tuple( {"score": 1.0, **record.public_dict()} for record in role_candidates ) + if provenance_intent: + if multiplicity == "source_qualified" and role_source is None and not intent.get("disambiguation"): + return unresolved( + "selector_source_unavailable", + "A source-qualified output role selector has no source qualification", + detail={"output_role": output_role}, + candidates=public_candidates, + ) + role_edges = [edge for edge in self._lineage if edge.output_role == output_role] + proven_candidates: list[tuple[TopologyRecord, list[TopologyLineage]]] = [] + cardinality_errors: list[RuntimeDiagnostic] = [] + for candidate in role_candidates: + for edge in role_edges: + if edge.coverage != "complete" or edge.status != "proven": + continue + if edge.derivation not in allowed: + continue + cardinality_error = self._operation_cardinality_error( + edge, + allowed=allowed, + source_record_id=candidate.record_id, + result_connected_only=True, + ) + if cardinality_error is not None: + cardinality_errors.append(cardinality_error) + continue + if candidate.record_id in edge.result_record_ids: + proven_candidates.append((candidate, [edge])) + break + for source_record_id in edge.result_record_ids: + descendants, relations, error = self._intent_lineage_successors( + source_record_id, allowed=allowed, active_body_id=active_body_id, + target_kind=candidate.kind, + ) + if error is None and any(item.record_id == candidate.record_id for item in descendants): + proven_candidates.append((candidate, [edge, *relations])) + break + else: + continue + break + if not proven_candidates: + if cardinality_errors: + error = cardinality_errors[0] + return unresolved( + error.code, + error.message, + detail=error.detail, + candidates=public_candidates, + ) + return unresolved( + "selector_kernel_history_missing", + "No complete builder relation proves the requested active output role", + detail={"output_role": output_role, "allowed": sorted(allowed)}, + candidates=public_candidates, + ) + role_candidates = [candidate for candidate, _relations in proven_candidates] + public_candidates = tuple({"score": 1.0, **record.public_dict()} for record in role_candidates) if len(role_candidates) == 1: + relations = proven_candidates[0][1] if provenance_intent else [] return SelectorResolution( selector=selector, status="resolved", record=role_candidates[0], candidates=public_candidates, + resolution_mode="operation_role", + evidence={ + "source_query": intent.get("source_query") if provenance_intent else None, + "semantic_anchor": {"type": "output_role", "owner_feature_id": owner, "output_role": output_role}, + "source_records": [], + "result_records": [role_candidates[0].record_id], + "relations": [edge.as_dict() for edge in relations], + "body_member": role_candidates[0].body_id, + "policy": intent.get("derivation_policy") if provenance_intent else None, + }, ) if len(role_candidates) > 1: return SelectorResolution( @@ -1041,6 +2097,134 @@ 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"}: + source_entity = intent.get("source_entity") + source_entities = intent.get("source_entities") + source_kind: str | None = None + semantic_anchor: dict[str, Any] | None = None + anchor_records: list[TopologyRecord] = [] + if isinstance(source_entity, dict): + sketch_id, entity_id = source_entity.get("sketch_id"), source_entity.get("entity_id") + if isinstance(sketch_id, str) and sketch_id and isinstance(entity_id, str) and entity_id: + source_kind = "edge" + 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) + ] + elif isinstance(source_entities, list): + pairs = tuple(sorted( + (str(source.get("sketch_id")), str(source.get("entity_id"))) + for source in source_entities + if isinstance(source, dict) + and isinstance(source.get("sketch_id"), str) + and isinstance(source.get("entity_id"), str) + )) + if len(pairs) == len(source_entities) and len(pairs) >= 2 and len(set(pairs)) == len(pairs): + source_kind = "vertex" + semantic_anchor = { + "type": "source_vertex", + "source_entities": [ + {"sketch_id": sketch_id, "entity_id": entity_id} + for sketch_id, entity_id in pairs + ], + } + anchor_records = [ + record for record in self._records + if record.kind == source_kind and record.source_entities == pairs + ] + if source_kind is None: + return unresolved( + "selector_source_unavailable", + "The provenance selector has no valid source-profile semantic anchor", + 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"}: + 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}, + ) + else: + lineage_role = None + public_anchors = tuple({"score": 1.0, **record.public_dict()} for record in anchor_records) + if 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", + detail={ + "query_family": intent.get("query_family"), + "semantic_anchor": semantic_anchor, + "candidate_count": len(anchor_records), + }, + 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, + ) + if multiplicity == "all_fragments": + return SelectorResolution( + selector=selector, + status="resolved", + records=tuple(successors), + candidates=public_successors, + resolution_mode="kernel_lineage", + evidence={ + "source_query": intent.get("source_query"), + "semantic_anchor": semantic_anchor, + "source_records": [anchor_records[0].record_id], + "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], + "policy": intent.get("derivation_policy"), + }, + ) + if len(successors) != 1: + return SelectorResolution( + selector=selector, + status="ambiguous" if successors else "not_found", + candidates=public_successors, + diagnostic=RuntimeDiagnostic( + "selector_relation_non_unique" if successors else "selector_body_member_inactive", + "The complete proven source-profile lineage does not yield one active result", + detail={ + "semantic_anchor": semantic_anchor, + "candidate_count": len(successors), + "multiplicity": multiplicity, + }, + ), + ) + return SelectorResolution( + selector=selector, + status="resolved", + record=successors[0], + candidates=public_successors, + resolution_mode="kernel_lineage", + evidence={ + "source_query": intent.get("source_query"), + "semantic_anchor": semantic_anchor, + "source_records": [anchor_records[0].record_id], + "result_records": [successors[0].record_id], + "relations": [edge.as_dict() for edge in relations], + "body_member": successors[0].body_id, + "policy": intent.get("derivation_policy"), + }, + ) stable_id = str(selector.get("stable_id") or "").strip() if stable_id: # #8 selector 持久性:stable_id 是跨 body 演化的持久标识符,精确 @@ -1050,11 +2234,66 @@ class TopologyRegistry: # 几何打分流程。 stable_records = [ record for record in self._records - if record.kind == kind and (not owner or owner in record.owners) + if record.kind == kind and not record.transient and (not owner or owner in record.owners) ] exact = [record for record in stable_records if record.record_id == stable_id] if len(exact) == 1: record = exact[0] + if provenance_intent: + successors, relations, error = self._intent_lineage_successors( + record.record_id, allowed=allowed, active_body_id=active_body_id, + target_kind=kind, + ) + public_successors = tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors) + if error is not None: + return unresolved( + error.code, + error.message, + detail={**error.detail, "stable_id": stable_id}, + candidates=public_successors, + ) + if multiplicity == "all_fragments": + return SelectorResolution( + selector=selector, + status="resolved", + records=tuple(successors), + candidates=public_successors, + resolution_mode="kernel_lineage", + evidence={ + "source_query": intent.get("source_query"), + "source_records": [record.record_id], + "result_records": [candidate.record_id for candidate in successors], + "relations": [edge.as_dict() for edge in relations], + "body_members": [candidate.body_id for candidate in successors], + "policy": intent.get("derivation_policy"), + }, + ) + if len(successors) != 1: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=public_successors, + diagnostic=RuntimeDiagnostic( + code="selector_relation_non_unique", + message="The complete proven topology lineage does not yield one active result", + detail={"stable_id": stable_id, "candidate_count": len(successors), "multiplicity": multiplicity}, + ), + ) + return SelectorResolution( + selector=selector, + status="resolved", + record=successors[0], + candidates=public_successors, + resolution_mode="kernel_lineage", + evidence={ + "source_query": intent.get("source_query"), + "source_records": [record.record_id], + "result_records": [successors[0].record_id], + "relations": [edge.as_dict() for edge in relations], + "body_member": successors[0].body_id, + "policy": intent.get("derivation_policy"), + }, + ) is_active = active_body_id is None or ( record.body_id == active_body_id or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) @@ -1069,6 +2308,7 @@ class TopologyRegistry: record.record_id, allowed=allowed, active_body_id=active_body_id, + target_kind=kind, ) if len(successors) == 1: record = successors[0] @@ -1147,6 +2387,8 @@ class TopologyRegistry: status="resolved", record=record, candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},), + resolution_mode="runtime_snapshot", + evidence={"source_records": [], "result_records": [record.record_id], "facts": ["runtime_stable_id"]}, ) if not geometry: return SelectorResolution( @@ -1182,22 +2424,10 @@ class TopologyRegistry: ), ) if provenance_intent: - evidence = intent.get("evidence") - code = "selector_geometry_only" if evidence == "geometry_hint" else "selector_kernel_history_missing" - message = ( - "A FeatureScript provenance selector cannot resolve from geometry alone" - if code == "selector_geometry_only" - else "The FeatureScript selector has no stable active record or complete kernel history" - ) - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code=code, - message=message, - detail={"query_family": intent.get("query_family")}, - ), + return unresolved( + "selector_source_unavailable", + "The provenance selector has no runtime-resolvable semantic source anchor", + detail={"query_family": intent.get("query_family"), "anchors": [key for key in ("output_role", "source_entity") if intent.get(key)]}, ) scored: list[tuple[float, TopologyRecord]] = [] for candidate in candidates: @@ -1220,6 +2450,16 @@ class TopologyRegistry: detail={"candidate_count": len(scored), "minimum_score": minimum_score}, ), ) + if selector.get("match_mode") == "all": + matches = tuple(record for score, record in scored if score >= minimum_score) + return SelectorResolution( + selector=selector, + status="resolved", + records=matches, + candidates=public_candidates, + resolution_mode="geometry", + evidence={"source_records": [], "result_records": [record.record_id for record in matches], "facts": ["explicit_geometry"]}, + ) best_score, best_record = scored[0] if len(scored) > 1 and abs(scored[1][0] - best_score) <= 1e-9: return SelectorResolution( @@ -1232,4 +2472,11 @@ class TopologyRegistry: detail={"best_score": best_score, "candidate_count": len(scored)}, ), ) - return SelectorResolution(selector=selector, status="resolved", record=best_record, candidates=public_candidates) + return SelectorResolution( + selector=selector, + status="resolved", + record=best_record, + candidates=public_candidates, + resolution_mode="geometry", + evidence={"source_records": [], "result_records": [best_record.record_id], "facts": ["explicit_geometry"]}, + ) diff --git a/backend/tests/test_engine_runtime_foundation.py b/backend/tests/test_engine_runtime_foundation.py index e9f4bb51..841869ed 100644 --- a/backend/tests/test_engine_runtime_foundation.py +++ b/backend/tests/test_engine_runtime_foundation.py @@ -395,6 +395,23 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertAlmostEqual(edge.tangent_at(0).Y, 1.0, places=6) self.assertAlmostEqual(edge.tangent_at(1).X, -1.0, places=6) + def test_bspline_interpolation_tangent_uses_the_runtime_parameter_domain(self) -> None: + from cdsl_engine.build123d_adapter import interpolated_bspline_point_and_tangent + + points = [(0.0, -23.11, 0.0), (0.0, -5.33, 0.0), (26.7, 9.39, 0.0)] + parameters = [0.0, 0.43299474427180723, 1.0] + point, tangent = interpolated_bspline_point_and_tangent( + points, + start_tangent=(0.0, 64.52, 0.0), + end_tangent=(141.23, -13.99, 0.0), + parameters=parameters, + interpolation_index=1, + ) + for actual, expected in zip(point, points[1]): + self.assertAlmostEqual(actual, expected, places=12) + self.assertAlmostEqual(tangent[0], 0.008342033594358346, places=10) + self.assertAlmostEqual(tangent[1], 36.52292778091852, places=10) + def test_two_point_bspline_profile_requires_and_preserves_endpoint_tangents(self) -> None: from cdsl_engine.build123d_adapter import Build123dGeometryAdapter @@ -930,51 +947,131 @@ class EngineRuntimeFoundationTests(unittest.TestCase): source = object() exact_result = object() geometrically_similar = object() - registry.replace_body_topology("base", "body:base", [ - TopologyRecord("base:face", "face", "base", "body:base", {"center_mm": [0, 0, 0]}, source), - ]) + source_anchor = TopologyRecord( + "anchor:base:circle", "edge", "base", geometry={}, value=source, + source_entity=("sketch_base", "circle"), + ) + registry.register(source_anchor) registry.replace_body_topology("later", "body:later", [ TopologyRecord("later:exact", "face", "later", "body:later", {"center_mm": [10, 0, 0]}, exact_result), TopologyRecord("later:similar", "face", "later", "body:later", {"center_mm": [0, 0, 0]}, geometrically_similar), - ], topology_delta=TopologyDelta("transform", ( - TopologyDeltaRelation("modified", "face", source, (exact_result,)), - ))) + ], topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation( + "generated", "edge", source, (exact_result,), + source_kind="edge", result_kind="face", derivation="boundary", + ), + )), additional_predecessors=[source_anchor]) selector = { - "kind": "face", "owner_feature_id": "base", "stable_id": "base:face", + "kind": "face", "owner_feature_id": "base", "source": "runtime_snapshot", "confidence": 1.0, "geometry": {"center_mm": [0, 0, 0]}, "selector_intent": { "version": "1.0", "kind": "face", "query_family": "SWEPT_FACE", - "source_query": {"ast": {}, "featurescript_version": "1511"}, - "derivation_policy": {"allowed": ["continuation"], "multiplicity": "one"}, + "source_query": { + "ast": {}, "featurescript_version": "1511", + "standard_library": "onshape/std/geometry.fs", + "standard_library_version": "1511.0", + }, + "source_entity": {"sketch_id": "sketch_base", "entity_id": "circle"}, + "derivation_policy": {"allowed": ["boundary"], "multiplicity": "one"}, "evidence": "kernel_history", }, } resolution = registry.resolve(selector, active_body_id="body:later") self.assertEqual(resolution.status, "resolved") self.assertEqual(resolution.record.record_id, "later:exact") - self.assertEqual(registry.lineage()[0].derivation, "continuation") + self.assertEqual(registry.lineage()[0].derivation, "boundary") + + def test_complete_continuation_ignores_nonfinal_builder_handles(self) -> None: + """A boolean's intermediate Generated handles are not face fragments. + + OCC can return both one final ``Modified(face)`` and Generated faces + which are absent from the result snapshot for the same source face. + The latter are useful diagnostics but cannot invalidate the exact + one-to-one target continuation. + """ + registry = TopologyRegistry() + source_edge = object() + initial_face = object() + final_face = object() + intermediate_face = object() + anchor = TopologyRecord( + "anchor:base:edge", "edge", "base", geometry={}, value=source_edge, + source_entity=("sketch_base", "edge"), + ) + registry.register(anchor) + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:wall", "face", "base", "body:base", {}, initial_face), + ], topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation( + "generated", "edge", source_edge, (initial_face,), + source_kind="edge", result_kind="face", derivation="boundary", + ), + )), additional_predecessors=[anchor]) + registry.replace_body_topology("cut", "body:cut", [ + TopologyRecord("cut:wall", "face", "cut", "body:cut", {}, final_face), + ], topology_delta=TopologyDelta("subtract", ( + TopologyDeltaRelation("modified", "face", initial_face, (final_face,)), + TopologyDeltaRelation( + "generated", "face", initial_face, (intermediate_face,), + derivation="boundary", + ), + ))) + + resolution = registry.resolve({ + "kind": "face", "owner_feature_id": "base", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + "version": "1.0", "kind": "face", "query_family": "SWEPT_FACE", + "source_query": { + "ast": {}, "featurescript_version": "1511", + "standard_library": "onshape/std/geometry.fs", + "standard_library_version": "1511.0", + }, + "source_entity": {"sketch_id": "sketch_base", "entity_id": "edge"}, + "derivation_policy": {"allowed": ["boundary", "continuation"], "multiplicity": "one"}, + "evidence": "kernel_history", + }, + }, active_body_id="body:cut") + + self.assertEqual(resolution.status, "resolved") + self.assertEqual(resolution.record.record_id, "cut:wall") + cut_relations = registry.topology_deltas()[-1]["relations"] + self.assertTrue(any(item["coverage"] == "partial" for item in cut_relations)) def test_provenance_selector_rejects_non_unique_fragment(self) -> None: registry = TopologyRegistry() source = object() first = object() second = object() - registry.replace_body_topology("base", "body:base", [ - TopologyRecord("base:edge", "edge", "base", "body:base", {"center_mm": [0, 0, 0]}, source), - ]) + source_anchor = TopologyRecord( + "anchor:base:vertex", "vertex", "base", geometry={}, value=source, + source_entities=(("sketch_base", "left"), ("sketch_base", "right")), + ) + registry.register(source_anchor) registry.replace_body_topology("fillet", "body:fillet", [ TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {"center_mm": [0, 0, 0]}, first), TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {"center_mm": [1, 0, 0]}, second), ], topology_delta=TopologyDelta("fillet", ( - TopologyDeltaRelation("modified", "edge", source, (first, second)), - ))) + TopologyDeltaRelation( + "modified", "vertex", source, (first, second), + source_kind="vertex", result_kind="edge", derivation="fragment", + ), + )), additional_predecessors=[source_anchor]) resolution = registry.resolve({ - "kind": "edge", "owner_feature_id": "base", "stable_id": "base:edge", + "kind": "edge", "owner_feature_id": "base", "source": "runtime_snapshot", "confidence": 1.0, "selector_intent": { "version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE", - "source_query": {"ast": {}, "featurescript_version": "1511"}, + "source_query": { + "ast": {}, "featurescript_version": "1511", + "standard_library": "onshape/std/geometry.fs", + "standard_library_version": "1511.0", + }, + "source_entities": [ + {"sketch_id": "sketch_base", "entity_id": "left"}, + {"sketch_id": "sketch_base", "entity_id": "right"}, + ], "derivation_policy": {"allowed": ["fragment"], "multiplicity": "one"}, "evidence": "kernel_history", }, @@ -987,21 +1084,34 @@ class EngineRuntimeFoundationTests(unittest.TestCase): source = object() first = object() second = object() - registry.replace_body_topology("base", "body:base", [ - TopologyRecord("base:edge", "edge", "base", "body:base", {}, source), - ]) + source_anchor = TopologyRecord( + "anchor:base:vertex", "vertex", "base", geometry={}, value=source, + source_entities=(("sketch_base", "left"), ("sketch_base", "right")), + ) + registry.register(source_anchor) registry.replace_body_topology("fillet", "body:fillet", [ TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {}, first), TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {}, second), ], topology_delta=TopologyDelta("fillet", ( - TopologyDeltaRelation("modified", "edge", source, (first, second)), - ))) + TopologyDeltaRelation( + "modified", "vertex", source, (first, second), + source_kind="vertex", result_kind="edge", derivation="fragment", + ), + )), additional_predecessors=[source_anchor]) resolution = registry.resolve({ - "kind": "edge", "owner_feature_id": "base", "stable_id": "base:edge", + "kind": "edge", "owner_feature_id": "base", "source": "runtime_snapshot", "confidence": 1.0, "selector_intent": { "version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE", - "source_query": {"ast": {}, "featurescript_version": "1511"}, + "source_query": { + "ast": {}, "featurescript_version": "1511", + "standard_library": "onshape/std/geometry.fs", + "standard_library_version": "1511.0", + }, + "source_entities": [ + {"sketch_id": "sketch_base", "entity_id": "left"}, + {"sketch_id": "sketch_base", "entity_id": "right"}, + ], "derivation_policy": {"allowed": ["fragment"], "multiplicity": "all_fragments"}, "evidence": "kernel_history", }, @@ -1010,6 +1120,75 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertIsNone(resolution.record) self.assertEqual([record.record_id for record in resolution.records], ["fillet:first", "fillet:second"]) + def test_fillet_consumes_all_proven_fragment_records(self) -> None: + """A dress-up must consume every record allowed by all_fragments.""" + from build123d import Box + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + from cdsl_engine.runtime import ExecutionSession, FeaturePlanNode, execute_node + + class RecordingAdapter(Build123dGeometryAdapter): + def __init__(self) -> None: + self.selected_edges: list[object] = [] + + def fillet_with_topology_delta(self, body, radius_mm, edges): + self.selected_edges = list(edges) + # Selection delivery is the behavior under test. Returning the + # same valid B-rep keeps this focused on the executor contract. + return body, None + + adapter = RecordingAdapter() + body = Box(10, 10, 10) + first, second = body.edges()[:2] + source = object() + session = ExecutionSession(sketches={}, nodes={}, adapter=adapter) + session.body = body + session.body_id = "body:fragments" + session.body_members = {"fragments": body} + source_anchor = TopologyRecord( + "anchor:source:vertex", "vertex", "source", geometry={}, value=source, + source_entities=(("sketch_source", "left"), ("sketch_source", "right")), + ) + session.topology.register(source_anchor) + session.topology.replace_body_topology("fragments", "body:fragments", [ + TopologyRecord("fragments:first", "edge", "fragments", "body:fragments", value=first), + TopologyRecord("fragments:second", "edge", "fragments", "body:fragments", value=second), + ], topology_delta=TopologyDelta("split", ( + TopologyDeltaRelation( + "modified", "vertex", source, (first, second), + source_kind="vertex", result_kind="edge", derivation="fragment", + ), + )), additional_predecessors=[source_anchor]) + node = FeaturePlanNode( + "fillet", "fillet", None, ("fragments",), {"radius_mm": 0.5}, ({ + "kind": "edge", "owner_feature_id": "source", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + "version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE", + "source_query": { + "ast": {}, "featurescript_version": "1511", + "standard_library": "onshape/std/geometry.fs", + "standard_library_version": "1511.0", + }, + "source_entities": [ + {"sketch_id": "sketch_source", "entity_id": "left"}, + {"sketch_id": "sketch_source", "entity_id": "right"}, + ], + "derivation_policy": {"allowed": ["fragment"], "multiplicity": "all_fragments"}, + }, + },), None, "supported", {"id": "fillet"}, + ) + + result = execute_node(node, session) + + self.assertEqual(result.status, "executed") + self.assertEqual(len(adapter.selected_edges), 2) + self.assertTrue(adapter.selected_edges[0].is_same(first)) + self.assertTrue(adapter.selected_edges[1].is_same(second)) + self.assertEqual( + [record["record_id"] for record in session.selector_resolutions[-1]["records"]], + ["fragments:first", "fragments:second"], + ) + def test_boolean_section_edges_are_recorded_as_intersection_lineage(self) -> None: registry = TopologyRegistry() section_edge = object() @@ -1022,6 +1201,138 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(lineage["result_record_ids"], ["boolean:section"]) self.assertTrue(delta["relations"][0]["section_edge"]) + def test_boolean_builder_qualifies_section_edges_with_exact_input_faces(self) -> None: + from build123d import Location, Solid + from cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + adapter = Build123dGeometryAdapter() + target = Solid.make_box(10, 10, 10) + tool = Solid.make_box(10, 10, 10).moved(Location((5, 5, 5))) + result, delta = adapter.cut_with_topology_delta(target, tool) + + self.assertTrue(result.is_valid) + self.assertIsNotNone(delta) + self.assertTrue(delta.section_relations) + final_edges = [edge.wrapped for edge in result.edges()] + target_faces = [face.wrapped for face in target.faces()] + tool_faces = [face.wrapped for face in tool.faces()] + for relation in delta.section_relations: + self.assertTrue(any(relation.result_value.IsSame(edge) for edge in final_edges)) + self.assertTrue(any(relation.source_values[0].IsSame(face) for face in target_faces)) + self.assertTrue(any(relation.source_values[1].IsSame(face) for face in tool_faces)) + # Qualified results must not also be exposed through the unqualified + # SectionEdges-only diagnostic channel. + self.assertFalse(delta.section_values) + + def test_boolean_section_selector_executes_through_source_qualified_lineage(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + def source_rectangle(points: list[list[float]]) -> list[dict]: + return [ + {"type": "line", "start": start, "end": end, "source_entity_id": f"E{index}"} + for index, (start, end) in enumerate(zip(points, [*points[1:], points[0]])) + ] + + workplane = _workplane() + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "section-selector", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [ + { + "id": "left_sketch", "source_sketch_id": "L", "workplane": workplane, + "profile": {"type": "analytic_contours", "contours": [{ + "role": "outer", "closed": True, + "segments": source_rectangle([[0, 0], [10, 0], [10, 10], [0, 10]]), + }]}, + }, + { + "id": "right_sketch", "source_sketch_id": "R", "workplane": workplane, + "profile": {"type": "analytic_contours", "contours": [{ + "role": "outer", "closed": True, + "segments": source_rectangle([[5, -5], [15, -5], [15, 5], [5, 5]]), + }]}, + }, + ]}, + "features": [ + { + "id": "left", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "left_sketch", + "params": {"distance_mm": 4, "result_mode": "new_body"}, + }, + { + "id": "right", "atomic_id": "extrude_add_blind", "depends_on": ["left"], "sketch_id": "right_sketch", + "params": {"distance_mm": 4, "result_mode": "new_body"}, + }, + { + "id": "boolean", "atomic_id": "boolean_bodies", "depends_on": ["left", "right"], + "params": { + "operation": "subtract", "target_feature_ids": ["left"], + "tool_feature_ids": ["right"], "keep_tools": False, + }, + }, + { + "id": "fillet", "atomic_id": "fillet", "depends_on": ["boolean"], "params": {"radius_mm": 0.1}, + "selectors": [{ + "kind": "edge", "owner_feature_id": "boolean", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + "version": "1.0", "kind": "edge", "query_family": "INTERSECT", + "source_query": { + "ast": {"call": "makeQuery"}, "featurescript_version": "1511", + "standard_library": "onshape/std/geometry.fs", + "standard_library_version": "1511.0", + }, + "derivation_policy": {"allowed": ["intersection"], "multiplicity": "one"}, + "evidence": "kernel_history", + "intersection_sources": [ + { + "query_family": "SWEPT_FACE", "owner_feature_id": "left", + "source_entity": {"sketch_id": "L", "entity_id": "E0"}, + }, + { + "query_family": "SWEPT_FACE", "owner_feature_id": "right", + "source_entity": {"sketch_id": "R", "entity_id": "E3"}, + }, + ], + }, + }], + }, + ], + } + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "section-selector.step") + + self.assertEqual(result["feature_results"][-1]["feature_id"], "fillet") + section_relations = [ + relation for delta in result["topology_deltas"] if delta["feature_id"] == "boolean" + for relation in delta["relations"] if relation.get("source_qualified") + ] + self.assertTrue(section_relations) + + # A primary REMOVE owns no active tool member. It can still retain the + # same source-qualified section evidence only through its transient + # direct-prism tool snapshot. + primary = deepcopy(cdsl) + primary["part_id"] = "primary-section-selector" + primary["features"][1].update({ + "id": "cut", "atomic_id": "extrude_cut_blind", "depends_on": ["left"], + "params": {"distance_mm": 4}, + }) + primary["features"] = [primary["features"][0], primary["features"][1], primary["features"][3]] + primary["features"][2]["depends_on"] = ["cut"] + selector = primary["features"][2]["selectors"][0] + selector["owner_feature_id"] = "cut" + selector["selector_intent"]["intersection_sources"][1]["owner_feature_id"] = "cut" + with tempfile.TemporaryDirectory() as directory: + primary_result = rebuild_cdsl(primary, Path(directory) / "primary-section-selector.step") + + self.assertEqual(primary_result["feature_results"][-1]["feature_id"], "fillet") + primary_delta = next( + delta for delta in primary_result["topology_deltas"] + if delta["feature_id"] == "cut" and delta["operation"] == "subtract" + ) + self.assertEqual(set(primary_delta["input_snapshot_ids"]), {"body:left", "transient:cut"}) + self.assertTrue(any(relation.get("source_qualified") for relation in primary_delta["relations"])) + self.assertTrue(any(record.get("transient") for record in primary_result["topology_records"])) + def test_shell_offset_role_source_selects_one_exact_builder_relation(self) -> None: registry = TopologyRegistry() extrude_start = object() @@ -1177,6 +1488,124 @@ 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: + from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl + from cdsl_engine.semantic_validation import validate_semantic_cdsl + + cap_reference = { + "kind": "face", "owner_feature_id": "base_add", "output_role": "extrude.end", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + "version": "1.0", "kind": "face", "query_family": "CAP_FACE", + "source_query": { + "ast": {"call": "makeQuery"}, "featurescript_version": "1511", + "standard_library": "onshape/std/geometry.fs", + "standard_library_version": "1511.0", + }, + "derivation_policy": {"allowed": ["boundary", "continuation"], "multiplicity": "one"}, + "evidence": "operation_role", "output_role": "extrude.end", + }, + } + cdsl = self._base_block() + cdsl["features"][0]["execution_status"] = "supported" + cdsl["features"][0]["params"].update({ + "result_mode": "new_body", "end_condition": {"type": "blind", "solidworks_code": 0}, + }) + cdsl["geometry"]["sketches"].append({ + "id": "cut", "workplane": _workplane(), + "profile": {"type": "circle", "center": [0, 0], "radius_mm": 1}, + }) + cdsl["features"].append({ + "id": "cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], + "params": { + "distance_mm": 1, + "end_condition": {"type": "up_to_surface", "solidworks_code": 2, "reference": cap_reference}, + }, + "sketch_id": "cut", "execution_status": "supported", + }) + + self.assertTrue(validate_semantic_cdsl(cdsl)["future_rebuild_ready"]) + self.assertTrue(analyze_cdsl(cdsl).feature_results[-1].executable) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(cdsl, Path(directory) / "up-to-surface-cap-role.step") + resolution = next(item for item in rebuilt["selector_resolution"] if item["feature_id"] == "cut") + self.assertEqual(resolution["status"], "resolved") + self.assertEqual(resolution["resolution_mode"], "operation_role") + self.assertEqual(resolution["selected"]["output_roles"], ["extrude.end"]) + + non_new_body = deepcopy(cdsl) + non_new_body["features"][0]["params"]["result_mode"] = "fuse" + with self.assertRaisesRegex(ValueError, "direct new_body blind extrusion cap"): + validate_semantic_cdsl(non_new_body) + self.assertIn( + "unsupported_extent_output_role_selector", + [blocker.code for blocker in analyze_cdsl(non_new_body).feature_results[-1].blockers], + ) + + non_immediate = deepcopy(cdsl) + non_immediate["features"].insert(1, { + "id": "gap", "atomic_id": "reference_plane", "depends_on": ["base_add"], + "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"): + validate_semantic_cdsl(non_immediate) + self.assertIn( + "unsupported_extent_output_role_selector", + [blocker.code for blocker in analyze_cdsl(non_immediate).feature_results[-1].blockers], + ) + + def test_shell_consumes_only_an_immediate_direct_cap_output_role(self) -> None: + from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl + from cdsl_engine.semantic_validation import validate_semantic_cdsl + + cap_selector = { + "kind": "face", "owner_feature_id": "base_add", "output_role": "extrude.end", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + "version": "1.0", "kind": "face", "query_family": "CAP_FACE", + "source_query": { + "ast": {"call": "makeQuery"}, "featurescript_version": "1511", + "standard_library": "onshape/std/geometry.fs", + "standard_library_version": "1511.0", + }, + "derivation_policy": {"allowed": ["boundary", "continuation"], "multiplicity": "one"}, + "evidence": "operation_role", "output_role": "extrude.end", + }, + } + cdsl = self._base_block() + cdsl["features"][0]["execution_status"] = "supported" + cdsl["features"][0]["params"].update({ + "result_mode": "new_body", "end_condition": {"type": "blind", "solidworks_code": 0}, + }) + cdsl["features"].append({ + "id": "shell", "atomic_id": "shell", "depends_on": ["base_add"], + "params": {"thickness_mm": 1, "inward": True}, "selectors": [cap_selector], + "execution_status": "supported", + }) + + self.assertTrue(validate_semantic_cdsl(cdsl)["future_rebuild_ready"]) + self.assertTrue(analyze_cdsl(cdsl).feature_results[-1].executable) + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(cdsl, Path(directory) / "cap-role-shell.step") + resolution = next(item for item in rebuilt["selector_resolution"] if item["feature_id"] == "shell") + self.assertEqual(resolution["status"], "resolved") + self.assertEqual(resolution["resolution_mode"], "operation_role") + self.assertEqual(resolution["selected"]["output_roles"], ["extrude.end"]) + + non_immediate = deepcopy(cdsl) + non_immediate["features"].insert(1, { + "id": "gap", "atomic_id": "reference_plane", "depends_on": ["base_add"], + "params": {"plane": _workplane()}, "execution_status": "supported", + }) + non_immediate["features"][-1]["depends_on"] = ["gap"] + with self.assertRaisesRegex(ValueError, "CAP_FACE output role requires the immediately preceding direct new_body blind extrusion cap"): + validate_semantic_cdsl(non_immediate) + self.assertIn( + "unsupported_cap_face_output_role_selector", + [blocker.code for blocker in analyze_cdsl(non_immediate).feature_results[-1].blockers], + ) + def test_transformed_owner_selector_executes_a_downstream_fillet(self) -> None: from cdsl_engine.runtime import rebuild_cdsl @@ -2303,6 +2732,40 @@ class EngineRuntimeFoundationTests(unittest.TestCase): for item in downstream["selector_resolution"] )) + 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 + + 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": "two-point-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": "bspline", "start": [0, 0], "end": [6, 10], + "points": [[0, 0], [6, 10]], "parameters": [0, 1], + "start_tangent": [0, 10], "end_tangent": [10, 0], + }, + }}, + }], + } + self.assertTrue(analyze_cdsl(cdsl).runtime_eligible) + missing_tangent = deepcopy(cdsl) + missing_tangent["features"][0]["params"]["path"]["segment"].pop("end_tangent") + blocker = analyze_cdsl(missing_tangent).feature_results[0].blockers[0] + self.assertEqual(blocker.code, "invalid_sweep_path") + self.assertEqual(blocker.message, "A two-point B-spline sweep path requires both endpoint tangents") + with tempfile.TemporaryDirectory() as directory: + result = rebuild_cdsl(cdsl, Path(directory) / "two-point-sweep.step") + self.assertEqual(result["solid_count"], 1) + self.assertGreater(result["volume_mm3"], 0) + 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 @@ -2572,6 +3035,71 @@ class EngineRuntimeFoundationTests(unittest.TestCase): result = rebuild_cdsl(base, Path(directory) / "local-hole.step") self.assertAlmostEqual(result["volume_mm3"], 1000 - 5 * 3.141592653589793, places=5) + def test_scoped_holes_preserve_the_original_body_member(self) -> None: + from cdsl_engine.runtime import prepare_cdsl_execution + + cdsl = self._base_block() + host_frame = { + "origin_mm": [0, 0, 10], "x_dir": [1, 0, 0], + "y_dir": [0, 1, 0], "normal": [0, 0, 1], + } + for feature_id, position in (("first_hole", [-2, 0, 0]), ("second_hole", [2, 0, 0])): + cdsl["features"].append({ + "id": feature_id, "atomic_id": "hole_wizard", + "depends_on": ["base_add" if feature_id == "first_hole" else "first_hole"], + "params": { + "hole_type": "plain", "diameter_mm": 2, "depth_mm": 5, + "end_condition": {"type": "blind", "solidworks_code": 0}, + "positions": [{"mm": position}], "host_face": {"frame": host_frame}, + "scope_feature_id": "base_add", + }, + }) + execution = prepare_cdsl_execution(cdsl) + self.assertTrue(all(result.executable for result in execution.analysis.feature_results)) + execution.execute_all() + self.assertEqual(set(execution.session.body_members), {"base_add"}) + self.assertLess(float(execution.session.body.volume), 1000.0) + + def test_unscoped_hole_keeps_the_legacy_feature_owned_member(self) -> None: + from cdsl_engine.runtime import prepare_cdsl_execution + + cdsl = self._base_block() + cdsl["features"].append({ + "id": "hole", "atomic_id": "hole_wizard", "depends_on": ["base_add"], + "params": { + "hole_type": "plain", "diameter_mm": 2, "depth_mm": 5, + "end_condition": {"type": "blind", "solidworks_code": 0}, + "positions": [{"mm": [0, 0, 0]}], + "host_face": {"frame": {"origin_mm": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0], "normal": [0, 0, 1]}}, + }, + }) + execution = prepare_cdsl_execution(cdsl) + self.assertTrue(all(result.executable for result in execution.analysis.feature_results)) + 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 + + cdsl = _two_body_boolean_cdsl( + "union", _rectangle([-5, -5], [5, 5]), _rectangle([20, -5], [30, 5]), + ) + cdsl["features"] = cdsl["features"][:2] + cdsl["features"].append({ + "id": "scoped_hole", "atomic_id": "hole_wizard", "depends_on": ["left_body", "right_body"], + "params": { + "hole_type": "plain", "diameter_mm": 2, "depth_mm": 2, + "end_condition": {"type": "blind", "solidworks_code": 0}, + "positions": [{"mm": [0, 0, 0]}], + "host_face": {"frame": {"origin_mm": [0, 0, 4], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0], "normal": [0, 0, 1]}}, + "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]) + def test_counterbore_and_countersink_holes_execute_with_explicit_host_frames(self) -> None: """Keep both legacy hole contracts covered by an actual kernel rebuild.""" from cdsl_engine.runtime import rebuild_cdsl diff --git a/backend/tests/test_selector_provenance_contract.py b/backend/tests/test_selector_provenance_contract.py new file mode 100644 index 00000000..35474d59 --- /dev/null +++ b/backend/tests/test_selector_provenance_contract.py @@ -0,0 +1,1062 @@ +from __future__ import annotations + +import unittest + +from engine.cdsl_engine.topology import ( + TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry, TopologySectionRelation, +) + + +def _intent( + family: str = "CAP_FACE", + version: str | None = "1511", + *, + allowed: list[str] | None = None, + multiplicity: str = "one", +) -> dict: + source_query = { + "ast": {"call": family}, + "standard_library": "onshape/std/geometry.fs", + "standard_library_version": "1511.0", + } + if version is not None: + source_query["featurescript_version"] = version + return { + "version": "1.0", + "kind": "face", + "query_family": family, + "source_query": source_query, + "derivation_policy": {"allowed": allowed or ["continuation"], "multiplicity": multiplicity}, + "evidence": "kernel_history", + } + + +def _intersection_intent() -> dict: + return { + "version": "1.0", + "kind": "edge", + "query_family": "INTERSECT", + "source_query": { + "ast": {"call": "makeQuery"}, "featurescript_version": "1511", + "standard_library": "onshape/std/geometry.fs", + "standard_library_version": "1511.0", + }, + "derivation_policy": {"allowed": ["intersection"], "multiplicity": "one"}, + "evidence": "kernel_history", + "intersection_sources": [ + {"query_family": "CAP_FACE", "owner_feature_id": "f1", "output_role": "extrude.end"}, + { + "query_family": "SWEPT_FACE", "owner_feature_id": "f2", + "source_entity": {"sketch_id": "S2", "entity_id": "E2"}, + }, + ], + } + + +class SelectorProvenanceContractTests(unittest.TestCase): + 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() + body = object() + registry.register(TopologyRecord( + "body:f1", "body", "f1", "body:f1", value=body, owner_feature_ids=("f1",), + )) + selector = { + "kind": "body", "owner_feature_id": "f1", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("SWEPT_BODY", allowed=["boundary"]), + "kind": "body", + "evidence": "active_body_member", + "body_member_contract": "direct_new_body", + }, + } + + resolved = registry.resolve(selector, active_body_id="body:f1") + + self.assertEqual(resolved.status, "resolved") + self.assertIs(resolved.record.value, body) + self.assertEqual(resolved.resolution_mode, "body_member") + self.assertEqual(resolved.evidence["body_member"], "body:f1") + + inactive = registry.resolve(selector, active_body_id="body:later") + self.assertEqual(inactive.status, "not_found") + self.assertEqual(inactive.diagnostic.code, "selector_body_member_inactive") + + mixed = {**selector, "stable_id": "body:f1"} + rejected = registry.resolve(mixed, active_body_id="body:f1") + self.assertEqual(rejected.status, "not_found") + self.assertEqual(rejected.diagnostic.code, "selector_source_unavailable") + + 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() + source_edge = object() + initial_side = object() + continued_side = object() + anchor = TopologyRecord( + "anchor:edge", "edge", "f1", value=source_edge, + source_entity=("F0", "E0"), + ) + registry.register(anchor) + registry.replace_body_topology( + "f1", "body:f1", [ + TopologyRecord("body:f1:face:0", "face", "f1", "body:f1", value=initial_side), + ], + topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation( + "generated", "edge", source_edge, (initial_side,), + source_kind="edge", result_kind="face", derivation="boundary", + coverage="complete", status="proven", + ), + )), + additional_predecessors=[anchor], + ) + registry.replace_body_topology( + "cut", "body:cut", [ + TopologyRecord("body:cut:face:0", "face", "cut", "body:cut", value=continued_side), + ], + topology_delta=TopologyDelta("subtract", ( + TopologyDeltaRelation( + "modified", "face", initial_side, (continued_side,), + derivation="continuation", coverage="complete", status="proven", + ), + )), + ) + selector = { + "kind": "face", "owner_feature_id": "f1", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("SWEPT_FACE", allowed=["boundary", "continuation"]), + "source_entity": {"sketch_id": "F0", "entity_id": "E0"}, + }, + } + + resolved = registry.resolve(selector, active_body_id="body:cut") + + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.record.record_id, "body:cut:face:0") + self.assertEqual(resolved.resolution_mode, "kernel_lineage") + self.assertEqual( + [relation["derivation"] for relation in resolved.evidence["relations"]], + ["boundary", "continuation"], + ) + + boundary_only = { + **selector, + "selector_intent": { + **selector["selector_intent"], + "derivation_policy": {"allowed": ["boundary"], "multiplicity": "one"}, + }, + } + rejected = registry.resolve(boundary_only, active_body_id="body:cut") + self.assertEqual(rejected.status, "not_found") + self.assertEqual(rejected.diagnostic.code, "selector_relation_non_unique") + + def test_direct_prism_source_anchors_resolve_cross_kind_kernel_lineage(self) -> None: + """Direct profile entities resolve only through their prism history.""" + from engine.cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + adapter = Build123dGeometryAdapter() + outer = [ + {"type": "line", "start_mm": [0, 0, 0], "end_mm": [10, 0, 0], "source_entity_id": "E0"}, + {"type": "line", "start_mm": [10, 0, 0], "end_mm": [10, 5, 0], "source_entity_id": "E1"}, + {"type": "line", "start_mm": [10, 5, 0], "end_mm": [0, 5, 0], "source_entity_id": "E2"}, + {"type": "line", "start_mm": [0, 5, 0], "end_mm": [0, 0, 0], "source_entity_id": "E3"}, + ] + faces, anchor_specs = adapter.faces_for_sketch_with_source_anchors({ + "id": "profile", + "source_sketch_id": "F0", + "profile": {"type": "analytic_contours", "contours": []}, + "contour_regions_mm": [{"outer": outer, "holes": []}], + }) + self.assertEqual(len(faces), 1) + self.assertEqual( + {(item["kind"], item.get("source_entity"), item.get("source_entities")) for item in anchor_specs}, + { + ("edge", ("F0", "E0"), None), ("edge", ("F0", "E1"), None), + ("edge", ("F0", "E2"), None), ("edge", ("F0", "E3"), None), + ("vertex", None, (("F0", "E0"), ("F0", "E1"))), + ("vertex", None, (("F0", "E0"), ("F0", "E3"))), + ("vertex", None, (("F0", "E1"), ("F0", "E2"))), + ("vertex", None, (("F0", "E2"), ("F0", "E3"))), + }, + ) + solid, delta = adapter.extrude_with_topology_delta(faces[0], [0, 0, 8]) + registry = TopologyRegistry() + anchors = [ + TopologyRecord( + f"anchor:{index}", item["kind"], "extrude", value=item["value"], + source_entity=item.get("source_entity"), + source_entities=tuple(item.get("source_entities") or ()), + ) + for index, item in enumerate(anchor_specs) + ] + for anchor in anchors: + registry.register(anchor) + registry.replace_body_topology( + "extrude", "body:extrude", + adapter.topology_records(solid, "extrude", "body:extrude"), + topology_delta=delta, + additional_predecessors=anchors, + ) + + face_selector = { + "kind": "face", "owner_feature_id": "extrude", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("SWEPT_FACE", allowed=["boundary"]), + "source_entity": {"sketch_id": "F0", "entity_id": "E0"}, + }, + } + face_resolution = registry.resolve(face_selector, active_body_id="body:extrude") + self.assertEqual(face_resolution.status, "resolved") + self.assertEqual(face_resolution.resolution_mode, "kernel_lineage") + self.assertEqual(face_resolution.evidence["semantic_anchor"], { + "type": "source_entity", "sketch_id": "F0", "entity_id": "E0", + }) + self.assertEqual(face_resolution.evidence["relations"][0]["source_kind"], "edge") + self.assertEqual(face_resolution.evidence["relations"][0]["result_kind"], "face") + + edge_selector = { + "kind": "edge", "owner_feature_id": "extrude", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("SWEPT_EDGE", allowed=["boundary"]), + "source_entities": [ + {"sketch_id": "F0", "entity_id": "E0"}, + {"sketch_id": "F0", "entity_id": "E1"}, + ], + }, + } + edge_resolution = registry.resolve(edge_selector, active_body_id="body:extrude") + self.assertEqual(edge_resolution.status, "resolved") + self.assertEqual(edge_resolution.evidence["relations"][0]["source_kind"], "vertex") + self.assertEqual(edge_resolution.evidence["relations"][0]["result_kind"], "edge") + + cap_records = [] + for role in ("extrude.start", "extrude.end"): + cap_resolution = registry.resolve({ + "kind": "edge", "owner_feature_id": "extrude", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("CAP_EDGE", allowed=["boundary"]), + "kind": "edge", + "source_entity": {"sketch_id": "F0", "entity_id": "E0"}, + "lineage_role": role, + }, + }, active_body_id="body:extrude") + self.assertEqual(cap_resolution.status, "resolved") + self.assertEqual(cap_resolution.resolution_mode, "kernel_lineage") + self.assertEqual(cap_resolution.evidence["relations"][0]["output_role"], role) + cap_records.append(cap_resolution.record.record_id) + self.assertEqual(len(set(cap_records)), 2) + + missing_role = registry.resolve({ + "kind": "edge", "owner_feature_id": "extrude", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("CAP_EDGE", allowed=["boundary"]), + "kind": "edge", + "source_entity": {"sketch_id": "F0", "entity_id": "E0"}, + "lineage_role": "extrude.end", + }, + }, active_body_id="body:other") + self.assertEqual(missing_role.status, "not_found") + self.assertEqual(missing_role.diagnostic.code, "selector_body_member_inactive") + + def test_source_anchor_rejects_partial_or_missing_prism_history(self) -> None: + registry = TopologyRegistry() + source = object() + result = object() + missing_result = object() + anchor = TopologyRecord( + "anchor:edge", "edge", "extrude", value=source, + source_entity=("F0", "E0"), + ) + registry.register(anchor) + registry.replace_body_topology("extrude", "body:extrude", [ + TopologyRecord("extrude:side", "face", "extrude", "body:extrude", value=result), + ], topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation( + "generated", "edge", source, (result, missing_result), + source_kind="edge", result_kind="face", derivation="boundary", + coverage="complete", status="proven", + ), + )), additional_predecessors=[anchor]) + selector = { + "kind": "face", "owner_feature_id": "extrude", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("SWEPT_FACE", allowed=["boundary"]), + "source_entity": {"sketch_id": "F0", "entity_id": "E0"}, + }, + } + + resolution = registry.resolve(selector, active_body_id="body:extrude") + + # The adapter claimed complete history, but one result is absent from + # the final snapshot. The registry must downgrade the relation before + # a selector can consume it. + relation = registry.topology_deltas()[0]["relations"][0] + self.assertEqual(relation["coverage"], "partial") + self.assertEqual(relation["result_record_ids"], ["extrude:side"]) + self.assertEqual(resolution.status, "not_found") + self.assertEqual(resolution.diagnostic.code, "selector_kernel_history_missing") + + 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() + source_edge = object() + initial_cap_edge = object() + continued_cap_edge = object() + anchor = TopologyRecord( + "anchor:edge", "edge", "extrude", value=source_edge, + source_entity=("F0", "E0"), + ) + registry.register(anchor) + registry.replace_body_topology( + "extrude", "body:extrude", [ + TopologyRecord("body:extrude:edge:0", "edge", "extrude", "body:extrude", value=initial_cap_edge), + ], + topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation( + "generated", "edge", source_edge, (initial_cap_edge,), + source_kind="edge", result_kind="edge", derivation="boundary", + coverage="complete", status="proven", output_role="extrude.start", + ), + )), + additional_predecessors=[anchor], + ) + registry.replace_body_topology( + "cut", "body:cut", [ + TopologyRecord("body:cut:edge:0", "edge", "cut", "body:cut", value=continued_cap_edge), + ], + topology_delta=TopologyDelta("subtract", ( + TopologyDeltaRelation( + "preserved", "edge", initial_cap_edge, (continued_cap_edge,), + source_kind="edge", result_kind="edge", derivation="continuation", + coverage="complete", status="proven", + ), + )), + ) + selector = { + "kind": "edge", "owner_feature_id": "extrude", + "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.start", + }, + } + + resolved = registry.resolve(selector, active_body_id="body:cut") + + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.record.record_id, "body:cut:edge:0") + self.assertEqual( + [relation["derivation"] for relation in resolved.evidence["relations"]], + ["boundary", "continuation"], + ) + + boundary_only = { + **selector, + "selector_intent": { + **selector["selector_intent"], + "derivation_policy": {"allowed": ["boundary"], "multiplicity": "one"}, + }, + } + rejected = registry.resolve(boundary_only, active_body_id="body:cut") + self.assertEqual(rejected.status, "not_found") + self.assertEqual(rejected.diagnostic.code, "selector_relation_non_unique") + + partial_registry = TopologyRegistry() + partial_registry.register(anchor) + partial_registry.replace_body_topology( + "extrude", "body:extrude", [ + TopologyRecord("body:extrude:edge:0", "edge", "extrude", "body:extrude", value=initial_cap_edge), + ], + topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation( + "generated", "edge", source_edge, (initial_cap_edge,), + source_kind="edge", result_kind="edge", derivation="boundary", + coverage="complete", status="proven", output_role="extrude.start", + ), + )), + additional_predecessors=[anchor], + ) + partial_registry.replace_body_topology( + "cut", "body:cut", [ + TopologyRecord("body:cut:edge:0", "edge", "cut", "body:cut", value=continued_cap_edge), + ], + topology_delta=TopologyDelta("subtract", ( + TopologyDeltaRelation( + "modified", "edge", initial_cap_edge, (continued_cap_edge,), + source_kind="edge", result_kind="edge", derivation="continuation", + coverage="partial", status="unknown", + ), + )), + ) + partial = partial_registry.resolve(selector, active_body_id="body:cut") + self.assertEqual(partial.status, "not_found") + self.assertEqual(partial.diagnostic.code, "selector_kernel_history_missing") + + def test_direct_annulus_source_edges_resolve_each_prism_side_face(self) -> None: + """Direct circular hole wires retain distinct source edge provenance.""" + from engine.cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + adapter = Build123dGeometryAdapter() + faces, anchor_specs = adapter.faces_for_sketch_with_source_anchors({ + "id": "annulus", + "source_sketch_id": "F0", + "workplane": { + "origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], + "y_dir": [0, 1, 0], "normal": [0, 0, 1], + }, + "profile": { + "type": "analytic_contours", + "contours": [ + {"role": "outer", "closed": True, "segments": [{ + "type": "circle", "center": [0, 0], "radius_mm": 10, + "source_entity_id": "outer", + }]}, + {"role": "inner", "closed": True, "segments": [{ + "type": "circle", "center": [0, 0], "radius_mm": 5, + "source_entity_id": "inner", + }]}, + ], + }, + }) + self.assertEqual(len(faces), 1) + self.assertEqual( + {(item["kind"], item.get("source_entity")) for item in anchor_specs}, + {("edge", ("F0", "outer")), ("edge", ("F0", "inner"))}, + ) + solid, delta = adapter.extrude_with_topology_delta(faces[0], [0, 0, 8]) + registry = TopologyRegistry() + anchors = [ + TopologyRecord( + f"anchor:{index}", item["kind"], "extrude", value=item["value"], + source_entity=item.get("source_entity"), + ) + for index, item in enumerate(anchor_specs) + ] + for anchor in anchors: + registry.register(anchor) + registry.replace_body_topology( + "extrude", "body:extrude", + adapter.topology_records(solid, "extrude", "body:extrude"), + topology_delta=delta, + additional_predecessors=anchors, + ) + + records = [] + for entity_id in ("outer", "inner"): + resolution = registry.resolve({ + "kind": "face", "owner_feature_id": "extrude", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("SWEPT_FACE", allowed=["boundary"]), + "source_entity": {"sketch_id": "F0", "entity_id": entity_id}, + }, + }, active_body_id="body:extrude") + self.assertEqual(resolution.status, "resolved") + self.assertEqual(resolution.resolution_mode, "kernel_lineage") + records.append(resolution.record.record_id) + self.assertEqual(len(set(records)), 2) + + def test_direct_linear_profile_with_circle_hole_keeps_exact_prism_anchors(self) -> None: + """A direct hole face retains outer vertex and inner edge provenance.""" + from engine.cdsl_engine.build123d_adapter import Build123dGeometryAdapter + + adapter = Build123dGeometryAdapter() + outer = [ + {"type": "line", "start_mm": [0, 0, 0], "end_mm": [12, 0, 0], "source_entity_id": "bottom"}, + {"type": "line", "start_mm": [12, 0, 0], "end_mm": [12, 8, 0], "source_entity_id": "right"}, + {"type": "line", "start_mm": [12, 8, 0], "end_mm": [0, 8, 0], "source_entity_id": "top"}, + {"type": "line", "start_mm": [0, 8, 0], "end_mm": [0, 0, 0], "source_entity_id": "left"}, + ] + inner = [{ + "type": "circle", "center_mm": [6, 4, 0], "radius_mm": 1.5, + "normal": [0, 0, 1], "x_dir_mm": [1, 0, 0], "source_entity_id": "hole", + }] + faces, anchor_specs = adapter.faces_for_sketch_with_source_anchors({ + "id": "profile", "source_sketch_id": "F0", + "profile": {"type": "analytic_contours", "contours": []}, + "contour_regions_mm": [{"outer": outer, "holes": [inner]}], + }) + + self.assertEqual(len(faces), 1) + self.assertEqual( + {(item["kind"], item.get("source_entity")) for item in anchor_specs if item["kind"] == "edge"}, + { + ("edge", ("F0", "bottom")), ("edge", ("F0", "right")), + ("edge", ("F0", "top")), ("edge", ("F0", "left")), + ("edge", ("F0", "hole")), + }, + ) + self.assertIn( + ("vertex", None, (("F0", "right"), ("F0", "top"))), + {(item["kind"], item.get("source_entity"), item.get("source_entities")) for item in anchor_specs}, + ) + + solid, delta = adapter.extrude_with_topology_delta(faces[0], [0, 0, 8]) + anchors = [ + TopologyRecord( + f"anchor:{index}", item["kind"], "extrude", value=item["value"], + source_entity=item.get("source_entity"), + source_entities=tuple(item.get("source_entities") or ()), + ) + for index, item in enumerate(anchor_specs) + ] + registry = TopologyRegistry() + for anchor in anchors: + registry.register(anchor) + registry.replace_body_topology( + "extrude", "body:extrude", + adapter.topology_records(solid, "extrude", "body:extrude"), + topology_delta=delta, additional_predecessors=anchors, + ) + + edge_resolution = registry.resolve({ + "kind": "edge", "owner_feature_id": "extrude", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("SWEPT_EDGE", allowed=["boundary"]), + "source_entities": [ + {"sketch_id": "F0", "entity_id": "right"}, + {"sketch_id": "F0", "entity_id": "top"}, + ], + }, + }, active_body_id="body:extrude") + self.assertEqual(edge_resolution.status, "resolved") + self.assertEqual(edge_resolution.resolution_mode, "kernel_lineage") + self.assertEqual(edge_resolution.evidence["relations"][0]["source_kind"], "vertex") + self.assertEqual(edge_resolution.evidence["relations"][0]["result_kind"], "edge") + + face_resolution = registry.resolve({ + "kind": "face", "owner_feature_id": "extrude", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("SWEPT_FACE", allowed=["boundary"]), + "source_entity": {"sketch_id": "F0", "entity_id": "hole"}, + }, + }, active_body_id="body:extrude") + self.assertEqual(face_resolution.status, "resolved") + self.assertEqual(face_resolution.resolution_mode, "kernel_lineage") + + cap_records = [] + for role in ("extrude.start", "extrude.end"): + cap_resolution = registry.resolve({ + "kind": "edge", "owner_feature_id": "extrude", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **_intent("CAP_EDGE", allowed=["boundary"]), + "kind": "edge", + "source_entity": {"sketch_id": "F0", "entity_id": "hole"}, + "lineage_role": role, + }, + }, active_body_id="body:extrude") + self.assertEqual(cap_resolution.status, "resolved") + self.assertEqual(cap_resolution.resolution_mode, "kernel_lineage") + self.assertEqual(cap_resolution.evidence["relations"][0]["source_kind"], "edge") + self.assertEqual(cap_resolution.evidence["relations"][0]["result_kind"], "edge") + self.assertEqual(cap_resolution.evidence["relations"][0]["output_role"], role) + cap_records.append(cap_resolution.record.record_id) + self.assertEqual(len(set(cap_records)), 2) + + def test_partial_logical_circle_region_does_not_create_a_source_anchor(self) -> None: + """A split circle needs every solver-proven segment before restoration.""" + from engine.cdsl_engine.build123d_adapter import Build123dGeometryAdapter + from engine.cdsl_engine.sketch_solver import resolve_profile + + sketch = { + "id": "profile", "source_sketch_id": "F0", + "workplane": { + "origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1], + }, + "profile": {"type": "analytic_contours", "contours": [ + {"closed": True, "segments": [ + {"type": "line", "start": [0, 0], "end": [12, 0], "source_entity_id": "bottom"}, + {"type": "line", "start": [12, 0], "end": [12, 8], "source_entity_id": "right"}, + {"type": "line", "start": [12, 8], "end": [0, 8], "source_entity_id": "top"}, + {"type": "line", "start": [0, 8], "end": [0, 0], "source_entity_id": "left"}, + ]}, + {"closed": True, "segments": [{ + "type": "circle", "center": [6, 4], "radius_mm": 1.5, "source_entity_id": "hole", + }]}, + ]}, + } + solved = resolve_profile(sketch) + solved["contour_regions_mm"][0]["holes"][0][-1].pop("logical_circle_source_entity_id") + _faces, anchors = Build123dGeometryAdapter().faces_for_sketch_with_source_anchors(solved) + + self.assertNotIn( + ("edge", ("F0", "hole")), + {(item["kind"], item.get("source_entity")) for item in 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 + + source_intent = _intent("SWEPT_FACE", allowed=["boundary"]) + cdsl = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", + "kind": "part", "part_id": "two-direct-circles", "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "sketch_F0", "source_sketch_id": "F0", + "workplane": { + "origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1], + }, + "profile": {"type": "analytic_contours", "contours": [ + {"closed": True, "segments": [{ + "type": "circle", "center": [-20, 0], "radius_mm": 5, + "source_entity_id": "E0", + }]}, + {"closed": True, "segments": [{ + "type": "circle", "center": [20, 0], "radius_mm": 5, + "source_entity_id": "E1", + }]}, + ]}, + }]}, + "features": [{ + "id": "f_F1", "atomic_id": "extrude_add_blind", "depends_on": [], + "sketch_id": "sketch_F0", "params": {"distance_mm": 8, "result_mode": "new_body"}, + }], + } + + execution = prepare_cdsl_execution(cdsl) + self.assertTrue(execution.analysis.runtime_eligible) + execution.execute_all() + self.assertEqual(len(execution.session.adapter.body_solids(execution.session.body)), 2) + + resolutions = {} + for entity_id in ("E0", "E1"): + resolution = execution.session.topology.resolve({ + "kind": "face", "owner_feature_id": "f_F1", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + **source_intent, + "source_entity": {"sketch_id": "F0", "entity_id": entity_id}, + }, + }, active_body_id=execution.session.body_id) + self.assertEqual(resolution.status, "resolved") + self.assertEqual(resolution.resolution_mode, "kernel_lineage") + self.assertIsNotNone(resolution.record) + resolutions[entity_id] = resolution + + self.assertNotEqual( + resolutions["E0"].record.record_id, + resolutions["E1"].record.record_id, + ) + self.assertNotEqual( + resolutions["E0"].evidence["body_member"], + resolutions["E1"].evidence["body_member"], + ) + active_records = { + record.record_id for record in execution.session.topology.records() + if record.body_id and record.body_id.startswith(f"{execution.session.body_id}:") + } + side_relations = [ + relation for delta in execution.session.topology.topology_deltas() + for relation in delta["relations"] + if relation["source_kind"] == "edge" and relation["result_kind"] == "face" + ] + self.assertEqual(len(side_relations), 2) + self.assertTrue(all( + relation["coverage"] == "complete" + and relation["lineage_status"] == "proven" + and len(relation["result_record_ids"]) == 1 + and set(relation["result_record_ids"]).issubset(active_records) + for relation in side_relations + )) + + def test_unknown_source_version_is_preserved_then_rejected_by_resolver(self) -> None: + registry = TopologyRegistry() + selector = { + "kind": "face", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _intent(version=None), + } + + resolution = registry.resolve(selector) + + self.assertEqual(resolution.status, "not_found") + self.assertEqual(resolution.diagnostic.code, "selector_query_version_unknown") + + def test_known_but_unregistered_query_version_is_rejected(self) -> None: + registry = TopologyRegistry() + selector = { + "kind": "face", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _intent(version="1512"), + } + + resolution = registry.resolve(selector) + + self.assertEqual(resolution.status, "not_found") + self.assertEqual(resolution.diagnostic.code, "selector_query_unsupported") + self.assertEqual(resolution.diagnostic.detail["verified_versions"], ["1511"]) + + def test_missing_or_unregistered_standard_library_revision_is_rejected(self) -> None: + registry = TopologyRegistry() + selector = { + "kind": "face", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _intent(), + } + + missing = {**selector, "selector_intent": {**selector["selector_intent"], "source_query": { + "ast": {"call": "CAP_FACE"}, "featurescript_version": "1511", + }}} + missing_resolution = registry.resolve(missing) + self.assertEqual(missing_resolution.status, "not_found") + self.assertEqual(missing_resolution.diagnostic.code, "selector_query_version_unknown") + + unregistered = {**selector, "selector_intent": {**selector["selector_intent"], "source_query": { + **selector["selector_intent"]["source_query"], "standard_library_version": "1512.0", + }}} + unregistered_resolution = registry.resolve(unregistered) + self.assertEqual(unregistered_resolution.status, "not_found") + self.assertEqual(unregistered_resolution.diagnostic.code, "selector_query_unsupported") + self.assertEqual( + unregistered_resolution.diagnostic.detail["verified_standard_libraries"], + [{"path": "onshape/std/geometry.fs", "version": "1511.0"}], + ) + + def test_unregistered_query_intent_cannot_fall_back_to_stable_id_or_geometry(self) -> None: + registry = TopologyRegistry() + face = object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord( + "base:face", "face", "base", "body:base", value=face, + geometry={"center_mm": [0.0, 0.0, 0.0]}, + ), + ]) + selector = { + "kind": "face", "owner_feature_id": "base", "stable_id": "base:face", + "source": "runtime_snapshot", "confidence": 1.0, + "geometry": {"center_mm": [0.0, 0.0, 0.0]}, + "selector_intent": _intent("LOFT_EDGE_CHAIN", version="1793"), + } + + resolution = registry.resolve(selector, active_body_id="body:base") + + self.assertEqual(resolution.status, "not_found") + self.assertEqual(resolution.diagnostic.code, "selector_query_unsupported") + self.assertEqual(resolution.diagnostic.detail["query_family"], "LOFT_EDGE_CHAIN") + self.assertEqual(resolution.diagnostic.detail["verified_versions"], []) + + def test_active_stable_id_does_not_replace_provenance_evidence(self) -> None: + registry = TopologyRegistry() + source = object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:face", "face", "base", "body:base", value=source), + ]) + selector = { + "kind": "face", "owner_feature_id": "base", "stable_id": "base:face", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _intent(), + } + + resolution = registry.resolve(selector, active_body_id="body:base") + + self.assertEqual(resolution.status, "not_found") + self.assertEqual(resolution.diagnostic.code, "selector_kernel_history_missing") + + def test_output_role_requires_a_relation_allowed_by_its_policy(self) -> None: + registry = TopologyRegistry() + profile = object() + cap = object() + registry.replace_body_topology("profile", "body:profile", [ + TopologyRecord("profile:face", "face", "profile", "body:profile", value=profile), + ]) + registry.replace_body_topology("extrude", "body:extrude", [ + TopologyRecord("extrude:cap", "face", "extrude", "body:extrude", value=cap), + ], topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation("generated", "face", profile, (cap,), output_role="extrude.end"), + ))) + selector = { + "kind": "face", "owner_feature_id": "extrude", "output_role": "extrude.end", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _intent(allowed=["continuation"]), + } + + rejected = registry.resolve(selector, active_body_id="body:extrude") + self.assertEqual(rejected.status, "not_found") + self.assertEqual(rejected.diagnostic.code, "selector_kernel_history_missing") + + selector["selector_intent"] = _intent(allowed=["boundary"]) + resolved = registry.resolve(selector, active_body_id="body:extrude") + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.resolution_mode, "operation_role") + self.assertEqual(resolved.evidence["result_records"], ["extrude:cap"]) + self.assertEqual(resolved.evidence["relations"][0]["derivation"], "boundary") + + def test_all_fragments_rejects_a_missing_branch(self) -> None: + registry = TopologyRegistry() + source = object() + first = object() + missing_second = object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:face", "face", "base", "body:base", value=source), + ]) + registry.replace_body_topology("split", "body:split", [ + TopologyRecord("split:first", "face", "split", "body:split", value=first), + ], topology_delta=TopologyDelta("split", ( + TopologyDeltaRelation("modified", "face", source, (first, missing_second)), + ))) + selector = { + "kind": "face", "owner_feature_id": "base", "stable_id": "base:face", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _intent(allowed=["fragment"], multiplicity="all_fragments"), + } + + resolution = registry.resolve(selector, active_body_id="body:split") + + self.assertEqual(resolution.status, "not_found") + self.assertEqual(resolution.diagnostic.code, "selector_kernel_history_missing") + + def test_merge_requires_merge_policy_even_with_one_final_record(self) -> None: + registry = TopologyRegistry() + first = object() + second = object() + merged = object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:first", "face", "base", "body:base", value=first), + TopologyRecord("base:second", "face", "base", "body:base", value=second), + ]) + registry.replace_body_topology("boolean", "body:boolean", [ + TopologyRecord("boolean:merged", "face", "boolean", "body:boolean", value=merged), + ], topology_delta=TopologyDelta("fuse", ( + TopologyDeltaRelation("modified", "face", first, (merged,)), + TopologyDeltaRelation("modified", "face", second, (merged,)), + ))) + selector = { + "kind": "face", "owner_feature_id": "base", "stable_id": "base:first", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _intent(allowed=["continuation"]), + } + + rejected = registry.resolve(selector, active_body_id="body:boolean") + self.assertEqual(rejected.status, "not_found") + self.assertEqual(rejected.diagnostic.code, "selector_relation_non_unique") + + selector["selector_intent"] = _intent(allowed=["continuation", "merge"]) + resolved = registry.resolve(selector, active_body_id="body:boolean") + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.record.record_id, "boolean:merged") + + def test_output_role_does_not_bypass_operation_merge_policy(self) -> None: + registry = TopologyRegistry() + first = object() + second = object() + cap = object() + registry.replace_body_topology("profiles", "body:profiles", [ + TopologyRecord("profiles:first", "face", "profiles", "body:profiles", value=first), + TopologyRecord("profiles:second", "face", "profiles", "body:profiles", value=second), + ]) + registry.replace_body_topology("extrude", "body:extrude", [ + TopologyRecord("extrude:cap", "face", "extrude", "body:extrude", value=cap), + ], topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation("generated", "face", first, (cap,), output_role="extrude.end"), + TopologyDeltaRelation("generated", "face", second, (cap,)), + ))) + selector = { + "kind": "face", "owner_feature_id": "extrude", "output_role": "extrude.end", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _intent(allowed=["boundary"]), + } + + rejected = registry.resolve(selector, active_body_id="body:extrude") + self.assertEqual(rejected.status, "not_found") + self.assertEqual(rejected.diagnostic.code, "selector_relation_non_unique") + + selector["selector_intent"] = _intent(allowed=["boundary", "merge"]) + resolved = registry.resolve(selector, active_body_id="body:extrude") + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.record.record_id, "extrude:cap") + + def test_cross_kind_relation_keeps_distinct_source_and_result_evidence(self) -> None: + registry = TopologyRegistry() + vertex = object() + edge = object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:vertex", "vertex", "base", "body:base", value=vertex), + ]) + registry.replace_body_topology("fillet", "body:fillet", [ + TopologyRecord("fillet:edge", "edge", "fillet", "body:fillet", value=edge), + ], topology_delta=TopologyDelta("fillet", ( + TopologyDeltaRelation( + "generated", "edge", vertex, (edge,), + source_kind="vertex", result_kind="edge", + ), + ))) + + relation = registry.topology_deltas()[0]["relations"][0] + lineage = registry.lineage()[0] + self.assertEqual(relation["source_record_ids"], ["base:vertex"]) + self.assertEqual(relation["result_record_ids"], ["fillet:edge"]) + self.assertEqual((relation["source_kind"], relation["result_kind"]), ("vertex", "edge")) + self.assertEqual((lineage.source_kind, lineage.result_kind), ("vertex", "edge")) + + def _source_qualified_section_registry(self, *, sections: int = 1, qualified: bool = True) -> TopologyRegistry: + registry = TopologyRegistry() + raw_cap, cap = object(), object() + anchor, swept = object(), object() + registry.replace_body_topology("seed", "body:seed", [ + TopologyRecord("seed:cap", "face", "seed", "body:seed", {"center_mm": [-1, 0, 0]}, raw_cap), + ]) + registry.replace_body_topology("f1", "body:f1", [ + TopologyRecord("f1:cap", "face", "f1", "body:f1", {"center_mm": [0, 0, 0]}, cap), + ], topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation("generated", "face", raw_cap, (cap,), output_role="extrude.end"), + ))) + source_anchor = TopologyRecord( + "anchor:f2", "edge", "f2", value=anchor, source_entity=("S2", "E2"), + ) + registry.register(source_anchor) + registry.replace_body_topology("f2", "body:f2", [ + TopologyRecord("f2:swept", "face", "f2", "body:f2", {"center_mm": [1, 0, 0]}, swept), + ], topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation( + "generated", "edge", anchor, (swept,), source_kind="edge", result_kind="face", derivation="boundary", + ), + )), additional_predecessors=[source_anchor]) + result_edges = [object() for _index in range(sections)] + registry.replace_body_topology("boolean", "body:boolean", [ + TopologyRecord(f"boolean:section:{index}", "edge", "boolean", "body:boolean", value=edge) + for index, edge in enumerate(result_edges) + ], topology_delta=TopologyDelta( + "subtract", + section_values=tuple(result_edges) if not qualified else (), + section_relations=( + tuple(TopologySectionRelation((cap, swept), edge) for edge in result_edges) + if qualified else () + ), + ), additional_predecessors=[ + TopologyRecord("f1:cap", "face", "f1", "body:f1", {"center_mm": [0, 0, 0]}, cap, output_roles=("extrude.end",)), + ]) + return registry + + def test_intersect_resolves_only_one_source_qualified_section_edge(self) -> None: + registry = self._source_qualified_section_registry() + 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.record.record_id, "boolean:section:0") + self.assertEqual(resolved.resolution_mode, "kernel_intersection") + 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_rejects_unqualified_section_edge(self) -> None: + registry = self._source_qualified_section_registry(qualified=False) + 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_kernel_history_missing") + + def test_intersect_rejects_multiple_source_qualified_section_edges(self) -> None: + registry = self._source_qualified_section_registry(sections=2) + 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_session_intersection_vertex_uses_the_common_provenance_gate(self) -> None: + """Deferred provenance must not reach the session geometry shortcut.""" + from engine.cdsl_engine.session import ExecutionSession + + class RecordingAdapter: + def __init__(self) -> None: + self.intersection_calls = 0 + + def intersection_vertex(self, body, face_sets): + self.intersection_calls += 1 + return object() + + def vertex_coordinates(self, vertex): + return (1.0, 2.0, 3.0) + + adapter = RecordingAdapter() + session = ExecutionSession(sketches={}, nodes={}, adapter=adapter) + session.body = object() + session.body_id = "body:base" + session._intersection_component_records = lambda _selector: [TopologyRecord( + "base:face", "face", "base", "body:base", value=object(), + )] + base_selector = { + "kind": "vertex", "owner_feature_id": "cut", + "intersection_of": [{"kind": "face"}, {"kind": "face"}], + } + + for intent, expected_code in ( + (_intent("INTERSECT", allowed=["intersection"], multiplicity="none"), "selector_query_unsupported"), + (_intent("INTERSECT", version="1793", allowed=["intersection"]), "selector_query_unsupported"), + (_intent("INTERSECT", version=None, allowed=["intersection"]), "selector_query_version_unknown"), + ): + with self.subTest(intent=intent): + result = session.resolve({**base_selector, "selector_intent": intent}) + self.assertEqual(result.status, "not_found") + self.assertEqual(result.diagnostic.code, expected_code) + self.assertEqual(adapter.intersection_calls, 0) + + legacy_result = session.resolve(base_selector) + self.assertEqual(legacy_result.status, "resolved") + self.assertEqual(adapter.intersection_calls, 1) + + def test_transient_prism_snapshot_is_historical_not_selectable(self) -> None: + registry = TopologyRegistry() + source_edge = object() + tool_face = object() + anchor = TopologyRecord( + "anchor:tool:E0", "edge", "tool", value=source_edge, + source_entity=("tool-sketch", "E0"), + ) + registered = registry.register_transient_snapshot( + "tool", + "transient:tool", + [TopologyRecord("transient:tool:face:0", "face", "tool", "transient:tool", value=tool_face)], + topology_delta=TopologyDelta("extrude", ( + TopologyDeltaRelation( + "generated", "edge", source_edge, (tool_face,), + source_kind="edge", result_kind="face", derivation="boundary", + ), + )), + anchors=[anchor], + ) + + self.assertTrue(all(record.transient for record in registered)) + self.assertTrue(all(record.transient for record in registry.records())) + self.assertEqual(registry.topology_deltas()[0]["output_snapshot_ids"], ["transient:tool"]) + self.assertEqual( + registry.resolve({"kind": "face", "owner_feature_id": "tool"}).status, + "not_found", + ) + with self.assertRaisesRegex(ValueError, "complete direct-prism history"): + registry.register_transient_snapshot( + "partial-tool", + "transient:partial-tool", + [TopologyRecord("transient:partial-tool:face:0", "face", "partial-tool", value=object())], + topology_delta=TopologyDelta("extrude", history_status="partial"), + anchors=[anchor], + ) + + def test_intersect_rejects_section_edge_outside_active_member(self) -> None: + registry = self._source_qualified_section_registry() + result = registry.resolve({ + "kind": "edge", "owner_feature_id": "boolean", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _intersection_intent(), + }, active_body_id="body:later") + + self.assertEqual(result.status, "not_found") + self.assertEqual(result.diagnostic.code, "selector_body_member_inactive") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_single_stage_workflow.py b/backend/tests/test_single_stage_workflow.py index fee6faa3..75c7cc13 100644 --- a/backend/tests/test_single_stage_workflow.py +++ b/backend/tests/test_single_stage_workflow.py @@ -133,6 +133,21 @@ def test_author_operation_context_exposes_server_injected_selector_contract(): assert context["authoring_sketch_template"] is None +def test_authoring_requirements_context_does_not_leak_open_analysis_fields(): + context = WorkflowCoordinator._authoring_requirements_context({ + "explicit_requirements": ["make a plate"], + "assumptions": ["use millimetres"], + "manual_targets": ["inspect the result"], + "acceptance_targets": [{"kind": "plate", "expected": {"host_face": "max_z"}}], + }) + + assert context == { + "explicit_requirements": ["make a plate"], + "assumptions": ["use millimetres"], + "manual_targets": ["inspect the result"], + } + + def test_schema_repair_hint_contains_the_exact_authoring_sketch_form(): assert "diameter_mm" in WorkflowCoordinator._repair_hint( "AUTHOR_SCHEMA_INVALID", "bodies.0.features.0.sketch.profile", diff --git a/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md b/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md index 23edad0c..d4409ecf 100644 --- a/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md +++ b/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md @@ -202,6 +202,18 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 strict 仍因 max/p99 surface `0.01 mm` 与体积/面积阈值失败而保留为诊断。单元测试还覆盖 copy、boolean、pattern 与 delete 均清空 lineage;没有能唯一表示的 nested source-member chain 仍属未完成 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` 才能 + 以 `active_body_member` / `direct_new_body` 解析该 record。resolver 以 producer identity 与 + active-member lifecycle 证明 `body_member`,不读 stable ID、binding ID、几何、aggregate 或 + current body。`00694309` F3 是正向 body-member evidence;该样本的 F4 strict/RP pass 由下述 + 独立 `CAP_EDGE` continuation contract 证明,而非放宽 body-member 规则。later successor、`ADD`、cut、 + revolve、sweep、`COPY`、boolean/pattern/delete、多 body/multiple-query 和其它 FeatureScript + version 都不属于这个 contract。fresh `output/core-17-swept-body-up-to-body-member-20260910` 的 + `tier=all` pipeline/RP run 完成 17/17,分类为 1 `comparison_timeout`、10 `rebuild_failed`、 + 1 `rebuilt_approximate`、5 `rebuilt_rejected`;它只证明本窄 selector contract 未改变核心集 + 的既有分类,不能标记 general `SWEPT_BODY` 或 `up_to_body` 完成。 2. 在 adapter 层记录每次建模操作的 `preserved`、`modified`、`generated`、`deleted` topology delta 及 CAP/SWEPT/boolean 等输出角色;runtime 以该记录完成后继 selector 绑定,不以“当前形状中最相近元素”猜测。当前增量已覆盖 OCC 刚体 @@ -232,7 +244,13 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 sweep/loft direct-builder cap role;registry 只会在 relation 的唯一 result snapshot 上写入 `TopologyRecord.output_roles`,resolver 要求 owner、active body snapshot 与唯一 role 命中,并拒绝 stable ID、geometry、snapshot ID - 混用。CADFS lowering 现对独立 `new_body` blind prism 的 CAP_FACE,以及满足全部条件的 + 混用。`extrude_add_blind`/`extrude_cut_blind` 的 + `params.end_condition.reference` 与 `shell` 的 `feature.selectors` 各有一个有界 direct-cap + contract:仅紧邻前序、`result_mode: new_body`、blind extrusion 的 `extrude.start`/ + `extrude.end` 可作为 `up_to_surface` reference 或 shell removal face,且必须由该 producer 的 + 完整/已证明 cap role 解析;schema、semantic preflight 与 runtime 同时拒绝非紧邻 producer、 + 其它 role、stable ID、binding ID 和 geometry fallback。CADFS lowering 现对独立 `new_body` + blind prism 的 CAP_FACE,以及满足全部条件的 `LocOpe_DPrism` drafted extrusion 生成该 contract:单一闭合外环、无内环、单向 blind extent,且拉伸方向与实际端盖法向同向。`extrude_from_face` 直接拉伸该 B-rep 面,不能把 CAP_FACE 还原为原始草图。registry 只会让唯一 OCC `modified`/`preserved` relation 跨 @@ -244,8 +262,99 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 RP 通过(bbox delta `0.0008 mm`、体积相对误差 `0.00020848`、面积相对误差 `0.00021742`)。`00268467` 的 drafted 内环则明确保留 capability diagnostic 和可执行 前缀,不伪造可绑定 selector。 -3. 建立统一 selector resolver:owner body、source feature、output role、几何签名、 - snapshot 和唯一性证明。找不到或多解必须稳定诊断。 + `TopologyDeltaRelation` 还必须显式记录 `source_kind` 与 `result_kind`,禁止把 face、edge + 或其它 subshape 的 relation 混作同一种后继。每个 `TopologyDelta` 必须随操作输出 input/ + output snapshot、body member 和 history 状态证据;当前 contract 的状态为 `proven`、 + `partial`、`unknown` 或 `rejected`,并可带原因。只有完整、可追溯的 kernel relation 才能 + 进入 lineage;`IsDeleted` 不等于存在 replacement,缺失的 `IsDeleted` 也不等于 deleted。 + boolean 的 section edge 可以作为 builder 生成事实记录,但没有 source-face 的精确 relation + 时不得被提升为 source-qualified lineage。当前 `INTERSECT@1511` 仅有一个受限 consumer: + explicit `boolean_bodies` 的单一 target/tool subtract/intersect 中,两个 direct-prism + `CAP_FACE`/`SWEPT_FACE` input 必须分别由 OCC `Generated(face)` 返回同一 `SectionEdges()` + edge,且三个 handle 都以 `IsSame` 绑定 input/final snapshot;resolver 再要求唯一 relation、 + `intersection`/`one` policy 和 active result member。另有一条同样受限的 primary `REMOVE` 路径: + immediate `extrude_cut_blind` 只可在一个 active target solid/member、一个 direct-prism tool、完整 + source anchor/history 且 source query 没有未核实 disambiguation 时注册 `transient:` tool + snapshot;它仅作紧随 cut 的 source-qualified section 历史输入,永不成为 active/selectable topology。 + 普通 `SectionEdges()`、多输出、keep-tools、多 member、COPY/transform/pattern/TDD/IMPRINT/non-prism、 + primary query 的未核实 `disambiguationData` inputs 均不执行。 + 这不构成通用 INTERSECT coverage,真实语料 RP 矩阵仍待补齐。这些记录是通用 topology 事实,不是由 stable ID + 或最终几何相似性反推的替代品。 +3. 建立统一 selector resolver,并让 runtime 与诊断性的 prefix binder 共用 + `TopologyRegistry.resolve` 及已导出的 topology facts。resolver 按“语义 source -> kernel + lineage -> derivation policy/cardinality -> active body member”顺序绑定,报告实际分支使用的 + `resolution_mode` 和 evidence,而不是从 selector 字段事后推断模式。stable ID 只可定位 + source,不能直接在 active record 上成功;owner、output role 或 geometry 也不能绕过完整 + lineage、允许的 derivation policy、operation-wide cardinality 与 active-body 证明。 + `none`、`source_qualified`、`one` 与 `all_fragments` 都必须有明确、可审计的语义:merge 即使 + 最终只有一个 result 仍按 N:1 检查,`all_fragments` 遇到任一分支缺失或 partial 必须拒绝。 + resolver 失败不得自动改走 legacy 成功路径;只有已文档化的 legacy explicit-geometry selector + 可保留 geometry fallback,provenance/COPY instance selector 不得回退到几何相近、current body + 或普通 context selector。找不到、多解、历史不完整、版本不支持和 policy/cardinality 不满足 + 都必须稳定诊断并保留可执行前缀。 + `selector_intent.version` 是唯一 canonical intent version;顶层 + `selector_intent_version` 仅为兼容字段,存在时必须一致。schema 可接收只有 + `selector_intent` 的语义 selector,并将 FeatureScript 标准库路径、精确 import version、全部 direct + import 列表与 source language version 元数据和 runtime ID 分离保存。lowering 不得为缺失 source + version 虚构 `"0"`;resolver 应返回 `selector_query_version_unknown`。query family/language + 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 标为 + 端到端可执行。后两者只适用于紧邻独立、无 draft、`new_body` blind prism 的 consumer: + adapter 必须保留 direct source-profile edge/vertex construction handle,并分别由 + `BRepPrimAPI_MakePrism.Generated(edge)` 的 edge -> side-face 或 + `Generated(vertex)` 的 vertex -> vertical-edge relation 完整证明;resolver 再检查 + `boundary` policy、source qualification 和 active member。`00111611` F3、`00974931` F2、 + `00594348` F2 和 `00000715` F3 是这些受限路径的真实语料证据。`SWEPT_EDGE` 的 direct + profile contract 现还接受一个 analytic contour region 的 hole wires:adapter 将外环和全部内环一次 + 交给 `BRepBuilderAPI_MakeFace`,只在 source edge/vertex 经 finished-face `IsSame` 验证后登记 + anchor;`00007264` F2 的四个 selector 已 `kernel_lineage` resolved(最终 comparison rejected), + `00039669` F2 strict/RP passing,`00151159` F2 RP passing。含 hole 的多 region profile 必须继续走 + 既有 `Face.make_holes` geometry path 且不登记 anchor,避免改变 prism combine 语义;`00248377` 是 + 该拒绝/保留边界,仍导出 F4 checkpoint。`00594348` 的 immediate + shell 仅接受 direct source 的 non-construction line wall,并从同一 edge -> face relation + 解析两张 removal face;它不输出 geometry 或 stable-ID hint。direct all-circle construction 可保留一个 + annular hole 的 outer/inner source edge;多个独立 direct circle region 只在每个 prism + result 都仍位于 final snapshot 时保留各自 relation(当前为原子证据,尚非多语料完成项)。 + `CAP_FACE` 还可作为上述受限 `up_to_surface` nested reference 或 immediate shell removal + face 的 cap role;`00212904` F2 与 `00789939` F2 分别以 `extrude.start` 和 `extrude.end` + 的 `operation_role` 执行。fresh `output/cap-face-immediate-shell-00212904-20260910-v1` + 证明前者执行到 F5,但 strict/RP 均拒绝;fresh + `output/cap-face-immediate-shell-00789939-20260910-v1` 证明 F2 及后续同一 direct-cap + shell F5 都执行,F6 仍为 `selector_query_unsupported` 并保留 F5 STEP checkpoint。fresh + `output/core-17-cap-face-immediate-shell-20260910-v1` 的 `tier=all` / pipeline / RP run 为 + 17/17 completed、1 `rebuilt_strict`、1 `rebuilt_approximate`、6 `rebuilt_rejected`、8 + `rebuild_failed`、1 `comparison_timeout`;其中 `00212904` 的分类由 selector failure 变为 + executable-but-rejected,不能将此记为几何相似通过。它们不允许在 producer 后续 mutation、 + non-immediate owner、stable ID、binding ID 或 geometry fallback 中继续解析; + 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。 + `00925274` 的 F3 以 `extrude.start` 的 `operation_role` 解析;F6、F9、F12 与 F15 都以 F1 E0 + side-wall `kernel_lineage` 依次经过 complete/proven 的 F3、F6、F9、F12 target-side subtract + continuation 解析。单实体 primary cut 现在始终保留其 cut builder 的 target snapshot history;仅 + transient tool snapshot 仍要求 direct-prism tool history,故没有扩大 source-qualified `INTERSECT` + 的工具可选范围。resolver 只在 selector 请求的 source/result topology kind 内计算 complete/proven + final-snapshot relation 的基数;没有 final snapshot member 的 partial intermediate handle 及 cross-kind + section diagnostic 保留在 delta 中但不伪造成 face fragment。任何绑定到 final result 的 partial、split、 + merge、inactive 或无 history relation 仍稳定拒绝,绝不以 geometry、stable ID、binding ID 或 current + body 猜测。fresh `output/core-17-swept-face-final-20260910` 的 `tier=all` / pipeline / RP run 完成 + 17/17:2 `rebuilt_strict`(`00694309`、`00925274`)、1 `rebuilt_approximate`、6 + `rebuilt_rejected`、7 `rebuild_failed`、1 `comparison_timeout`;`00925274` 为 strict/RP pass,不能 + 推广为一般到面终止或 general SWEPT_FACE completion。受控完整 `0040` shard 的 fresh + `output/shard-0040-swept-face-final-20260910-v2` 也完成 71/71:7 strict、9 approximate、16 rejected、 + 34 rebuild-failed、3 runtime-ineligible、1 timeout、1 deferred,并保留 32 个 comparison artifact;它是 + 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 完成。 + CADFS production rebuild 以一个 `IncrementalCdslExecution` 按 history 顺序执行:每个 feature + 在执行前只对本 session 已登记的 topology snapshot 调用 resolver,随后立即执行并登记新的 + body/history evidence。`binding_feature_id` 读取同一 session 保留的历史 snapshot,不能通过 + 重建 prefix 或复制 OCC body 获得结果。feature 或 selector 失败时,系统直接从该 session 导出 + 最后已执行 checkpoint;不会为尝试更早 prefix 而重放历史。`bind_candidate_selectors` 仍可为 + 不完整的外部/诊断 CDSL 显式运行 legacy prefix adapter,但不得进入 production success/failure + path,也不得将 provenance 或 output-role selector 降级为 geometry binding。 4. 完成草图 region/wire 模型:多轮廓、内环、开口 reference geometry、B-spline/ellipse、 profile query、显式 construction 和退化检测。 非周期 `skFitSpline` 的两点受限变体现已贯通:仅当端点不同、同时给出两端导数,且 @@ -263,7 +372,62 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 `SWEPT_EDGE`、`OFFSET_FACE`、`INTERSECT`、`MID_CAP_EDGE`,将其物化为可追溯的 profile/wire,而不是复用原草图或写入样本特例。现有 direct-prism,以及单闭合无内环、 法向同向的 `LocOpe_DPrism` draft CAP_FACE,是受限例外:它们以 builder-proven output - role 直接消费物理 B-rep 面,未试图物化或重建 profile。 + role 直接消费物理 B-rep 面,未试图物化或重建 profile。`OFFSET_FACE` 的端到端 status + 同样只限于 capability matrix 所列、带 TDD source cap 的 direct shell output role;它不代表 + 任意 offset query 已可重放。`SWEPT_FACE` 与 `SWEPT_EDGE` 现在各有一条 1511 direct-prism + 纵向 contract:source profile edge 经 `Generated(edge)` 到 side face,以及由完整 incident + source-edge set 唯一命名的 source vertex 经 `Generated(vertex)` 到 vertical edge。lowering 只在 + producer 紧邻、独立、无 draft、`new_body` blind prism 且原 profile 未变时产生语义 selector; + adapter 只将 direct wire/face construction 中 exact `IsSame` anchor 注册为 transient source fact, + resolver 则只沿 complete/proven 跨 kind relation 解析。单一 analytic region 的 direct hole wires + 也可在 finished face identity 未改变时提供这份 evidence:`00007264` 的四个 vertical-edge selector + 皆已解析,`00039669` strict/RP passing,`00151159` RP passing;含 hole 的 multi-region profile + 继续使用旧 geometry path 并明确不产生 anchor。`00111611` F3 的圆 edge -> side face、 + `00974931` F2 的 direct annulus outer edge -> side face,与 `00000715` F3 的两个 incident-edge + 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 + 或上述两个样本提前完成。 + `CAP_EDGE` 现在也有一条独立的 1511 direct-prism contract,但它不物化 profile/wire:source + sketch entity 必须仍对应一个 exact direct-profile edge,query 的 `isStart` 被保存为 + `lineage_role: extrude.start|extrude.end`,adapter 以 + `BRepPrimAPI_MakePrism.FirstShape(source_edge)` / `LastShape(source_edge)` 取得 cap edge,并以 + final snapshot 的 `IsSame` 证明该 handle 未失效。resolver 只沿这个 source edge 与 role-qualified + `boundary` relation 解析;stable ID、几何、owner propagation 和 current-body 均不是回退路径。 + `00021014` F2 的九个 CAP_EDGE selector 覆盖两个 role,完整 history strict/RP 通过。该例外只接受 + 紧邻、独立、无 draft 的 `new_body` blind prism 与未变 direct profile。一个 direct all-circle + annulus(一个外圆和一个包含的内圆)也可保留 outer/inner 的 exact source anchor:`00479470`、 + `00501170`、`00526649` 在 F2 的 CAP_EDGE consumer 后 RP 通过,`00621329` strict/RP 通过; + `00566233` 的两个 F2 selector 已由 kernel lineage 解析,随后才因 OCC chamfer feasibility 失败, + `00614954`/`00678961` 也都在 F2 解析后才被后续未支持 query 截断。对于线段外环加圆孔,solver + 仅给同一 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。 + 不完整 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 继续拒绝。 + 对 direct-prism `CAP_EDGE` 的 fillet/chamfer consumer,boundary-only policy 另可在运行时存在 + 完整 kernel continuation 时扩展为 `boundary + continuation`:每一跳都必须为 exact + `preserved`/`modified` relation、`coverage: complete`、`status: proven`,且 operation-wide + cardinality 保持 1:1;任何 partial、split/merge、inactive result 或无 history 都稳定拒绝。 + `00694309` 证明这一条:F4 的 F1 `extrude.start` cap edge 经 F3 primary-cut `preserved` + continuation 解析为 `body:f_F3:edge:1`,完整 pipeline `output/cap-edge-continuation-after-primary-cut-20260910-v1` + 的三 feature history strict/RP passing。任何 boolean/SPLIT、COPY/pattern 或其它中间操作也必须 + 提供同样完整的 1:1 relation;它们目前没有独立的 corpus coverage,不能仅按 owner/operation 名称 + 通过。这个 contract 也不接受任意 CAP_EDGE source,且没有 geometry、stable-ID、 + binding-ID 或 current-body fallback。fresh + `output/core-17-cap-edge-continuation-20260910-v1` 的 `tier=all` / pipeline / RP run 完成 17/17: + 1 `rebuilt_strict`(`00694309`)、1 `rebuilt_approximate`、5 `rebuilt_rejected`、9 + `rebuild_failed`、1 `comparison_timeout`。这只确认该完整 relation contract 将此样本从 F3 + executable prefix 推进为实际 strict/RP pass,不能作为 CAP_EDGE family completion 或 general + boolean continuation 的证据。 + 共享 selector/runtime suite 为 261 passed、1 skipped;强制 core-17 + 重跑 `output/core-17-direct-prism-cap-edge-hole-20260910` 为 1 RP、5 rejected、10 rebuild-failed、1 + comparison timeout,与 selector-migration 基线分类一致。因此 `CAP_EDGE` family 与 P1 的通用派生 + profile 目标仍未完成。 `OFFSET_FACE` 现有一个独立的受限 profile 物化路径:只有 inward shell 直接消费一个 `new_body` blind additive prism,shell 恰移除该 prism 的一个 CAP,且 OFFSET query 恰指向 其未修改、单闭合凸线性 profile 中的一条非 construction line,lowering 才根据 source @@ -302,11 +466,23 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 `INTERSECT` 的第一条通用受限路径现以 CDSL `planar_imprint` 保存同一草图的原始 analytic entities、IMPRINT 面侧、可选 INTERSECT vertex order 与 fragment side;solver 仅转换这些精确曲线,adapter 用 OCP `BOPAlgo_Splitter` 切分有界 support face 后选取实际 - B-rep region。没有唯一 fragment、选中无界 support-boundary region、construction/source - alias、split failure 或不完整 query 都稳定拒绝,不把 B-spline/arc 采样成多边形,也不复用 - 整张草图。该路径覆盖 bounded line/arc/circle/ellipse/B-spline arrangement 的 shared - contract,但尚未覆盖不同 sketch、surface/topology producer、trim/copy/pattern 后继或完整 - FeatureScript IMPRINT query grammar,因此仍是 P1 部分完成项。工件 + B-rep region。top-level `IMPRINT FACE` 的 multi-face query 只有既有的 + `_profile_selection_sketch` 不能将它缩减为单一/圆形/open 专用 profile 时,才进入该 typed + arrangement profile;不能把 nested `INTERSECT` 误当作 outer profile family,也不能抢占 + existing circle/open/surface IMPRINT contracts。没有唯一 fragment、选中无界 support-boundary + region、construction/source alias、split failure 或不完整 query 都稳定拒绝,不把 B-spline/arc + 采样成多边形,也不复用整张草图。该路径覆盖 bounded line/arc/circle/ellipse/B-spline + arrangement 的 shared contract,但尚未覆盖不同 sketch、surface/topology producer、trim/copy/ + pattern 后继或完整 FeatureScript IMPRINT query grammar,因此仍是 P1 部分完成项。 + `output/planar-imprint-multiface-20260910` 的 seven-source forced RP matrix 验证这个收紧后的 + 分派:`00304488`、`00253824` rebuilt_rejected;`00354246` 在 F2 deferred `SWEPT_FACE` 时保留 + F1 checkpoint,`00306689` 在 F1 OCC `BRep_API: command not done` 失败,`00784127` 在 F1、 + `00782218` 在 F2 因 selected region unbounded 拒绝(后者保留 F1 checkpoint),`00429745` + 在 F1 OCC extrude 无有效 solid 时拒绝。该 matrix 不把任一 IMPRINT-derived profile 的下游 + `SWEPT_FACE` 视为完成:它们保留 source intent 并由 shared resolver 有界拒绝。当前 shared + selector/runtime suite 为 262 passed、1 skipped;强制 core-17 + `output/core-17-planar-imprint-multiface-20260910` 为 1 RP、5 rejected、10 rebuild-failed、1 + comparison timeout,与 selector-migration 基线一致。工件 `output/planar-imprint-intersect-20260909-runtime` 是 CAP_FACE 扩展前的历史工件: `00082324` 与当时的 `00835610` 都保留可执行 STEP 但 `rp.passed == false`,`00071859` 和 `00093912` 分别保留既有 `TopoDS::Solid` 和 revolution-segmentation runtime failure。 @@ -350,6 +526,26 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 作为受限 CDSL output-role selector 供 downstream feature 消费;同一 contract 还支持 independent blind prism CAP_FACE 的 `extrude_from_face`。这些路径仍不能代替完整的 derived-topology selector 语义,CADFS lowering 也尚未以此表示一般 CAP/SWEPT query。 + capability matrix 中 `SWEPT_FACE@1511` 与 `SWEPT_EDGE@1511` 另有 direct-prism 的受限 + end-to-end contract:独立、无 draft、`new_body` blind prism 的 direct source profile edge/ + 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 + `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 的完成声明。 2. `shell`:单一 selected solid 的 direct-builder topology delta 与有限 output-role evidence 已覆盖。CADFS lowering 现额外接受一个受限的 removal selector:直接 blind 或 two-sided linear extrusion 的立即后继 shell,可从同一未修改 source profile 中唯一的、非 @@ -413,7 +609,22 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 都不继承该 contract。该受限路径不覆盖 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/ + 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。 + `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 或完整孔型/螺纹语义。 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` @@ -471,6 +682,69 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 `output/core-17-cplane-offset-opposite-20260909` 分类保持 3 strict、3 RP、9 rejected、1 rebuild failure、1 comparison timeout。它仍只是 cPlane 的一项通用 frame contract,不覆盖 曲面/曲线 attachment、退化输入或完整 derived-topology plane semantics。 + `LINE_ANGLE` 现额外覆盖单一 direct `skCircle` edge:FeatureScript 1511 的 + `cplane.fs::lineAnglePlane` 将该选择传入 `evAxis`,而同版本 `query.fs` 将 circle + 列为 `ALLOWS_AXIS`。lowering 因此只用 circle centre 与 source sketch plane normal + 作为 axis,并按同版本 `vector.fs::perpendicularVector` 的确定性阈值分支适配 zero-angle + direction,再按 + `angle` 和 `oppositeDirection` 的 signed angle 旋转;不会以 circle centre vertex、 + `CAP_EDGE`/`SWEPT_*`/COPY 等派生 topology 或 current body 替代这个 source contract。 + 该语义依据来自 MIT 许可的 Onshape standard-library mirror 的 1511.0 commit + `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 未实现而没有 + solid feature,pipeline 正确分类为 `runtime_ineligible`,没有将工作平面 lowering + 误报为 STEP/RP 成功。多实体 reference、direct arc/cylinder/cone 等其他 `evAxis` + 输入和一般 derived topology 仍未覆盖。focused shared selector/runtime suite 为 + `263 passed, 1 skipped`(142.98 s;仅既有 build123d deprecation 与 workplane + re-orthogonalization warnings)。强制 RP Core-17 refresh + `output/core-17-line-angle-direct-circle-20260910` 为 1 `comparison_timeout`、10 + `rebuild_failed`、1 `rebuilt_approximate`、5 `rebuilt_rejected`,与既有核心集分类一致; + 它只证明共享改动没有改写这些既有结果,不构成 generic `LINE_ANGLE` 或 STEP/RP success。 + 两实体 `LINE_ANGLE` 现额外覆盖 source-only contract:direct `skLineSegment`/`skCircle` + 提供 axis,默认 datum 或已 lower reference plane、direct `skPoint` 或 direct line endpoint + 提供第二 reference。实现严格镜像 1511 `cplane.fs::lineAnglePlane`:第一个 selection + 不是 axis 而第二个是 axis 时交换;两个非平行 axis 用第二 direction,平行 axis 用两 origin + 之差,plane 用 `cross(axis, plane.normal)`,point 用 `point - axis.origin`,再以 signed angle + 旋转 `cross(axis, secondInPlaneDirection)`。共线/零长度选择明确 defer。此 direct branch + 只接受未组合的 `sQuery`/`sketchEntityQuery`,不通过 `_query_line`、current body 或 geometry hint + 推导 CAP/SWEPT/COPY/trim/derived query; + 它继续引用 MIT-licensed Onshape standard-library mirror commit + `eaef87b22d3b15e9377d8b1c6cb701f8111b6a0c`,未引入依赖或复制外部代码。lowering regression + 覆盖 datum-first swap、axis-first、nonparallel/parallel-offset axes、direct point、reversed + point/axis、`oppositeDirection`、degenerate reject,并断言 direct helper 不接受 `CAP_EDGE`。 + 真实 matrix `output/cplane-line-angle-two-entity-matrix-20260910` 的六条直接 axis/default-plane + history(`00067847`、`00185138`、`00217351`、`00298026`、`00586156`、`00831400`)均无 cPlane + diagnostic 且保留 rebuild STEP:`00586156` strict/RP pass,`00298026` RP pass/strict diagnostic, + 前三条为独立 `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` + `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` 确实 + 将 arc 列为 `ALLOWS_AXIS`/`evAxis` 输入,但当前 9,347 条 corpus 未发现 direct `skArc` 被 + `LINE_ANGLE` 实际选择的 history;这只是待验证语义,不能提前宣称 direct-arc coverage。 + `CURVE_POINT` 现额外接受同一 direct source curve 的精确切线:`skArc.start/end` 由解析 + 圆心、半径和方向生成切线,`skFitSpline.start/end` 只在 FeatureScript 导出了相应 endpoint + derivative 时执行。它还受限支持 source token `E..internal`:必须是同一 direct、 + non-periodic `skFitSpline`,并有完整 interpolation points、centripetal parameters 与两端 + derivatives。lowerer 复用 runtime 的 `Edge.make_spline(..., scale=False)` 构造,再在显式 + parameter[index] 以 OCC `BRepAdaptor_Curve.D1` 读取导数,同时核验返回点仍等于 source + interpolation point;绝不以 adjacent interpolation-point chord 代替曲线切线。`00827798` + F1 与 `00845891` F3 是两个真实 B-spline endpoint 证据,`00456498` F1 (`E0.2.internal`) + 与 `00812557` F2 (`E0.3.internal`) 是 source interpolation-token 证据;`00057273` 的受控 + `skArc` endpoint 变体覆盖圆弧方向。derived suffix、越界 index、无完整 interpolation + data、无导数/periodic spline、curve/surface topology source 和退化 tangent 继续明确拒绝。 + 两条新增样本 pipeline `output/cplane-curve-point-bspline-interpolation-20260910` 均已无 + cPlane diagnostic;`00456498` 在后续 sweep OCC solid failure 停止,`00812557` 在后续 + sweep OCC valid-solid failure 停止,保留诊断和可用工件,不误报为整体成功。shared + selector/runtime suite 为 `277 passed, 1 skipped`(既有 workplane re-orthogonalization + warning)。RP Core-17 refresh `output/core-17-cplane-curve-point-bspline-interpolation-20260910` + 为 1 `comparison_timeout`、10 `rebuild_failed`、1 `rebuilt_approximate`、5 + `rebuilt_rejected`,与 prior semantic-selector baseline 分类一致;它不构成完整 cPlane + 或 generic curve-attachment completion。 ### P3:尚未支持的 FeatureScript 操作 @@ -495,14 +769,22 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 一项能力仅在满足以下条件时可标记完成: -1. 有版本化的 CDSL 表达,且 schema/semantic validation 拒绝不完整或歧义输入。 +1. 有版本化的 CDSL 表达,且 schema/semantic validation 拒绝不完整或歧义输入。对 + FeatureScript query,`selector_intent.version`、source query family、精确 language version 和直接 + standard-library import path/version 必须有明确契约;缺版本和未注册 family/language/library revision + 必须诊断,不能以 legacy 顶层版本、数字前缀、相同路径或默认版本补齐。 2. lowering 仅根据 FeatureScript source 产生该表达,并保留 source feature、selector 和 body provenance。仅有 runtime CDSL contract 而未由 lowering 产生的能力必须标为 部分完成。 -3. runtime/adapter 按通用算法执行,记录结果 body 和 topology delta,不依赖样本信息。 +3. runtime/adapter 按通用算法执行,记录结果 body 和 topology delta,不依赖样本信息。派生 + selector 必须具有 source/result kind、输入/输出 snapshot、body member、history status 及 + kernel relation evidence;resolver 必须据此证明 derivation policy、operation-wide cardinality + 和 active member,并输出实际 `resolution_mode` 与 evidence。 4. 能力矩阵记录对应 FeatureScript API/query 的源版本文档或标准库依据、采用的语义和 未覆盖边界;原子语义矩阵包含正向、边界和拒绝测试,且至少多个真实语料样本覆盖 - 不同几何和生命周期组合。 + 不同几何和生命周期组合。只有 parser/AST 或 resolver unit test 的 query family 只能标为 + resolver-level/部分完成;必须同时具备 lowering、schema、adapter、consumer 和多个真实样本 + 的端到端证据后才可标为 complete。 5. 受影响核心集、扩展集和全量 shard 有可复现结果,工程相似通过率、失败数和剩余 exception 均更新到本地台账。 6. 代码审查确认没有 sample-specific 分支、gold STEP 参数回填、隐式默认尺寸或为 @@ -518,3 +800,54 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 全量目标完成时,报告必须按“工程相似”“严格一致”“source exception”“未实现能力” 和“基础设施失败”分别给出分母、样本 ID、工件和原因。任何仍可执行的模型都必须 保留输出,不能因未满足最终几何目标而丢弃。 + +## Selector 迁移台账(2026-09-09) + +已完成第一条迁移边界,而非完整 query-family 实现:CDSL schema 允许保留未知的 +大写 FeatureScript query family;CADFS lowering 对全部 topology fallback 写入 source +AST、版本、语义 family 和 `feature_script_query` evidence。未在版本化 capability matrix +登记的 query 使用非空 derivation policy 加 `multiplicity: none`,因此 resolver 在 stable ID、 +geometry、owner 或 current-body fallback 前稳定返回 `selector_query_unsupported`。嵌套的 +extent、COPY 和 INTERSECT selector 同样递归写入 source metadata;COPY/INTERSECT 保留外层 +query family,不错误继承其内部 CAP/SWEPT component。显式 datum plane 单独为 +`GEOMETRIC` / `explicit_datum`,不构成派生 topology 的逃逸通道。 + +此前 core-17 candidate inventory 中有 14 个 runtime-snapshot edge、7 个 runtime-snapshot +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 必须保留 +候选、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 的已证明边界内执行。 + +2026-09-10 的 source-version migration 补齐了 import-level evidence:parser 逐条保留直接 +`onshape/std/*` import 的 `{path, version}`,CDSL 顶层 `source_featurescript` 和嵌套 selector +`source_query` 同时保留 primary import 与完整 import list。resolver 对 provenance selector 先要求 +language version、direct `standard_library` 和 `standard_library_version`,再按 capability matrix 的精确 +三元组匹配。缺 import version 返回 `selector_query_version_unknown`;未登记 library revision 返回 +`selector_query_unsupported`,且不会进入 stable-ID、geometry、owner 或 current-body fallback。全量 +9,347 条 source scan 记录到 `geometry.fs` 与 FeatureScript 版本一一成对变化;只有现有 +`1511` / `geometry.fs@1511.0` direct contracts 保持 executable。该迁移只完善来源证据门槛,不新增 +`COPY`、`SPLIT` 或其他 query family 的执行能力。验证包括 parser/lowering/provenance suite +`140 passed`、runtime foundation `130 passed, 1 skipped`、以及 fresh `00212904` RP pipeline +(完整 F1--F5 replay,`rebuilt_rejected`)和 `core-17-selector-standard-library-revision-20260910-v1` +(1 strict、1 approximate、6 rejected、8 rebuild-failed、1 timeout);所有结果保留各自 STEP/checkpoint。 +同步的 integration/binder regression 也确认现有 immediate CAP shell contract 的真实前缀:`00789939` +执行 F2/F5 后在 F6 停止并保留两-solid F5 checkpoint,`00090436` 与 `00107631` 的 outward F1/F2 +prefix 都可 rebuild;combined selector/lowering/binding/integration suite 为 `168 passed`。 + +`intersection_of` vertex 是由 `ExecutionSession` 从已解析的 face component 直接计算的特殊 +consumer,因而不能绕开 outer selector 的 provenance gate。runtime 现将 nested/legacy version +一致性、FeatureScript source version、capability allow-list 和 derivation policy 抽为 +`TopologyRegistry.resolve` 与该 session path 共用的 contract:`multiplicity:none`、未知 version 或 +未登记 capability 的 outer `INTERSECT` 在任何 component resolve 或 OCC 求交前稳定拒绝。没有 +`selector_intent` 的既有显式几何 intersection selector 仍维持其 legacy contract。原子回归覆盖这三种 +拒绝以及 legacy 正向路径(`backend.tests.test_selector_provenance_contract`: 21 passed);这只关闭 +provenance bypass,不新增 general `INTERSECT`、vertex provenance 或 source-qualified section-edge 覆盖。 +当前代码的 shared selector/runtime suite 为 `264 passed, 1 skipped, 43 subtests`(142.57 s;仅既有 +build123d deprecation 与 workplane re-orthogonalization warnings)。真实 `00423838` 以 +`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 成功。 diff --git a/cadfs_to_cdsl/FEATURESCRIPT_QUERY_CAPABILITY_MATRIX.md b/cadfs_to_cdsl/FEATURESCRIPT_QUERY_CAPABILITY_MATRIX.md new file mode 100644 index 00000000..236392d2 --- /dev/null +++ b/cadfs_to_cdsl/FEATURESCRIPT_QUERY_CAPABILITY_MATRIX.md @@ -0,0 +1,54 @@ +# FeatureScript Query Capability Matrix + +This matrix is the versioned allow-list consumed by the CDSL selector resolver. +It records what the current runtime may execute, not what FeatureScript text it +can parse. A missing entry is intentionally rejected with +`selector_query_unsupported`; a missing source version is rejected with +`selector_query_version_unknown`. Every registered row currently requires the +direct source import `onshape/std/geometry.fs@1511.0` in addition to its +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. + +| 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. | +| `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. | + +## Source Sketch Path Queries (Not Runtime Selector Capabilities) + +This section is intentionally outside the resolver allow-list above. The query +does not select rebuilt body topology: it selects source sketch reference-wire +geometry while lowering a `sweep` path. It therefore creates no +`selector_intent`, kernel-lineage relation, or stable-ID fallback permission. + +| 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. | + +## Deferred source queries + +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 +`multiplicity: "none"` policy. Their geometry and legacy stable ID remain +diagnostic context only: `TopologyRegistry.resolve` rejects them before either +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. + +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` +failure with the executable F3 checkpoint exported. Equivalent bounded +rejections now cover unproven CAP/SWEPT/OFFSET/COPY/INTERSECT fallbacks; they do +not reduce the matrix's direct-prism coverage or claim general query support. + +The repository does not currently vendor the FeatureScript standard-library +sources for other versions or `makeQuery` encodings. They are not inferred +from a `15xx` prefix or a single corpus sample. diff --git a/cadfs_to_cdsl/featurescript_parser.py b/cadfs_to_cdsl/featurescript_parser.py index a2c1ecdc..cffe680a 100644 --- a/cadfs_to_cdsl/featurescript_parser.py +++ b/cadfs_to_cdsl/featurescript_parser.py @@ -6,6 +6,27 @@ from .featurescript_lexer import Token, lex from .ir import Call, FeatureIR, ModelIR, SketchIR +_IMPORT_RE = re.compile(r"\bimport\s*\(\s*(?P.*?)\s*\)\s*;", re.DOTALL) +_IMPORT_PATH_RE = re.compile(r"\bpath\s*:\s*['\"]([^'\"]+)['\"]") +_IMPORT_VERSION_RE = re.compile(r"\bversion\s*:\s*['\"]([^'\"]+)['\"]") + + +def _standard_library_imports(source: str) -> list[dict[str, str]]: + """Preserve each directly imported Onshape standard-library revision.""" + imports: list[dict[str, str]] = [] + for match in _IMPORT_RE.finditer(source): + arguments = match.group("arguments") + path = _IMPORT_PATH_RE.search(arguments) + if path is None or "onshape/std/" not in path.group(1): + continue + item = {"path": path.group(1)} + version = _IMPORT_VERSION_RE.search(arguments) + if version is not None: + item["version"] = version.group(1) + imports.append(item) + return imports + + def _string(value: Any) -> Any: if isinstance(value, str) and len(value) >= 2 and value[0] in {'"', "'"} and value[-1] == value[0]: try: @@ -133,15 +154,15 @@ def _arg_map(call: Call) -> dict[str, Any]: 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) - standard_library = re.search( - r"\bimport\s*\(\s*path\s*:\s*[\"']([^\"']*onshape/std/[^\"']*)[\"']", - source, - ) + standard_library_imports = _standard_library_imports(source) + primary_standard_library = standard_library_imports[0] if standard_library_imports else {} model = ModelIR( sample_id, raw_source=source, featurescript_version=version.group(1) if version else None, - standard_library=standard_library.group(1) if standard_library else None, + standard_library=primary_standard_library.get("path"), + standard_library_version=primary_standard_library.get("version"), + standard_library_imports=standard_library_imports, ) for call in calls: if call.name == "newSketch": diff --git a/cadfs_to_cdsl/ir.py b/cadfs_to_cdsl/ir.py index 40568e76..ccfce3b2 100644 --- a/cadfs_to_cdsl/ir.py +++ b/cadfs_to_cdsl/ir.py @@ -41,3 +41,5 @@ class ModelIR: # selector can be audited against the FeatureScript API contract it used. featurescript_version: str | None = None standard_library: str | None = None + standard_library_version: str | None = None + standard_library_imports: list[dict[str, str]] = field(default_factory=list) diff --git a/cadfs_to_cdsl/lowering.py b/cadfs_to_cdsl/lowering.py index dacf8fac..34a12281 100644 --- a/cadfs_to_cdsl/lowering.py +++ b/cadfs_to_cdsl/lowering.py @@ -66,9 +66,11 @@ def _selector_intent( "version": "1.0", "kind": kind, "query_family": query_family, - # This placeholder is populated from the enclosing ModelIR once the - # FeatureScript source header has been parsed. - "source_query": {"ast": query.ast, "featurescript_version": "0"}, + # Version metadata is populated from the enclosing ModelIR only when + # the exported source actually contains it. ``"0"`` used to look + # like a real FeatureScript version and caused downstream capability + # checks to make an unjustified compatibility decision. + "source_query": {"ast": query.ast}, "derivation_policy": {"allowed": list(allowed), "multiplicity": multiplicity}, "evidence": evidence, } @@ -81,21 +83,75 @@ def _selector_intent( return intent +def _deferred_featurescript_selector_intent(value: Any, *, kind: str) -> dict[str, Any]: + """Preserve an unsupported CADFS topology query without authorizing hints. + + Geometry remains useful diagnostic context, but it cannot be mistaken for + an implementation of the source query. A non-empty policy keeps the + intent structurally valid while ``none`` makes the resolver reject it + before stable-ID or geometry matching. + """ + query = parse_query(value) + try: + _call, _owner, family, _query_kind, _definition = _direct_make_query(value) + except ValueError: + family = str(query.topology_type or "FEATURESCRIPT_QUERY").rsplit(".", 1)[-1].upper() + if not family or not family.replace("_", "").isalnum() or not family[0].isalpha(): + family = "FEATURESCRIPT_QUERY" + return _selector_intent( + value, + query_family=family, + kind=kind, + evidence="feature_script_query", + allowed=("continuation",), + multiplicity="none", + disambiguation={ + "type": "deferred_source_query", + "query_combinators": list(query.query_combinators), + "filters": list(query.filters), + "body_scope": list(query.body_scope), + "source_refs": [ + {"sketch_id": sketch_id, "entity_id": entity_id} + for sketch_id, entity_id in _source_refs(value) + ], + }, + ) + + +def _explicit_datum_selector_intent(value: Any, *, kind: str) -> dict[str, Any]: + """Mark a source datum as an explicit geometric, not lineage, selector.""" + return _selector_intent( + value, + query_family="GEOMETRIC", + kind=kind, + evidence="explicit_datum", + ) + + def _finalize_selector_intents(cdsl: dict[str, Any], model: ModelIR) -> None: - """Bind source-version metadata after all FeatureScript selectors lower.""" - source_version = model.featurescript_version or "0" - for feature in cdsl.get("features") or []: - for selector in feature.get("selectors") or []: - intent = selector.get("selector_intent") if isinstance(selector, dict) else None - if not isinstance(intent, dict): - continue - selector["selector_intent_version"] = "1.0" + """Bind source-version metadata for top-level and nested selectors.""" + source_version = model.featurescript_version + stack: list[Any] = [cdsl] + while stack: + value = stack.pop() + if isinstance(value, list): + stack.extend(value) + continue + if not isinstance(value, dict): + continue + intent = value.get("selector_intent") + if isinstance(intent, dict): source_query = intent.get("source_query") - if not isinstance(source_query, dict): - continue - source_query["featurescript_version"] = source_version - if model.standard_library: - source_query["standard_library"] = model.standard_library + if isinstance(source_query, dict): + if source_version: + source_query["featurescript_version"] = source_version + if model.standard_library: + source_query["standard_library"] = model.standard_library + if model.standard_library_version: + source_query["standard_library_version"] = model.standard_library_version + if model.standard_library_imports: + source_query["standard_library_imports"] = deepcopy(model.standard_library_imports) + stack.extend(value.values()) def _bool(value: Any) -> bool: @@ -359,12 +415,68 @@ def _extent_reference( feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], + *, + feature_by_id: dict[str, dict[str, Any]] | None = None, + sketches_by_id: dict[str, dict[str, Any]] | None = None, + previous: list[str] | None = None, + allow_cap_output_role: bool = False, + allow_direct_prism_swept_lineage: bool = False, + featurescript_version: str | None = None, ) -> dict[str, Any]: query = parse_query(value) if query.kind not in {expected_kind, f"entitytype.{expected_kind}"}: raise ValueError(f"extrude extent target is not a {expected_kind}") - if expected_kind == "face" and query.topology_type in {"CAP_FACE", "SWEPT_FACE"}: - return _face_reference(value, feature_frames, sketch_by_source, entity_by_sketch) + if expected_kind == "face": + # An up-to-surface reference may consume a direct cap only while its + # producing prism is the active preceding result. A later body + # mutation, composed query, or unsupported producer keeps the source + # query as deferred evidence instead of choosing a similar face. + cap_output_role = ( + _cap_face_output_role_selector(value, feature_by_id or {}, sketches_by_id or {}) + if feature_by_id is not None and sketches_by_id is not None + else None + ) + if ( + allow_cap_output_role + and cap_output_role is not None + and previous[-1:] == [cap_output_role["owner_feature_id"]] + ): + return 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 + # intent for a one-sided extent; all other face queries stay deferred. + if ( + allow_direct_prism_swept_lineage + and query.topology_type == "SWEPT_FACE" + and query.owner_feature + and feature_by_id is not None + and sketches_by_id is not None + ): + swept_lineage = _direct_prism_swept_selector( + value, + owner=query.owner_feature, + 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 or [], + featurescript_version=featurescript_version, + allow_continuation=True, + ) + if swept_lineage is not None: + return swept_lineage + if expected_kind == "body": + swept_body = _direct_swept_body_extent_selector( + value, + feature_by_id=feature_by_id or {}, + previous=previous or [], + featurescript_version=featurescript_version, + ) + 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}" @@ -374,6 +486,7 @@ def _extent_reference( "stable_id": f"cadfs_{owner}_{expected_kind}", "source": "solidworks", "confidence": 1.0, + "selector_intent": _deferred_featurescript_selector_intent(value, kind=expected_kind), } return reference @@ -395,6 +508,56 @@ def _direct_make_query(value: Any) -> tuple[Call, str, str, str, dict[str, Any]] return current, owner, topology, kind, definition +def _direct_swept_body_extent_selector( + value: Any, + *, + feature_by_id: dict[str, dict[str, Any]], + previous: list[str], + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Lower one direct active body query for a one-sided ``up_to_body`` extent. + + A single-item ``qUnion`` is the FeatureScript set identity, but it does not + grant general query composition. The producer has to be the immediately + active independent prism, so runtime can prove the selected body from the + body graph without using a stable ID, aggregate body, or geometry score. + """ + if featurescript_version != "1511": + return None + try: + _call, owner, topology, kind, _definition = _direct_make_query(value) + except ValueError: + return None + producer_id = f"f_{owner}" + producer = feature_by_id.get(producer_id) or {} + params = producer.get("params") or {} + if ( + topology != "SWEPT_BODY" + or kind not in {"body", "entitytype.body"} + 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 + intent = _selector_intent( + value, + query_family="SWEPT_BODY", + kind="body", + evidence="active_body_member", + allowed=("boundary",), + ) + intent["body_member_contract"] = "direct_new_body" + return { + "kind": "body", + "owner_feature_id": producer_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": intent, + } + + def _face_reference( value: Any, feature_frames: dict[str, dict[str, Any]], @@ -475,6 +638,7 @@ def _face_reference( else: raise ValueError("swept face source entity is unsupported") reference["geometry"] = geometry + reference["selector_intent"] = _deferred_featurescript_selector_intent(value, kind="face") if binding_feature_id is not None: reference["binding_feature_id"] = binding_feature_id if match_mode is not None: reference["match_mode"] = match_mode return reference @@ -488,6 +652,7 @@ def _direct_linear_extrude_swept_face_shell_reference( entity_by_sketch: dict[str, dict[str, dict[str, Any]]], feature_by_id: dict[str, dict[str, Any]], previous: list[str], + featurescript_version: str | None, ) -> dict[str, Any]: """Lower one source-proven linear-extrude side wall for a shell removal. @@ -550,6 +715,20 @@ def _direct_linear_extrude_swept_face_shell_reference( "shell_face_selector", "current CDSL shell SWEPT_FACE requires one original non-construction source line", ) + lineage_selector = _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, + ) + if lineage_selector is not None: + return lineage_selector return _face_reference(value, feature_frames, sketch_by_source, entity_by_sketch) @@ -669,6 +848,9 @@ def _pattern_copy_face_reference( rotation=(axis, angle), ) reference["owner_match_required"] = True + # The outer COPY query, not its CAP/SWEPT source, defines the semantic + # request for the pattern instance. + reference["selector_intent"] = _deferred_featurescript_selector_intent(value, kind="face") return reference @@ -706,6 +888,7 @@ def _intersection_vertex_reference( "source": "solidworks", "confidence": 1.0, "intersection_of": components, + "selector_intent": _deferred_featurescript_selector_intent(value, kind="vertex"), } @@ -816,10 +999,15 @@ def _split_line_vertices(edges: list[dict[str, Any]]) -> list[dict[str, Any]]: if 1e-8 < parameter < 1.0 - 1e-8 and cross <= 1e-7 * max(1.0, math.sqrt(length_squared)): cuts.add(parameter) ordered = sorted(cuts) + # A line cut by an intervening profile vertex no longer maps one-to-one + # to its FeatureScript source entity. Keep the semantic source anchor + # only when this operation retained the original B-rep edge intact. + preserve_source = len(ordered) == 2 and isinstance(edge.get("source_entity_id"), str) output.extend({ "type": "line", "start": [start[0] + direction[0] * left, start[1] + direction[1] * left], "end": [start[0] + direction[0] * right, start[1] + direction[1] * right], + **({"source_entity_id": edge["source_entity_id"]} if preserve_source else {}), } for left, right in zip(ordered, ordered[1:])) return output @@ -909,6 +1097,19 @@ def _centripetal_parameters(points: list[list[float]]) -> list[float]: def _lower_sketch(sketch: SketchIR, plane: dict[str, Any], allow_open: bool = False) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: segments: list[dict[str, Any]] = []; explicit_construction: list[dict[str, Any]] = []; entities: dict[str, dict[str, Any]] = {}; points: list[dict[str, Any]] = []; unsupported = [] + + def sketch_result(profile: dict[str, Any], *, role: str | None = None) -> dict[str, Any]: + result = { + "id": f"sketch_{sketch.feature_id}", + "name": sketch.feature_id, + "source_sketch_id": sketch.feature_id, + "workplane": plane, + "profile": profile, + } + if role is not None: + result["role"] = role + return result + for entity in sketch.entities: p = entity.params if entity.operation == "skPoint": @@ -954,6 +1155,10 @@ def _lower_sketch(sketch: SketchIR, plane: dict[str, Any], allow_open: bool = Fa entities[entity.feature_id] = {**item, "construction": True} explicit_construction.append(item) else: + # This is source construction provenance, not a geometric + # selector hint. Derived or split segments deliberately do not + # inherit this identity later in lowering. + item["source_entity_id"] = entity.feature_id entities[entity.feature_id] = item segments.append(item) if unsupported: raise ValueError("unsupported sketch entities: " + ",".join(sorted(set(unsupported)))) @@ -961,9 +1166,14 @@ def _lower_sketch(sketch: SketchIR, plane: dict[str, Any], allow_open: bool = Fa if not segments: profile: dict[str, Any] = {"type": "analytic_contours", "contours": []} if explicit_construction: profile["construction"] = explicit_construction - return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities + return sketch_result(profile, role="reference"), entities if len(segments) == 1 and segments[0]["type"] == "circle" and not explicit_construction: - profile = {"type": "circle", "center": segments[0]["center"], "radius_mm": segments[0]["radius_mm"]} + profile = { + "type": "circle", + "center": segments[0]["center"], + "radius_mm": segments[0]["radius_mm"], + "source_entity_id": segments[0]["source_entity_id"], + } else: contours, open_segments = _contours(segments) if open_segments: @@ -973,15 +1183,15 @@ def _lower_sketch(sketch: SketchIR, plane: dict[str, Any], allow_open: bool = Fa if not contours: if not allow_open: raise OpenSketchProfileError(f"sketch has {len(open_segments)} open non-construction segment(s)") profile = {"type": "analytic_contours", "contours": [], "construction": explicit_construction + open_segments} - return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities + return sketch_result(profile, role="reference"), entities explicit_construction = [*explicit_construction, *open_segments] construction = list(explicit_construction) if not contours: profile = {"type": "analytic_contours", "contours": [], "construction": construction} - return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities + return sketch_result(profile, role="reference"), entities profile = {"type": "analytic_contours", "contours": contours} if construction: profile["construction"] = construction - return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile}, entities + return sketch_result(profile), entities def _queries(value: Any) -> list[Any]: @@ -1222,6 +1432,430 @@ def _profile_matches_direct_source(selected: dict[str, Any], source: dict[str, A ) +def _direct_profile_source_entity_ids(profile_sketch: dict[str, Any]) -> set[str]: + """Return only source entities still represented by one direct profile edge. + + This follows the explicit source labels carried by the CDSL profile. A + source entity dropped by region selection or split during contour + preparation is intentionally absent instead of being recovered by shape + comparison. + """ + 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 _direct_prism_swept_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, + allow_continuation: bool = False, +) -> dict[str, Any] | None: + """Lower one direct source-profile prism query to kernel lineage intent. + + This is deliberately narrower than generic SWEPT support. By default the + producer must be the immediately preceding independent blind prism. A + specifically enabled consumer may opt into proven continuation: it still + requires the complete direct source sketch, and the runtime must prove + every boundary/continuation edge to the active body. Any + IMPRINT-derived subset, split source edge, draft, fused result, or other + generator keeps the existing bounded diagnostic path. + """ + query = parse_query(value) + 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 {} + 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 + immediate_producer = previous[-1:] == [producer_id] + if ( + producer.get("atomic_id") not in {"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 (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 + source_ids = _direct_profile_source_entity_ids(profile_sketch) + refs = _source_refs(value) + if topology == "CAP_EDGE": + if ( + selector_kind != "edge" + or query.is_start is None + or len(refs) != 1 + or refs[0][0] != profile_source + or query.source_sketch != profile_source + or query.source_entity != refs[0][1] + or refs[0][1] not in source_ids + ): + return None + entity = (entity_by_sketch.get(profile_source) or {}).get(refs[0][1]) + if not entity or entity.get("construction"): + return None + intent = _selector_intent( + value, + query_family="CAP_EDGE", + kind="edge", + evidence="kernel_history", + allowed=("boundary", "continuation") if allow_continuation else ("boundary",), + ) + intent["lineage_role"] = f"extrude.{'start' if query.is_start else 'end'}" + return { + "kind": "edge", + "owner_feature_id": producer_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": intent, + } + if topology == "SWEPT_FACE": + if ( + selector_kind != "face" + or len(refs) != 1 + or refs[0][0] != profile_source + or query.source_sketch != profile_source + or query.source_entity != refs[0][1] + or refs[0][1] not in source_ids + ): + return None + entity = (entity_by_sketch.get(profile_source) or {}).get(refs[0][1]) + if not entity or entity.get("construction"): + return None + 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",), + ), + } + if selector_kind != "edge" or len(refs) < 2 or {source for source, _token in refs} != {profile_source}: + 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: + 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", + allowed=("boundary",), + ) + 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 _intersection_source_face_descriptor( + value: Any, + *, + allowed_input_features: set[str], + primary_tool_owner: str | None = None, + 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]]], + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Describe one exact boolean-input face without lowering a generic query. + + This is intentionally narrower than the standalone CAP/SWEPT selector + paths. The source must be an independently retained direct prism, or the + one transient direct prism built by the enclosing primary REMOVE. It + records the semantic anchor used to locate the *input* face; section-edge + selection is deferred to runtime builder history. + """ + if featurescript_version != "1511": + return None + try: + _call, owner, topology, kind, _definition = _direct_make_query(value) + except ValueError: + return None + owner_feature_id = f"f_{owner}" + if kind != "face" or owner_feature_id not in allowed_input_features: + return None + producer = feature_by_id.get(owner_feature_id) or {} + params = producer.get("params") or {} + is_primary_tool = owner_feature_id == primary_tool_owner + if ( + producer.get("atomic_id") != ("extrude_cut_blind" if is_primary_tool else "extrude_add_blind") + or (params.get("result_mode") is not None if is_primary_tool else params.get("result_mode") != "new_body") + or (params.get("end_condition") or {}).get("type") != "blind" + or params.get("draft") is not None + ): + return None + if topology == "CAP_FACE": + query = parse_query(value) + if query.is_start is None: + return None + return { + "query_family": "CAP_FACE", + "owner_feature_id": owner_feature_id, + "output_role": f"extrude.{'start' if query.is_start else 'end'}", + } + if topology != "SWEPT_FACE": + return None + 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(value) + if ( + 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 parse_query(value).source_sketch != profile_source + or parse_query(value).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 + return { + "query_family": "SWEPT_FACE", + "owner_feature_id": owner_feature_id, + "source_entity": {"sketch_id": profile_source, "entity_id": refs[0][1]}, + } + + +def _direct_boolean_intersection_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]]], + featurescript_version: str | None, +) -> dict[str, Any] | None: + """Lower one source-qualified `INTERSECT EDGE` boolean query. + + The operation accepts exactly two direct prism source faces from the + explicitly named target/tool members of a single `boolean_bodies` node. + COPY, transformed, patterned, TDD/TD/IMPRINT-derived and primary-tool + booleans intentionally retain deferred source intent. + """ + if featurescript_version != "1511": + return None + try: + _call, owner, topology, kind, definition = _direct_make_query(value) + except ValueError: + return None + if topology != "INTERSECT" or kind != "edge": + return None + boolean_id = f"f_{owner}" + boolean = feature_by_id.get(boolean_id) or {} + params = boolean.get("params") or {} + target_ids = params.get("target_feature_ids") + tool_ids = params.get("tool_feature_ids") + if ( + boolean.get("atomic_id") != "boolean_bodies" + or params.get("operation") not in {"subtract", "intersect"} + or bool(params.get("keep_tools")) + or not isinstance(target_ids, list) + or not isinstance(tool_ids, list) + or len(target_ids) != 1 + or len(tool_ids) != 1 + ): + return None + derived = definition.get("derivedFrom") + if not isinstance(derived, list) or len(derived) != 2: + return None + allowed_inputs = {str(target_ids[0]), str(tool_ids[0])} + sources = [ + _intersection_source_face_descriptor( + item, + allowed_input_features=allowed_inputs, + 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, + featurescript_version=featurescript_version, + ) + for item in derived + ] + if any(source is None for source in sources): + return None + owners = {str(source["owner_feature_id"]) for source in sources if source is not None} + if owners != allowed_inputs: + return None + return { + "kind": "edge", + "owner_feature_id": boolean_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": _selector_intent( + value, + query_family="INTERSECT", + kind="edge", + evidence="kernel_history", + allowed=("intersection",), + ) | { + "intersection_sources": sources, + "disambiguation": {"type": "source_qualified_boolean_section"}, + }, + } + + +def _direct_primary_cut_intersection_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 unique source-qualified section from a primary REMOVE. + + The primary cut's tool is not a CDSL body member. Runtime creates a + transient direct-prism snapshot only for this exact source pair, then + binds the final section edge through the cut builder. Any FeatureScript + disambiguation encoding remains deferred until its API semantics are + independently verified; OCC section-list position is not a substitute. + """ + if featurescript_version != "1511": + return None + try: + _call, owner, topology, kind, definition = _direct_make_query(value) + except ValueError: + return None + if ( + topology != "INTERSECT" + or kind != "edge" + or definition.get("disambiguationData") not in (None, []) + ): + return None + primary_id = f"f_{owner}" + primary = feature_by_id.get(primary_id) or {} + primary_params = primary.get("params") or {} + if ( + primary.get("atomic_id") != "extrude_cut_blind" + or primary_params.get("result_mode") is not None + or (primary_params.get("end_condition") or {}).get("type") != "blind" + or primary_params.get("draft") is not None + ): + return None + derived = definition.get("derivedFrom") + if not isinstance(derived, list) or len(derived) != 2: + return None + try: + input_owners = [f"f_{_direct_make_query(item)[1]}" for item in derived] + except ValueError: + return None + if input_owners.count(primary_id) != 1 or len(set(input_owners)) != 2: + return None + target_id = next(item for item in input_owners if item != primary_id) + # At the primary cut, one immediately preceding body is both the runtime + # target and the only allowed source-qualified target producer. + if previous[-2:] != [target_id, primary_id]: + return None + sources = [ + _intersection_source_face_descriptor( + item, + allowed_input_features={target_id, primary_id}, + primary_tool_owner=primary_id, + 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, + featurescript_version=featurescript_version, + ) + for item in derived + ] + if any(source is None for source in sources): + return None + return { + "kind": "edge", + "owner_feature_id": primary_id, + "source": "runtime_snapshot", + "confidence": 1.0, + "selector_intent": _selector_intent( + value, + query_family="INTERSECT", + kind="edge", + evidence="kernel_history", + allowed=("intersection",), + ) | { + "intersection_sources": sources, + "disambiguation": {"type": "source_qualified_primary_section"}, + }, + } + + +def _uses_planar_imprint_profile( + owner: str, + feature_by_id: dict[str, dict[str, Any]], + sketches_by_id: dict[str, dict[str, Any]], +) -> bool: + """Return whether a producer's profile was selected by B-rep IMPRINT.""" + feature = feature_by_id.get(f"f_{owner}") or {} + sketch = sketches_by_id.get(str(feature.get("sketch_id") or "")) or {} + return (sketch.get("profile") or {}).get("type") == "planar_imprint" + + def _source_sketch(params: dict[str, Any]) -> str | None: for key in ("entities", "sheetProfilesArray", "surfaceEntities"): if key in params: @@ -1786,6 +2420,7 @@ def _cap_face_selector(value: Any, feature_frames: dict[str, dict[str, Any]], fe "confidence": 1.0, "binding_feature_id": f"f_{query.owner_feature}", "geometry": {"normal": normal, "plane_offset_mm": _dot(normal, cap["origin_mm"])}, + "selector_intent": _deferred_featurescript_selector_intent(value, kind="face"), } @@ -2371,6 +3006,152 @@ def _shell_target_body_source( return source +def _hole_scope_body_source( + value: Any, + previous: list[str], + body_aliases: dict[str, str], + body_members: set[str], +) -> str: + """Lower one CADFS ``hole.scope`` query to its sole live body member. + + Hole scope is an ownership constraint, not permission to cut the current + aggregate. This first contract accepts one direct CADFS ``SWEPT_BODY`` + only while the lowering-side body graph proves that it remains the one + independently selectable active member. + """ + queries = _queries(value) + if len(queries) != 1: + raise UnsupportedCapability( + "hole_scope_body_source", + "current CDSL hole.scope requires exactly one direct SWEPT_BODY", + ) + _call, owner, topology, kind, _definition = _direct_make_query(queries[0]) + if topology != "SWEPT_BODY" or kind not in {"body", "entitytype.body"}: + raise UnsupportedCapability( + "hole_scope_body_source", + "current CDSL hole.scope requires one direct SWEPT_BODY", + ) + source = f"f_{owner}" + 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: + raise UnsupportedCapability( + "hole_scope_body_source", + "hole scope body is no longer the sole independently selectable member", + ) + return source + + +def _direct_hole_location( + value: Any, + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> 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, + 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: + 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: + return None + token = info.source_entity + entity_id = max((key for key in entities if token == key or token.startswith(key + ".")), key=len, default="") + entity = entities.get(entity_id) + if entity is None: + return None + suffix = "" if token == entity_id else token[len(entity_id) + 1:] + point: list[float] | None = None + if entity.get("type") == "point" and not suffix: + point = entity.get("point") + elif entity.get("type") == "circle" and suffix == "center": + 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): + return None + return [float(point[0]), float(point[1]), 0.0], sketch["workplane"] + + +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 1511 sketch-wire query used as a sweep path. + + ``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 + qUnion is only the set identity here, never permission to flatten or pick + among several path entities. + """ + if ( + featurescript_version != "1511" + or standard_library != "onshape/std/geometry.fs" + or standard_library_version != "1511.0" + ): + return None + 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 + ): + return None + current = value.args[0][0] + if not isinstance(current, Call) or current.name != "qConstructionFilter" or len(current.args) != 2: + return None + if 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": + return None + current = current.args[0] + if not isinstance(current, Call) or current.name != "qCreatedBy" or len(current.args) != 2: + return None + if symbolic_string(current.args[1]).rsplit(".", 1)[-1].upper() != "EDGE": + return None + source = parse_query(current).owner_feature + sketch = sketch_by_source.get(source or "") + entities = entity_by_sketch.get(source or "") or {} + if sketch is None: + return None + candidates = [ + (entity_id, entity) + for entity_id, entity in entities.items() + if not entity.get("construction") + ] + if len(candidates) != 1: + return None + entity_id, entity = candidates[0] + if entity.get("type") not in {"line", "bspline"}: + return None + return str(source), entity_id, entity, sketch + + def _pattern_remove_source(feature: dict[str, Any]) -> None: # circularPattern 的 REMOVE 会把源实体及其实例作为切削工具。仅直接的 # 加料拉伸/回转可无歧义改写为同一 profile 的切除;其他 source 需要 @@ -2917,14 +3698,27 @@ def _entity_point(entity: dict[str, Any], plane: dict[str, Any], token: str) -> if entity["type"] == "line": local = entity["end"] if ".end" in token else entity["start"] return _global(plane, local) + if entity["type"] == "arc": + if token.endswith(".start"): + return _global(plane, entity["start"]) + if token.endswith(".end"): + return _global(plane, entity["end"]) + raise ValueError("arc reference point is unresolved") if entity["type"] == "bspline": - # FeatureScript spline query suffixes are zero-based output-vertex - # indexes: ``E1.2.internal`` identifies the third interpolation - # point, including a point that is also the wire end. Treating them - # as one-based moves a reference plane to its preceding control point. - index = next((int(part) for part in token.split(".") if part.isdigit()), 0) points = entity.get("points") or [] if not points: raise ValueError("B-spline reference point is unresolved") + # ``.start`` and ``.end`` are distinct direct source endpoints. + # FeatureScript's ``.N.internal`` suffixes are zero-based output + # vertex indexes; neither form may silently fall back to the first + # interpolation point. + if token.endswith(".start"): + index = 0 + elif token.endswith(".end"): + index = len(points) - 1 + else: + index = next((int(part) for part in token.split(".") if part.isdigit()), -1) + if index < 0: + raise ValueError("B-spline reference point is unresolved") return _global(plane, points[max(0, min(index, len(points) - 1))]) raise ValueError("reference entity does not define a point") @@ -2934,6 +3728,189 @@ def _entity_line(entity: dict[str, Any], plane: dict[str, Any]) -> tuple[list[fl return _global(plane, entity["start"]), _global(plane, entity["end"]) +def _curve_endpoint_tangent(entity: dict[str, Any], endpoint: str) -> list[float]: + """Return the exact directed source tangent at one direct curve endpoint. + + ``CURVE_POINT`` is defined by the curve tangent at its selected point, not + by a chord through adjacent fit points. The CADFS source carries exact + derivatives for the bounded B-spline case below, while an analytic arc has + an exact radial construction. Interior spline vertices and splines without + an exported endpoint derivative deliberately stay unsupported. + """ + if endpoint not in {"start", "end"}: + raise UnsupportedCapability( + "reference_plane:curve_point", + "CURVE_POINT requires a direct source-curve endpoint", + ) + curve_type = entity.get("type") + if curve_type == "arc": + point = entity.get(endpoint) + center = entity.get("center") + if not ( + isinstance(point, list) and len(point) == 2 + and isinstance(center, list) and len(center) == 2 + ): + raise ValueError("arc endpoint tangent is unresolved") + radial = [float(point[index]) - float(center[index]) for index in range(2)] + return [radial[1], -radial[0]] if entity.get("clockwise") else [-radial[1], radial[0]] + if curve_type == "bspline": + tangent = entity.get(f"{endpoint}_tangent") + if not ( + isinstance(tangent, list) + and len(tangent) == 2 + and all(isinstance(value, (int, float)) and math.isfinite(value) for value in tangent) + ): + raise UnsupportedCapability( + "reference_plane:curve_point", + "CURVE_POINT B-spline endpoint has no exported tangent", + ) + return [float(value) for value in tangent] + raise UnsupportedCapability( + "reference_plane:curve_point", + "CURVE_POINT supports only direct lines, arcs, or B-spline endpoints with exported tangents", + ) + + +def _curve_interpolation_tangent(entity: dict[str, Any], index: int) -> list[float]: + """Return the kernel-proven tangent at one direct B-spline fit point. + + An ``E..internal`` source token denotes an interpolation vertex + emitted by the source sketch solver. It is neither a derived topology + suffix nor permission to estimate a tangent from adjacent fit points. We + reproduce the CDSL runtime's explicit-parameter, non-scaling OCC + interpolator and verify it still passes through that exact source point. + """ + if entity.get("type") != "bspline": + raise UnsupportedCapability( + "reference_plane:curve_point", + "CURVE_POINT internal point requires a direct B-spline source curve", + ) + if entity.get("periodic"): + raise UnsupportedCapability( + "reference_plane:curve_point", + "CURVE_POINT does not support periodic B-spline interpolation points", + ) + points = entity.get("points") + parameters = entity.get("parameters") + start_tangent = entity.get("start_tangent") + end_tangent = entity.get("end_tangent") + valid_vector = lambda value: ( + isinstance(value, list) + and len(value) == 2 + and all(isinstance(component, (int, float)) and math.isfinite(component) for component in value) + ) + if ( + not isinstance(points, list) + or not all(valid_vector(point) for point in points) + or not isinstance(parameters, list) + or not valid_vector(start_tangent) + or not valid_vector(end_tangent) + ): + raise UnsupportedCapability( + "reference_plane:curve_point", + "CURVE_POINT B-spline interpolation point has no complete source interpolation data", + ) + if index < 0 or index >= len(points): + raise UnsupportedCapability( + "reference_plane:curve_point", + "CURVE_POINT B-spline interpolation point is out of range", + ) + try: + # Import the runtime helper lazily: conversion remains usable in a + # parser-only environment until this kernel-backed capability is used. + from engine.cdsl_engine.build123d_adapter import interpolated_bspline_point_and_tangent + + point, tangent = interpolated_bspline_point_and_tangent( + [(*map(float, value), 0.0) for value in points], + start_tangent=(*map(float, start_tangent), 0.0), + end_tangent=(*map(float, end_tangent), 0.0), + parameters=[float(value) for value in parameters], + interpolation_index=index, + ) + except (ImportError, TypeError, ValueError) as exc: + raise UnsupportedCapability( + "reference_plane:curve_point", + f"CURVE_POINT B-spline interpolation tangent is unavailable: {exc}", + ) from exc + source_point = points[index] + if math.dist(point[:2], source_point) > 1e-7: + raise UnsupportedCapability( + "reference_plane:curve_point", + "CURVE_POINT B-spline interpolation does not reproduce its source vertex", + ) + if math.hypot(tangent[0], tangent[1]) <= 1e-12: + raise UnsupportedCapability( + "reference_plane:curve_point", + "CURVE_POINT B-spline interpolation tangent is degenerate", + ) + return [tangent[0], tangent[1]] + + +def _curve_point_tangent( + curve_query: Any, + point_query: Any, + point: list[float], + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> tuple[dict[str, Any], list[float]]: + """Resolve one direct source curve's tangent at its explicitly named end.""" + curve, plane, _ = _entity_from_query(curve_query, sketch_by_source, entity_by_sketch) + curve_info = parse_query(curve_query) + point_info = parse_query(point_query) + _point_entity, _point_plane, point_token = _entity_from_query( + point_query, sketch_by_source, entity_by_sketch, + ) + source_entity_id = curve.get("source_entity_id") + if ( + not isinstance(source_entity_id, str) + or curve_info.source_sketch != point_info.source_sketch + or not point_token.startswith(source_entity_id + ".") + ): + raise UnsupportedCapability( + "reference_plane:curve_point", + "CURVE_POINT point must be an endpoint of the same direct source curve", + ) + suffix = point_token[len(source_entity_id) + 1:] + interpolation_index: int | None = None + if suffix in {"start", "end"}: + local_point = curve.get(suffix) + else: + parts = suffix.split(".") + if ( + curve.get("type") == "bspline" + and len(parts) == 2 + and parts[0].isdigit() + and parts[1] == "internal" + ): + interpolation_index = int(parts[0]) + points = curve.get("points") + local_point = points[interpolation_index] if isinstance(points, list) and 0 <= interpolation_index < len(points) else None + else: + local_point = None + if not isinstance(local_point, list) or len(local_point) != 2: + raise UnsupportedCapability( + "reference_plane:curve_point", + "CURVE_POINT point must name a direct source-curve endpoint or B-spline interpolation vertex", + ) + expected_point = _global(plane, local_point) + if math.dist(point, expected_point) > 1e-7: + raise UnsupportedCapability( + "reference_plane:curve_point", + "CURVE_POINT point does not match its source-curve endpoint", + ) + local_tangent = ( + _curve_interpolation_tangent(curve, interpolation_index) + if interpolation_index is not None + else _curve_endpoint_tangent(curve, suffix) + ) + y_dir = _y_dir(plane) + tangent = [ + local_tangent[0] * plane["x_dir"][index] + local_tangent[1] * y_dir[index] + for index in range(3) + ] + return plane, tangent + + def _local(plane: dict[str, Any], point: list[float]) -> list[float]: relative = _sub(point, plane["origin_mm"]) return [_dot(relative, plane["x_dir"]), _dot(relative, _y_dir(plane))] @@ -3844,6 +4821,236 @@ def _query_point( return _entity_point(entity, plane, token) +def _featurescript_perpendicular_vector(value: list[float]) -> list[float]: + """Match FeatureScript 1511's deterministic ``perpendicularVector``. + + ``LINE_ANGLE`` with one selected axis has no second reference that can + determine its zero-angle direction. The standard library deliberately + supplies one with ``perpendicularVector``; using an arbitrary local frame + here changes subsequent sketch coordinates. Keep the same branch + thresholds as the 1511 implementation instead of deriving a direction + from a current body or a topology hint. + """ + direction = _unit(value, "line-angle reference axis is degenerate") + if abs(direction[0]) > 1.036663652861932668633 * abs(direction[1]): + different = [0.0, 0.0, 1.0] if abs(direction[0]) > .951702989392233451722 * abs(direction[2]) else [0.0, 1.0, 0.0] + else: + different = [1.0, 0.0, 0.0] if abs(direction[1]) > .920419947455385938102 * abs(direction[2]) else [0.0, 1.0, 0.0] + return _unit(_cross(different, direction), "line-angle reference axis is degenerate") + + +def _direct_line_angle_source_entity( + query: Any, + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> tuple[dict[str, Any], dict[str, Any], str] | None: + """Resolve one exact sketch entity without widening it to topology. + + ``LINE_ANGLE`` accepts direct sketch construction geometry as well as + profile geometry. The source entity key must therefore match exactly; + suffix matching would incorrectly turn a trim, CAP, SWEPT, COPY, or other + derived query into an original sketch reference. + """ + info = parse_query(query) + direct_calls = {"sQuery", "sketchEntityQuery", "__binary__"} + if ( + info.topology_type is not None + or not info.calls + or any(call not in direct_calls for call in info.calls) + or not isinstance(info.source_sketch, str) + or not isinstance(info.source_entity, str) + ): + return None + sketch = sketch_by_source.get(info.source_sketch) + entity = (entity_by_sketch.get(info.source_sketch) or {}).get(info.source_entity) + if sketch is None or entity is None: + return None + return entity, sketch["workplane"], info.source_entity + + +def _derived_line_angle_cylinder_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]]], +) -> tuple[dict[str, Any], dict[str, Any], str] | None: + """Accept the legacy derived-cylinder contract without widening it. + + The older LINE_ANGLE compatibility path is not a general fallback for a + query which happens to mention a source line. It is limited to a direct + source circle selected as a CAP_EDGE or SWEPT_FACE of an operation whose + recorded frame can prove its cylinder axis. + """ + try: + _call, owner, topology, kind, _definition = _direct_make_query(query) + except ValueError: + return None + if ( + (topology == "CAP_EDGE" and kind != "edge") + or (topology == "SWEPT_FACE" and kind != "face") + or topology not in {"CAP_EDGE", "SWEPT_FACE"} + ): + return None + info = parse_query(query) + if info.owner_feature != owner or not isinstance(info.source_sketch, str) or not isinstance(info.source_entity, str): + return None + references = _source_refs(query) + if references != [(info.source_sketch, info.source_entity)]: + return None + resolved = _source_ref_entity(info.source_sketch, info.source_entity, entity_by_sketch) + sketch = sketch_by_source.get(info.source_sketch) + frame = feature_frames.get(owner) + if ( + resolved is None + or sketch is None + or frame is None + or resolved[0] != info.source_entity + or resolved[1].get("type") != "circle" + or not isinstance(frame.get("start"), dict) + or not isinstance(frame.get("end"), dict) + ): + return None + return resolved[1], sketch["workplane"], info.source_entity + + +def _direct_line_angle_axis( + query: Any, + sketch_by_source: dict[str, dict[str, Any]], + entity_by_sketch: dict[str, dict[str, dict[str, Any]]], +) -> tuple[list[float], list[float]] | None: + """Return the exact ``evAxis`` result for a direct sketch line/circle. + + FeatureScript's ``lineAnglePlane`` passes selected axes to ``evAxis``. + A direct ``skLineSegment`` and a direct ``skCircle`` have unique source + axes. This does not infer an axis from a resulting edge: CAP/SWEPT/COPY + and suffix-derived references stay out of this source-only path. + """ + info = parse_query(query) + if info.kind not in {"edge", "entitytype.edge"}: + return None + direct = _direct_line_angle_source_entity(query, sketch_by_source, entity_by_sketch) + if direct is None: + return None + entity, plane, _token = direct + if entity.get("type") == "line": + start, end = _entity_line(entity, plane) + return start, _unit(_sub(end, start), "line-angle reference axis is degenerate") + if entity.get("type") == "circle": + center = entity.get("center") + if not isinstance(center, list) or len(center) != 2: + return None + return _global(plane, center), _unit(list(plane["normal"]), "line-angle reference axis is degenerate") + return None + + +def _direct_line_angle_reference_plane( + query: Any, + feature_frames: dict[str, dict[str, Any]], +) -> dict[str, Any] | None: + """Resolve only a default plane or an already-lowered reference plane.""" + default = _default_plane(query) + if default is not None: + return default + info = parse_query(query) + if ( + info.topology_type is not None + or "qCreatedBy" not in info.calls + or not info.owner_feature + ): + return None + frame = feature_frames.get(info.owner_feature) + if not isinstance(frame, dict) or frame.get("start") != frame.get("end"): + return None + start = frame.get("start") + return dict(start) if isinstance(start, dict) else None + + +def _direct_line_angle_reference_point( + query: 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.""" + info = parse_query(query) + 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) + resolved = _source_ref_entity(info.source_sketch, info.source_entity, entity_by_sketch) + if sketch is None or resolved is None: + return None + entity_id, entity = resolved + token = info.source_entity + if entity.get("type") == "point" and token == entity_id: + return _entity_point(entity, sketch["workplane"], token) + if entity.get("type") == "line" and token in {f"{entity_id}.start", f"{entity_id}.end"}: + return _entity_point(entity, sketch["workplane"], token) + return None + + +def _direct_line_angle_two_entity_plane( + entities: list[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]]], +) -> dict[str, Any] | None: + """Implement FeatureScript's exact two-entity ``lineAnglePlane`` rule. + + The first entity normally supplies the axis. If it has no direct axis and + the second entity does, FeatureScript swaps the axis and treats the + original first entity as the plane/point reference. Parallel axes use + their origin-to-origin vector; nonparallel axes use the second direction. + """ + 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) + if first_axis is None: + if second_axis is None: + return None + axis_origin, axis_direction = second_axis + reference = entities[0] + reference_axis = None + else: + axis_origin, axis_direction = first_axis + reference = entities[1] + reference_axis = second_axis + + if reference_axis is not None: + reference_origin, reference_direction = reference_axis + if math.sqrt(_dot(_cross(axis_direction, reference_direction), _cross(axis_direction, reference_direction))) <= 1e-9: + second_in_plane_direction = _sub(reference_origin, axis_origin) + else: + second_in_plane_direction = reference_direction + else: + reference_plane = _direct_line_angle_reference_plane(reference, feature_frames) + if reference_plane is not None: + 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, + ) + if reference_point is None: + return None + second_in_plane_direction = _sub(reference_point, axis_origin) + + normal = _cross(axis_direction, second_in_plane_direction) + _unit(normal, "line-angle reference selection is degenerate") + signed_angle = _number(params.get("angle", 0.0)) + if _bool(params.get("oppositeDirection")): + signed_angle = -signed_angle + return _frame( + axis_origin, + axis_direction, + _rotate(normal, axis_direction, math.radians(signed_angle)), + ) + + def _cplane( params: dict[str, Any], feature_frames: dict[str, dict[str, Any]], @@ -3860,27 +5067,61 @@ def _cplane( offset = -offset return _shift_plane(_query_plane(entities[0], feature_frames, sketch_by_source, entity_by_sketch), offset) if plane_type == "LINE_ANGLE": - line_query = next((item for item in entities if parse_query(item).source_entity), None) - if line_query is None: raise ValueError("line-angle reference line is unresolved") - base_query = next((item for item in entities if item is not line_query), line_query) - try: - base = _query_plane(base_query, feature_frames, sketch_by_source, entity_by_sketch) - except ValueError: - _, base, _ = _entity_from_query(line_query, sketch_by_source, entity_by_sketch) + # ``lineAnglePlane`` resolves the direct source contract before any + # topology-derived workplane compatibility path. In particular, a + # two-entity plane is defined by the axis plus the second entity's + # direction, not by rotating an arbitrary source workplane frame. + if len(entities) == 1: + direct_axis = _direct_line_angle_axis( + entities[0], sketch_by_source, entity_by_sketch, + ) + if direct_axis is not None: + origin, axis = direct_axis + signed_angle = _number(params.get("angle", 0.0)) + if _bool(params.get("oppositeDirection")): + signed_angle = -signed_angle + base_normal = _featurescript_perpendicular_vector(axis) + normal = _rotate(base_normal, axis, math.radians(signed_angle)) + return _frame(origin, axis, normal) + if len(entities) == 2: + direct_plane = _direct_line_angle_two_entity_plane( + entities, params, feature_frames, sketch_by_source, entity_by_sketch, + ) + if direct_plane is not None: + return direct_plane + # The retained compatibility path is specifically a cylinder + # generatrix contract. Do not use it as a generic derived-topology + # fallback: a CAP/SWEPT line or a query combinator has different + # FeatureScript provenance and needs its own source proof. + candidates = [ + item for item in entities + if _derived_line_angle_cylinder_axis( + item, feature_frames, sketch_by_source, entity_by_sketch, + ) is not None + ] + if len(entities) != 2 or len(candidates) != 1: + raise ValueError("line-angle reference selection is unsupported") + line_query = candidates[0] + base_query = next(item for item in entities if item is not line_query) + base = _direct_line_angle_reference_plane(base_query, feature_frames) + if base is None: + raise ValueError("line-angle cylinder reference plane is unsupported") start, end = _query_line(line_query, feature_frames, sketch_by_source, entity_by_sketch) axis = _sub(end, start); angle = -_number(params.get("angle", 0.0)) opposite = _bool(params.get("oppositeDirection")) normal = _rotate(base["normal"], axis, math.radians(angle)) x_dir = _rotate(base["x_dir"], axis, math.radians(angle)) - entity, _, _ = _entity_from_query(line_query, sketch_by_source, entity_by_sketch) - info = parse_query(line_query) - if entity.get("type") == "circle" and info.topology_type in {"SWEPT_FACE", "CAP_EDGE"}: - # 圆柱面/端盖圆边用于 LINE_ANGLE 时,FeatureScript 取圆柱面上的 - # 母线作为旋转轴,而不是圆心处的中心轴。将该轴移到旋转后的面 - # 法向所指的圆柱半径位置,才能保持后续草图和端面引用的位置。 - axis_direction = _unit(axis, "line-angle reference line is degenerate") - radial = _sub(normal, [axis_direction[index] * _dot(normal, axis_direction) for index in range(3)]) - start = [start[index] + _unit(radial, "line-angle cylinder radial direction is degenerate")[index] * entity["radius_mm"] for index in range(3)] + entity, _plane, _token = _derived_line_angle_cylinder_axis( + line_query, feature_frames, sketch_by_source, entity_by_sketch, + ) or (None, None, None) + if entity is None: # Kept for type narrowing; the candidate was checked above. + raise ValueError("line-angle reference selection is unsupported") + # 圆柱面/端盖圆边用于 LINE_ANGLE 时,FeatureScript 取圆柱面上的 + # 母线作为旋转轴,而不是圆心处的中心轴。将该轴移到旋转后的面 + # 法向所指的圆柱半径位置,才能保持后续草图和端面引用的位置。 + axis_direction = _unit(axis, "line-angle reference line is degenerate") + radial = _sub(normal, [axis_direction[index] * _dot(normal, axis_direction) for index in range(3)]) + start = [start[index] + _unit(radial, "line-angle cylinder radial direction is degenerate")[index] * entity["radius_mm"] for index in range(3)] return _frame(start, x_dir, normal) if plane_type == "PLANE_POINT": base_query = next((item for item in entities if _default_plane(item) or "qCreatedBy" in parse_query(item).calls), None) @@ -3893,9 +5134,15 @@ def _cplane( curve_query = next((item for item in entities if item is not point_query), None) if point_query is None or curve_query is None: raise ValueError("curve-point references are unresolved") point = _query_point(point_query, feature_frames, sketch_by_source, entity_by_sketch) - start, end = _query_line(curve_query, feature_frames, sketch_by_source, entity_by_sketch) - _, source_plane, _ = _entity_from_query(curve_query, sketch_by_source, entity_by_sketch) - return _frame(point, source_plane["normal"], _sub(end, start)) + curve, source_plane, _ = _entity_from_query(curve_query, sketch_by_source, entity_by_sketch) + if curve.get("type") == "line": + start, end = _query_line(curve_query, feature_frames, sketch_by_source, entity_by_sketch) + tangent = _sub(end, start) + else: + source_plane, tangent = _curve_point_tangent( + curve_query, point_query, point, sketch_by_source, entity_by_sketch, + ) + 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] @@ -4179,9 +5426,28 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: cap_edge_union_profile = _cap_edge_union_profile_sketch( profile_value, sketch_by_source, entity_by_sketch, fid, ) + # First let the established profile materializer handle + # single-region, circle and open-IMPRINT contracts. A + # multi-face IMPRINT query reaches the arrangement path only + # when that materializer cannot reduce the source sketch. + materialized_imprint = ( + _profile_selection_sketch( + sketch_by_source[source], profile_value, entity_by_sketch[source], fid, + ) + if profile_kind == "IMPRINT" and source in sketch_by_source + else None + ) planar_imprint_profile = _planar_imprint_profile_sketch( profile_value, sketch_by_source, entity_by_sketch, fid, - ) if profile_kind == "INTERSECT" else None + ) if ( + profile_kind == "INTERSECT" + or ( + profile_kind == "IMPRINT" + and len(_queries(profile_value)) > 1 + and isinstance(source, str) + and materialized_imprint is sketch_by_source.get(source) + ) + ) else None intersect_profile_sketch = _intersect_partition_profile_sketch( profile_value, sketch_by_source, entity_by_sketch, fid, ) if profile_kind == "INTERSECT" and planar_imprint_profile is None else None @@ -4233,11 +5499,22 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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") + second = _bool(p.get("hasSecondDirection")) end = _end_condition("SYMMETRIC" if _bool(p.get("symmetric")) else p.get("endBound")) if end["type"] == "up_to_body": - end["reference"] = _extent_reference(p.get("endBoundEntityBody"), "body", feature_frames, sketch_by_source, entity_by_sketch) + end["reference"] = _extent_reference( + p.get("endBoundEntityBody"), "body", 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 end["type"] == "up_to_surface": - end["reference"] = _extent_reference(p.get("endBoundEntityFace"), "face", feature_frames, sketch_by_source, entity_by_sketch) + 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, + ) 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, @@ -4251,7 +5528,6 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: # 并入既有主体会显式给出 ADD;不能根据当前 CDSL history 猜成 ADD, # 否则会把应保留的独立 body 在转换时丢失。 reverse = _bool(p.get("oppositeDirection")); cutting = any(x in operation for x in ("REMOVE", "CUT")) - second = _bool(p.get("hasSecondDirection")) if cutting and not second and end["type"] not in {"blind", "mid_plane", "through_all", "through_next", "up_to_surface", "up_to_vertex", "up_to_body"}: capability = f"extrude_cut_{end['type']}" raise UnsupportedCapability(capability, f"current CDSL atomic set has no exact {capability} operation") @@ -4266,7 +5542,10 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: if reverse_end["type"] == "up_to_body": reverse_end["reference"] = _extent_reference(p.get("secondDirectionBoundEntityBody"), "body", feature_frames, sketch_by_source, entity_by_sketch) elif reverse_end["type"] == "up_to_surface": - reverse_end["reference"] = _extent_reference(p.get("secondDirectionBoundEntityFace"), "face", feature_frames, sketch_by_source, entity_by_sketch) + 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, + ) 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, @@ -4422,7 +5701,19 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: path_entity = (entity_by_sketch.get(path_source or "") or {}).get(path_query.source_entity or "") path_sketch = sketch_by_source.get(path_source or "") if path_entity is None or path_sketch is None: - raise ValueError("sweep path is unresolved") + direct_wire_path = _direct_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 direct_wire_path is not None: + path_source, _path_entity_id, path_entity, path_sketch = direct_wire_path + 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") operation = str(p.get("operationType") or "NEW").upper() @@ -4583,9 +5874,85 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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"))): - query = parse_query(query_value); owner = query.owner_feature + query = parse_query(query_value) + # ``parse_query`` retains nested source metadata for + # diagnostics, but its recursive walk ends on an inner + # CAP/SWEPT query. A fillet/chamfer consumes the outer + # makeQuery result, so use the direct outer expression + # for its topology kind and owner. + try: + _outer_call, outer_owner, outer_topology, outer_kind, _outer_definition = _direct_make_query(query_value) + except ValueError: + outer_owner = query.owner_feature + outer_topology = query.topology_type + outer_kind = query.kind + owner = outer_owner if not owner: raise ValueError("selector owner is unresolved") - selector_kind = "face" if query.kind in {"face", "entitytype.face"} or query.topology_type in {"CAP_FACE", "SWEPT_FACE"} else "edge" + selector_kind = "face" if outer_kind in {"face", "entitytype.face"} else "edge" + if outer_topology == "INTERSECT": + intersection_selector = _direct_boolean_intersection_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, + featurescript_version=model.featurescript_version, + ) + if intersection_selector is None: + intersection_selector = _direct_primary_cut_intersection_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 intersection_selector is not None: + selectors.append(intersection_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 in {"CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE"}: + lineage_selector = _direct_prism_swept_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, + allow_continuation=outer_topology == "CAP_EDGE", + ) + if lineage_selector is not None: + selectors.append(lineage_selector) + continue + if _uses_planar_imprint_profile(owner, feature_by_id, sketches_by_id): + 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 refs = _source_refs(query_value) source_entity = (entity_by_sketch.get(query.source_sketch or "") or {}).get(query.source_entity or "") geometry: dict[str, Any] = {} @@ -4627,7 +5994,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: if source_entity["type"] == "circle": # Keep the source circle signature until the prefix has been # rebuilt. OCC may expose it as one edge or several arcs. - selectors.append({"kind": selector_kind, "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry or {"curve_type": "circle", "source_circle_center_mm": _global(cap, source_entity["center"]), "source_circle_radius_mm": source_entity["radius_mm"], "source_plane_normal": cap["normal"]}}) + selectors.append({"kind": selector_kind, "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry or {"curve_type": "circle", "source_circle_center_mm": _global(cap, source_entity["center"]), "source_circle_radius_mm": source_entity["radius_mm"], "source_plane_normal": cap["normal"]}, "selector_intent": _deferred_featurescript_selector_intent(query_value, kind=selector_kind)}) continue elif source_entity["type"] == "line": start, end = _global(cap, source_entity["start"]), _global(cap, source_entity["end"]) @@ -4635,7 +6002,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: if not geometry: raise ValueError("selector geometry is unresolved") 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}) + 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)}) 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")): @@ -4660,7 +6027,17 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: if kind not in {"face", "entitytype.face"}: raise UnsupportedCapability("shell_face_selector", "current CDSL shell requires face removal selectors") if topology == "CAP_FACE": - selector = _face_reference(query_value, feature_frames, sketch_by_source, entity_by_sketch) + # A direct CAP_FACE names the extrusion's physical + # builder output. It may serve a shell only while + # that independent prism is still the active, + # immediately preceding body. Other CAP histories + # 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, + ) + 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: cap_removals.append((query.owner_feature, "start" if query.is_start else "end")) source_frame = feature_frames.get(query.owner_feature or "") or {} @@ -4671,7 +6048,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: elif topology == "SWEPT_FACE": selector = _direct_linear_extrude_swept_face_shell_reference( query_value, feature_frames, sketch_by_source, sketches_by_id, - entity_by_sketch, feature_by_id, previous, + entity_by_sketch, feature_by_id, previous, model.featurescript_version, ) elif topology == "OFFSET_FACE": selector = _shell_offset_face_output_role_selector( @@ -4686,7 +6063,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: # Feature-output roles resolve only through the active # kernel snapshot. A stable id would turn that semantic # evidence into a stale geometric selector. - if selector.get("output_role") is None: + 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) if not selectors: @@ -4744,10 +6121,20 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: elif item.operation == "hole": locations = _queries(p.get("locations")); positions = []; host_plane = None for location in locations: - query = parse_query(location); source = query.source_sketch - entity = (entity_by_sketch.get(source or "") or {}).get(query.source_entity or "") - if not source or source not in sketch_by_source or not entity or entity.get("type") != "point": raise ValueError("hole location is unresolved") - positions.append({"mm": [entity["point"][0], entity["point"][1], 0.0]}); host_plane = sketch_by_source[source]["workplane"] + resolved_location = _direct_hole_location(location, sketch_by_source, entity_by_sketch) + if resolved_location is None: + raise UnsupportedCapability( + "hole_location_vertex", + "hole location requires one direct original sketch vertex", + ) + position, location_plane = resolved_location + if host_plane is not None and location_plane != host_plane: + raise UnsupportedCapability( + "hole_location_plane", + "hole locations must share one explicit sketch plane", + ) + positions.append({"mm": position}) + host_plane = location_plane 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"]]} @@ -4764,6 +6151,14 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: if standard_through_diameter is None and style.upper() in {"COUNTERBORE", "C_BORE"}: hole_params["counterbore"] = {"diameter_mm": _number(p.get("counterboreDiameter") or p.get("cBoreDiameter") or p.get("majorDiameter"), True), "depth_mm": _number(p.get("counterboreDepth") or p.get("cBoreDepth"), True)} if _bool(p.get("isTappedThrough")) or p.get("tapSize") is not None: hole_params["thread"] = {"source": "CADFS", "decorative": True} + if p.get("scope") is None: + raise UnsupportedCapability( + "hole_scope_body_source", + "current CDSL CADFS hole lowering requires an explicit scope", + ) + hole_params["scope_feature_id"] = _hole_scope_body_source( + p["scope"], previous, body_transform_aliases, lowered_body_members, + ) feature = {"id": fid, "name": item.feature_id, "atomic_id": "hole_wizard", "depends_on": depends, "params": hole_params, "execution_status": "supported"} elif item.operation == "circularPattern": sources = _pattern_source_features(p.get("entities"), previous) @@ -4812,7 +6207,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: if not owners: raise ValueError("mirror source features are unresolved") plane_query = p.get("mirrorPlane"); plane_info = parse_query(plane_query); plane_owner = f"f_{plane_info.owner_feature}" if plane_info.owner_feature else None if plane_owner and any(existing["id"] == plane_owner and existing["atomic_id"] == "reference_plane" for existing in features): - mirror_plane = {"kind": "plane", "owner_feature_id": plane_owner, "stable_id": f"cadfs_{fid}_plane", "source": "runtime_snapshot", "confidence": 1.0} + mirror_plane = {"kind": "plane", "owner_feature_id": plane_owner, "stable_id": f"cadfs_{fid}_plane", "source": "runtime_snapshot", "confidence": 1.0, "selector_intent": _explicit_datum_selector_intent(plane_query, kind="plane")} source_plane = feature_frames.get(plane_info.owner_feature or "", {}).get("start") if source_plane is None: raise ValueError("mirror plane frame is unresolved") else: @@ -4823,7 +6218,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: plane_owner = f"{fid}_plane" features.append({"id": plane_owner, "name": f"{item.feature_id} plane", "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane}, "execution_status": "supported"}) previous.append(plane_owner) - mirror_plane = {"kind": "plane", "owner_feature_id": plane_owner, "stable_id": f"cadfs_{fid}_plane", "source": "runtime_snapshot", "confidence": 1.0} + mirror_plane = {"kind": "plane", "owner_feature_id": plane_owner, "stable_id": f"cadfs_{fid}_plane", "source": "runtime_snapshot", "confidence": 1.0, "selector_intent": _explicit_datum_selector_intent(plane_query, kind="plane")} source_plane = plane feature = {"id": fid, "name": item.feature_id, "atomic_id": "pattern_mirror", "depends_on": list(dict.fromkeys(owners + [plane_owner])), "params": {"source_feature_ids": owners, "mirror_plane": mirror_plane}, "selectors": [mirror_plane], "execution_status": "supported"} if mirror_current_body: feature["params"]["mirror_current_body"] = True @@ -4867,9 +6262,15 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: except Exception as exc: diagnostics.append({"code": "feature_deferred", "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False if not features: return LoweringResult(None, "deferred_no_executable_feature", diagnostics, history) + source_featurescript = { + **({"version": model.featurescript_version} if model.featurescript_version else {}), + **({"standard_library": model.standard_library} if model.standard_library else {}), + **({"standard_library_version": model.standard_library_version} if model.standard_library_version else {}), + **({"standard_library_imports": deepcopy(model.standard_library_imports)} if model.standard_library_imports else {}), + } cdsl = {"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": model.sample_id, "meta": {"unit": "mm", "source": "CADFS", "provenance": provenance, "capability_gaps": sorted({d.get("operation") for d in diagnostics if d.get("operation")}), - "source_featurescript": {"version": model.featurescript_version or "0", **({"standard_library": model.standard_library} if model.standard_library else {})}}, + "source_featurescript": source_featurescript}, "geometry": {"sketches": sketches}, "features": features} _finalize_selector_intents(cdsl, model) return LoweringResult(cdsl, "converted_complete" if complete else "converted_partial", diagnostics, history) diff --git a/cadfs_to_cdsl/rebuild.py b/cadfs_to_cdsl/rebuild.py index bafd00dc..8a4dedb2 100644 --- a/cadfs_to_cdsl/rebuild.py +++ b/cadfs_to_cdsl/rebuild.py @@ -6,36 +6,38 @@ import re from typing import Any -def _last_executable_prefix( - cdsl: dict[str, Any], - failed_feature_id: str | None, - output: Path, -) -> dict[str, Any] | None: - """Bind and export the longest verified prefix before a failed feature.""" - from .selector_binding import bind_candidate_selectors - from engine.cdsl_engine.runtime import rebuild_cdsl +def _last_executable_prefix(error: Exception, output: Path) -> dict[str, Any] | None: + """Export the checkpoint already produced by the failed live replay.""" + execution = getattr(error, "incremental_execution", None) + bound = getattr(error, "bound_cdsl", None) + if execution is None or not isinstance(bound, dict): + return None + session = execution.session + if not session.results or (session.body is None and not session.surface_members): + return None + from engine.cdsl_engine.runtime import finalize_cdsl_execution - features = list(cdsl.get("features") or []) - failed_index = next( - (index for index, feature in enumerate(features) if feature.get("id") == failed_feature_id), - len(features), + try: + result = finalize_cdsl_execution(execution, output) + except Exception: + return None + last_feature_id = next(reversed(session.results)) + features = list(bound.get("features") or []) + feature_count = next( + (index + 1 for index, feature in enumerate(features) if feature.get("id") == last_feature_id), + 0, ) - for feature_count in range(failed_index, 0, -1): - prefix = deepcopy(cdsl) - prefix["features"] = features[:feature_count] - try: - bound_prefix, _binding = bind_candidate_selectors(prefix) - result = rebuild_cdsl(bound_prefix, output, strict=True) - except Exception: - continue - return { - "failed_feature_id": failed_feature_id, - "feature_count": feature_count, - "last_feature_id": str(features[feature_count - 1].get("id") or ""), - "bound_cdsl": bound_prefix, - "result": result, - } - return None + if feature_count == 0: + return None + prefix = deepcopy(bound) + prefix["features"] = features[:feature_count] + return { + "failed_feature_id": getattr(error, "failed_feature_id", None) or _failed_feature_id(error), + "feature_count": feature_count, + "last_feature_id": last_feature_id, + "bound_cdsl": prefix, + "result": result, + } def _failed_feature_id(error: Exception) -> str | None: @@ -48,22 +50,34 @@ def _failed_feature_id(error: Exception) -> str | None: def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]: - from engine.cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl - from .selector_binding import bind_candidate_selectors + from engine.cdsl_engine.runtime import analyze_cdsl, finalize_cdsl_execution + from .selector_binding import bind_and_execute_candidate_selectors + 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} - bound: dict[str, Any] | None = None try: - bound, binding = bind_candidate_selectors(cdsl) - result = rebuild_cdsl(bound, output, strict=True) - return {"status": "rebuilt", "analysis": analysis_dict, "selector_binding": binding, "bound_cdsl": bound, "result": result} + replay = bind_and_execute_candidate_selectors(cdsl) + result = finalize_cdsl_execution(replay.execution, output) + return { + "status": "rebuilt", + "analysis": analysis_dict, + "selector_binding": replay.evidence, + "bound_cdsl": replay.bound_cdsl, + "result": result, + } except Exception as exc: detail = {"type": type(exc).__name__, "message": str(exc)} - if hasattr(exc, "selector_resolutions"): detail["selector_resolutions"] = exc.selector_resolutions - prefix = _last_executable_prefix(cdsl, _failed_feature_id(exc), output) + if hasattr(exc, "selector_resolutions"): + detail["selector_resolutions"] = exc.selector_resolutions + prefix = _last_executable_prefix(exc, output) result = {"status": "rebuild_failed", "analysis": analysis_dict, "error": detail} - if bound is not None: result["bound_cdsl"] = bound + bound = getattr(exc, "bound_cdsl", None) + if isinstance(bound, dict): + result["bound_cdsl"] = bound + binding = getattr(exc, "selector_binding", None) + if isinstance(binding, list): + result["selector_binding"] = binding if prefix is not None: result["last_executable_prefix"] = prefix return result diff --git a/cadfs_to_cdsl/selector_binding.py b/cadfs_to_cdsl/selector_binding.py index 938f09bc..930248b1 100644 --- a/cadfs_to_cdsl/selector_binding.py +++ b/cadfs_to_cdsl/selector_binding.py @@ -2,6 +2,7 @@ from __future__ import annotations import math from copy import deepcopy +from dataclasses import dataclass from pathlib import Path import tempfile from typing import Any @@ -95,6 +96,14 @@ def _binding_targets(selector: dict[str, Any]): yield selector +def _runtime_selector(placeholder: dict[str, Any]) -> dict[str, Any]: + """Convert a legacy prefix placeholder to the runtime selector contract.""" + selector = dict(placeholder) + selector.setdefault("source", "runtime_snapshot") + selector.setdefault("confidence", 1.0) + return selector + + def _bound_selector(placeholder: dict[str, Any], records: list[dict[str, Any]]) -> tuple[dict[str, Any], list[dict[str, Any]]]: bound = [] for record in records: @@ -109,160 +118,241 @@ def _bound_selector(placeholder: dict[str, Any], records: list[dict[str, Any]]) return bound[0], bound +def _selector_roots(feature: dict[str, Any]) -> list[dict[str, Any]]: + roots = list(feature.get("selectors") or []) + for name in ("end_condition", "reverse_end_condition"): + condition = (feature.get("params") or {}).get(name) + reference = condition.get("reference") if isinstance(condition, dict) else None + if isinstance(reference, dict): + roots.append(reference) + return roots + + +def _selector_key(selector: dict[str, Any]) -> tuple[Any, ...]: + stable_id = selector.get("stable_id") + if stable_id is not None: + return ("stable_id", str(stable_id)) + source = selector.get("output_role_source") + return ( + "output_role", + selector.get("owner_feature_id"), + selector.get("kind"), + selector.get("output_role"), + source.get("owner_feature_id") if isinstance(source, dict) else None, + source.get("output_role") if isinstance(source, dict) else None, + ) + + +def _bind_feature_selectors( + feature: dict[str, Any], + *, + registry: Any, + body_id_for_feature: dict[str, str | None], +) -> list[dict[str, Any]]: + """Bind one feature immediately before it runs in the shared session.""" + targets = [target for root in _selector_roots(feature) for target in _binding_targets(root)] + resolved: list[dict[str, Any]] = [] + for placeholder in targets: + binding_feature_id = placeholder.get("binding_feature_id") + if binding_feature_id is None: + active_body_id = body_id_for_feature.get("__current__") + else: + if not isinstance(binding_feature_id, str) or binding_feature_id not in body_id_for_feature: + raise ValueError(f"{feature['id']}: selector binding feature is missing or forward") + active_body_id = body_id_for_feature[binding_feature_id] + intent = placeholder.get("selector_intent") + runtime_selector = _runtime_selector(placeholder) + resolution = registry.resolve(runtime_selector, active_body_id=active_body_id) + # Legacy owner-qualified, geometry-free context selectors predate the + # runtime's canonical evidence fields. Their compatibility contract + # permits a unique active context object, but never relaxes a + # provenance, output-role, or instance-locked selector. + if ( + resolution.status != "resolved" + and not isinstance(intent, dict) + and not runtime_selector.get("owner_match_required") + and not runtime_selector.get("output_role") + ): + fallback = dict(runtime_selector) + fallback.pop("owner_feature_id", None) + resolution = registry.resolve(fallback, active_body_id=active_body_id) + if resolution.status != "resolved": + code = resolution.diagnostic.code if resolution.diagnostic is not None else f"selector_{resolution.status}" + raise ValueError(f"{feature['id']}: {code} during incremental replay") + selected = list(resolution.records or ((resolution.record,) if resolution.record is not None else ())) + if not selected: + raise ValueError(f"{feature['id']}: selector_not_found during incremental replay") + public_records = [record.public_dict() for record in selected] + # Operation-role and provenance selectors stay declarative in bound + # CDSL. A runtime record ID is execution evidence, never their durable + # semantic replacement. + if (isinstance(intent, dict) and intent.get("query_family") != "GEOMETRIC") or placeholder.get("output_role"): + resolved.extend(public_records) + continue + selector, bound_selectors = _bound_selector(placeholder, public_records) + placeholder.clear() + placeholder.update(selector) + resolved.extend(bound_selectors) + selectors = feature.get("selectors") or [] + feature["selectors"] = list({_selector_key(selector): selector for selector in selectors}.values()) + if feature.get("atomic_id") == "pattern_mirror": + planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"] + if len(planes) != 1: + raise ValueError(f"{feature['id']}: mirror plane binding is not unique") + feature.setdefault("params", {})["mirror_plane"] = planes[0] + return resolved + + +@dataclass +class IncrementalBindingReplay: + """Bound candidate plus the one session that produced its evidence.""" + + bound_cdsl: dict[str, Any] + evidence: list[dict[str, Any]] + execution: Any + + +def bind_and_execute_candidate_selectors(cdsl: dict[str, Any]) -> IncrementalBindingReplay: + """Bind and execute a CADFS candidate in one ordered kernel replay. + + A feature is bound only against topology facts registered by earlier + features in this session. Historical ``binding_feature_id`` values select + a retained snapshot ID, so the binder never needs to re-run a prefix or + recreate an OCC body. Resolver failures retain the live session for the + caller to export its last executable checkpoint. + """ + from engine.cdsl_engine.runtime import prepare_cdsl_execution + + bound = deepcopy(cdsl) + execution = prepare_cdsl_execution(bound) + if not execution.analysis.runtime_eligible: + first = next((result for result in execution.analysis.feature_results if not result.executable), None) + code = first.blockers[0].code if first and first.blockers else "runtime_ineligible" + raise ValueError(code) + bound_features = {str(feature.get("id") or ""): feature for feature in bound.get("features") or []} + body_id_for_feature: dict[str, str | None] = {"__current__": None} + evidence: list[dict[str, Any]] = [] + for index, node in enumerate(execution.analysis.plan): + feature = node.source_feature + try: + resolved = _bind_feature_selectors( + feature, + registry=execution.session.topology, + body_id_for_feature=body_id_for_feature, + ) + public_feature = bound_features[node.feature_id] + public_feature["selectors"] = deepcopy(feature.get("selectors") or []) + public_feature["params"] = deepcopy(feature.get("params") or {}) + evidence.append({ + "feature_id": node.feature_id, + "prefix_feature_count": index, + "selectors": public_feature["selectors"], + "resolved": resolved, + }) + execution.execute_next(strict=True) + body_id_for_feature[node.feature_id] = execution.session.body_id + body_id_for_feature["__current__"] = execution.session.body_id + except Exception as error: + setattr(error, "bound_cdsl", bound) + setattr(error, "selector_binding", evidence) + setattr(error, "incremental_execution", execution) + setattr(error, "failed_feature_id", node.feature_id) + raise + return IncrementalBindingReplay(bound, evidence, execution) + + def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]: - """Rebuild every selector-bearing prefix and bind against its active body.""" + """Legacy diagnostic binder for incomplete or externally supplied CDSL. + + The production CADFS rebuild path uses ``bind_and_execute_candidate_selectors``. + This compatibility entry point deliberately keeps prefix replay explicit + for callers that need to inspect a partial document before it is runtime + eligible. It rehydrates exact exported topology facts and calls the same + resolver; provenance selectors never become geometry guesses here. + """ from engine.cdsl_engine.runtime import rebuild_cdsl - bound = deepcopy(cdsl); evidence = [] + from engine.cdsl_engine.topology import TopologyRegistry + + bound = deepcopy(cdsl) + evidence: list[dict[str, Any]] = [] with tempfile.TemporaryDirectory(prefix="cadfs-bind-") as temporary: for index, feature in enumerate(bound.get("features") or []): - roots = list(feature.get("selectors") or []) - for name in ("end_condition", "reverse_end_condition"): - condition = (feature.get("params") or {}).get(name) - reference = condition.get("reference") if isinstance(condition, dict) else None - if isinstance(reference, dict): roots.append(reference) - targets = [target for root in roots for target in _binding_targets(root)] - if not targets: continue - prefix_cache: dict[int, tuple[list[dict[str, Any]], str | None]] = {} + targets = [target for root in _selector_roots(feature) for target in _binding_targets(root)] + if not targets: + continue + prefix_cache: dict[int, tuple[list[dict[str, Any]], str | None, list[dict[str, Any]]]] = {} - def prefix_records(binding_feature_id: str | None, owner_feature_id: str | None) -> list[dict[str, Any]]: + def prefix_snapshot(binding_feature_id: str | None) -> tuple[list[dict[str, Any]], str | None, list[dict[str, Any]]]: prefix_count = index if binding_feature_id is not None: - binding_index = next((item_index for item_index, item in enumerate(bound["features"][:index]) if item["id"] == binding_feature_id), None) - if binding_index is None: raise ValueError(f"{feature['id']}: selector binding feature is missing or forward") + binding_index = next( + (item_index for item_index, item in enumerate(bound["features"][:index]) if item["id"] == binding_feature_id), + None, + ) + if binding_index is None: + raise ValueError(f"{feature['id']}: selector binding feature is missing or forward") prefix_count = binding_index + 1 if prefix_count not in prefix_cache: - prefix = deepcopy(bound); prefix["features"] = bound["features"][:prefix_count] - if not prefix["features"]: raise ValueError(f"{feature['id']}: selector has no executable prefix") - report = rebuild_cdsl(prefix, Path(temporary) / f"prefix-{index}-{prefix_count}.step", strict=True) - body_id = next((item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")), None) - prefix_cache[prefix_count] = (list(report.get("topology_records") or []), body_id) - all_records, body_id = prefix_cache[prefix_count] - return [ - item for item in all_records - # Reference planes and axes are session context, not body - # topology. They must remain available while binding a mirror - # or extent selector against a body-bearing prefix. - if item.get("kind") in {"plane", "axis"} - or not body_id - or item.get("body_id") == body_id - or str(item.get("body_id") or "").startswith(f"{body_id}:") - ] - - resolved = [] - for placeholder in targets: - records = prefix_records(placeholder.get("binding_feature_id"), placeholder.get("owner_feature_id")) - output_role = str(placeholder.get("output_role") or "").strip() - intent = placeholder.get("selector_intent") - if ( - isinstance(intent, dict) - and intent.get("query_family") != "GEOMETRIC" - and not output_role - ): - # Prefix rebuilding is retained as a diagnostic adapter. - # It must not turn an unproven FeatureScript provenance - # query into a geometry-scored stable selector. - raise ValueError( - f"{feature['id']}: selector_kernel_history_missing after prefix rebuild" + prefix = deepcopy(bound) + prefix["features"] = bound["features"][:prefix_count] + if not prefix["features"]: + raise ValueError(f"{feature['id']}: selector has no executable prefix") + report = rebuild_cdsl( + prefix, + Path(temporary) / f"prefix-{index}-{prefix_count}.step", + strict=True, ) - if output_role: - # Builder output roles are not geometry placeholders. They - # remain in the bound CDSL so runtime can resolve the - # current active B-rep face through exact kernel history. - # Replacing one with stable_id/geometry would mix evidence - # and make a stale snapshot appear durable. - owner = placeholder.get("owner_feature_id") - role_source = placeholder.get("output_role_source") - source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None - source_role = role_source.get("output_role") if isinstance(role_source, dict) else None - candidates = [ - record for record in records - if record.get("kind") == placeholder.get("kind") - and owner in (record.get("owner_feature_ids") or [record.get("feature_id")]) - and output_role in (record.get("output_roles") or []) - and ( - role_source is None - or any( - item.get("output_role") == output_role - and item.get("owner_feature_id") == source_owner - and item.get("source_output_role") == source_role - for item in record.get("output_role_sources") or [] - ) - ) - ] - if len(candidates) != 1: - status = "not_found" if not candidates else "ambiguous" - raise ValueError( - f"{feature['id']}: selector_output_role_{status} after prefix rebuild" - ) - resolved.append(candidates[0]) - continue - geometry = placeholder.get("geometry") or {} - candidates = _circle_records(geometry, records) if geometry.get("source_circle_radius_mm") else [] - if not candidates: - same_kind = [record for record in records if record.get("kind") == placeholder.get("kind")] - owner = placeholder.get("owner_feature_id") - owner_matches = [record for record in same_kind if owner in (record.get("owner_feature_ids") or [record.get("feature_id")])] - # Pattern instance provenance is stronger than an ordinary - # feature owner: an unresolved instance cannot fall back to - # an aggregate face with matching geometry. Ordinary source - # selectors retain their established geometry-binding path - # across topology-changing dress-up operations. - pool = owner_matches if placeholder.get("owner_match_required") else (owner_matches or same_kind) - if not geometry: - # Context selectors (notably a generated mirror plane) - # may have no geometric snapshot. Their owner-qualified - # singleton identity is sufficient and must not be scored - # as a zero-information geometric match. - if len(pool) != 1: - status = "not_found" if not pool else "ambiguous" - raise ValueError(f"{feature['id']}: selector_{status} after prefix rebuild") - candidates = [pool[0]] - else: - scored = [(score, record) for record in pool if (score := _score(geometry, record.get("geometry") or {})) is not None and score >= 0.8] - # A source owner is preferred for ordinary geometry - # selectors, but can be retained by an unrelated exact - # continuation after a dress-up. If none of that - # owner's active records satisfies the full geometry - # signature, bind against the current active body and - # still require a unique threshold-qualified match. - # Instance-qualified selectors never take this path. - if not scored and not placeholder.get("owner_match_required"): - scored = [ - (score, record) - for record in same_kind - if (score := _score(geometry, record.get("geometry") or {})) is not None - and score >= 0.8 - ] - scored.sort(key=lambda value: (-value[0], str(value[1].get("record_id")))) - if scored: - if placeholder.get("match_mode") != "all" and len(scored) > 1 and abs(scored[0][0] - scored[1][0]) <= 1e-9: - raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild") - candidates = [item[1] for item in scored] if placeholder.get("match_mode") == "all" else [scored[0][1]] - if not candidates: raise ValueError(f"{feature['id']}: selector_not_found after prefix rebuild") - selector, bound_selectors = _bound_selector(placeholder, candidates) - placeholder.clear(); placeholder.update(selector) - resolved.extend(bound_selectors) - feature_selectors = feature.get("selectors") or [] - def selector_key(selector: dict[str, Any]) -> tuple[Any, ...]: - stable_id = selector.get("stable_id") - if stable_id is not None: - return ("stable_id", str(stable_id)) - source = selector.get("output_role_source") - return ( - "output_role", - selector.get("owner_feature_id"), - selector.get("kind"), - selector.get("output_role"), - source.get("owner_feature_id") if isinstance(source, dict) else None, - source.get("output_role") if isinstance(source, dict) else None, - ) + body_id = next( + (item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")), + None, + ) + prefix_cache[prefix_count] = ( + list(report.get("topology_records") or []), + body_id, + list(report.get("topology_deltas") or []), + ) + return prefix_cache[prefix_count] - unique = {selector_key(selector): selector for selector in feature_selectors} - feature["selectors"] = list(unique.values()) + resolved: list[dict[str, Any]] = [] + for placeholder in targets: + records, body_id, topology_deltas = prefix_snapshot(placeholder.get("binding_feature_id")) + registry = TopologyRegistry.from_public_snapshot(records, topology_deltas) + intent = placeholder.get("selector_intent") + runtime_selector = _runtime_selector(placeholder) + resolution = registry.resolve(runtime_selector, active_body_id=body_id) + if ( + resolution.status != "resolved" + and not isinstance(intent, dict) + and not runtime_selector.get("owner_match_required") + and not runtime_selector.get("output_role") + ): + fallback = dict(runtime_selector) + fallback.pop("owner_feature_id", None) + resolution = registry.resolve(fallback, active_body_id=body_id) + if resolution.status != "resolved": + code = resolution.diagnostic.code if resolution.diagnostic is not None else f"selector_{resolution.status}" + raise ValueError(f"{feature['id']}: {code} after prefix rebuild") + selected = list(resolution.records or ((resolution.record,) if resolution.record is not None else ())) + if not selected: + raise ValueError(f"{feature['id']}: selector_not_found after prefix rebuild") + public_records = [record.public_dict() for record in selected] + if (isinstance(intent, dict) and intent.get("query_family") != "GEOMETRIC") or placeholder.get("output_role"): + resolved.extend(public_records) + continue + selector, bound_selectors = _bound_selector(placeholder, public_records) + placeholder.clear() + placeholder.update(selector) + resolved.extend(bound_selectors) + feature["selectors"] = list({_selector_key(selector): selector for selector in feature.get("selectors") or []}.values()) if feature.get("atomic_id") == "pattern_mirror": planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"] if len(planes) != 1: raise ValueError(f"{feature['id']}: mirror plane binding is not unique") feature.setdefault("params", {})["mirror_plane"] = planes[0] - evidence.append({"feature_id": feature["id"], "prefix_feature_count": index, "selectors": feature["selectors"], "resolved": resolved}) + evidence.append({ + "feature_id": feature["id"], + "prefix_feature_count": index, + "selectors": feature["selectors"], + "resolved": resolved, + }) return bound, evidence diff --git a/cadfs_to_cdsl/selector_candidate_demo.py b/cadfs_to_cdsl/selector_candidate_demo.py new file mode 100644 index 00000000..1ce751f0 --- /dev/null +++ b/cadfs_to_cdsl/selector_candidate_demo.py @@ -0,0 +1,897 @@ +"""Offline experiment for geometry-only CADFS selector candidates. + +The production resolver must not consume this module. It removes provenance +intent only in a copied CDSL document, then compares that diagnostic replay to +the source's final STEP. A strict passing probe is replayed a second time from +its bound CDSL and can emit a *behavioural* selector record. The record is +training/diagnostic evidence, not a claim that a heuristic recovered Onshape's +hidden topology identity. +""" + +from __future__ import annotations + +import argparse +from copy import deepcopy +import json +import multiprocessing +from pathlib import Path +from typing import Any + +from .compare import compare_steps +from .dataset import Sample, scan_dataset +from .featurescript_parser import parse_featurescript +from .lowering import lower_model +from .rebuild import rebuild_candidate +from .reports import read_json, write_json, write_manifest + + +def strip_provenance_intents(value: Any) -> int: + """Remove provenance-only selector fields from one copied CDSL value.""" + removed = 0 + if isinstance(value, dict): + for key in ("selector_intent", "selector_intent_version"): + if key in value: + value.pop(key) + removed += 1 + for child in value.values(): + removed += strip_provenance_intents(child) + elif isinstance(value, list): + for child in value: + removed += strip_provenance_intents(child) + return removed + + +def _write_rebuild_artifacts(directory: Path, name: str, outcome: dict[str, Any]) -> None: + """Persist the same useful replay artifacts as the regular pipeline.""" + artifact = deepcopy(outcome) + bound = artifact.pop("bound_cdsl", None) + prefix = artifact.get("last_executable_prefix") + if isinstance(prefix, dict): + prefix_bound = prefix.pop("bound_cdsl", None) + if bound is None: + bound = prefix_bound + write_json(directory / f"{name}.rebuild.json", artifact) + if isinstance(bound, dict): + write_json(directory / f"{name}.bound.cdsl.json", bound) + + +def _rebuild_worker(candidate: str, step: str, result: str) -> None: + write_json(Path(result), rebuild_candidate(read_json(Path(candidate)), Path(step))) + + +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: + """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) + process.start() + process.join(timeout_seconds) + if process.is_alive(): + process.terminate() + process.join(5) + if process.is_alive(): + process.kill() + process.join() + return "timeout" + return "completed" if process.exitcode == 0 and result_path.exists() else "failed" + + +def _run_rebuild(candidate_path: Path, step: Path, *, timeout_seconds: float) -> dict[str, Any]: + worker = step.with_suffix(".worker.json") + outcome = _isolated(_rebuild_worker, (str(candidate_path), str(step), str(worker)), worker, timeout_seconds) + if outcome == "completed": + result = read_json(worker) + worker.unlink(missing_ok=True) + return result + step.unlink(missing_ok=True) + return { + "status": "rebuild_timeout" if outcome == "timeout" else "rebuild_failed", + "error": { + "type": "TimeoutError" if outcome == "timeout" else "WorkerProcessError", + "message": f"selector candidate rebuild exceeded {timeout_seconds:g} seconds" if outcome == "timeout" else "selector candidate rebuild worker exited without a result", + }, + } + + +def _run_comparison(gold_step: Path, rebuilt_step: Path, output: Path, *, timeout_seconds: float) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + worker = output.with_suffix(".worker.json") + outcome = _isolated(_compare_worker, (str(gold_step), str(rebuilt_step), str(worker)), worker, timeout_seconds) + if outcome == "completed": + comparison = read_json(worker) + worker.unlink(missing_ok=True) + return comparison, None + return None, { + "type": "TimeoutError" if outcome == "timeout" else "WorkerProcessError", + "message": f"selector candidate comparison exceeded {timeout_seconds:g} seconds" if outcome == "timeout" else "selector candidate comparison worker exited without a result", + } + + +def _outcome_summary(outcome: dict[str, Any], comparison: dict[str, Any] | None) -> dict[str, Any]: + summary: dict[str, Any] = { + "rebuild_status": outcome.get("status"), + "last_executable_prefix": { + key: value + for key, value in (outcome.get("last_executable_prefix") or {}).items() + if key in {"failed_feature_id", "feature_count", "last_feature_id"} + }, + } + error = outcome.get("error") + if isinstance(error, dict): + summary["error"] = {key: error.get(key) for key in ("type", "message")} + if comparison is not None: + summary["comparison"] = { + "decision": comparison.get("decision"), + "strict_passed": bool((comparison.get("strict") or {}).get("passed")), + "rp_passed": bool((comparison.get("rp") or {}).get("passed")), + } + return summary + + +def _strict_passed(comparison: dict[str, Any] | None) -> bool: + return bool(isinstance(comparison, dict) and (comparison.get("strict") or {}).get("passed")) + + +def _selector_sites(feature: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: + """Return the public selector positions of one CDSL feature. + + The locations are deliberately structural rather than runtime IDs. A + selector record must remain useful when a fresh replay creates different + topology-record instances. + """ + sites: list[tuple[str, dict[str, Any]]] = [] + for index, selector in enumerate(feature.get("selectors") or []): + if isinstance(selector, dict): + sites.append((f"selectors[{index}]", selector)) + params = feature.get("params") or {} + for name in ("end_condition", "reverse_end_condition"): + condition = params.get(name) + reference = condition.get("reference") if isinstance(condition, dict) else None + if isinstance(reference, dict): + sites.append((f"params.{name}.reference", reference)) + return sites + + +def _semantic_source_selector(selector: dict[str, Any]) -> dict[str, Any] | None: + """Keep the FeatureScript meaning, never a source runtime topology ID.""" + intent = selector.get("selector_intent") + if not isinstance(intent, dict): + return None + result = { + key: deepcopy(selector[key]) + for key in ("kind", "owner_feature_id", "binding_feature_id", "output_role") + if selector.get(key) is not None + } + result["selector_intent"] = deepcopy(intent) + return result + + +def _selector_record_entries( + sample_id: str, + provenance_candidate: dict[str, Any], + compiled_cdsl: dict[str, Any], + selector_binding: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Extract FeatureScript meaning plus a freshly replayable binding plan. + + A geometry probe has only the selected record, not rejected alternatives. + We preserve that limitation explicitly. When source and compiled selector + counts differ (for example, one source query expands to several topology + records), emit a feature group instead of inventing a one-to-one mapping. + """ + source_by_id = { + str(feature.get("id")): feature + for feature in provenance_candidate.get("features") or [] + if isinstance(feature, dict) and isinstance(feature.get("id"), str) + } + compiled_by_id = { + str(feature.get("id")): feature + for feature in compiled_cdsl.get("features") or [] + if isinstance(feature, dict) and isinstance(feature.get("id"), str) + } + entries: list[dict[str, Any]] = [] + for binding in selector_binding: + feature_id = binding.get("feature_id") + if not isinstance(feature_id, str): + continue + source_feature = source_by_id.get(feature_id) + compiled_feature = compiled_by_id.get(feature_id) + if source_feature is None or compiled_feature is None: + continue + source_sites = [ + (location, semantic) + for location, selector in _selector_sites(source_feature) + if (semantic := _semantic_source_selector(selector)) is not None + ] + if not source_sites: + continue + compiled_sites = _selector_sites(compiled_feature) + resolved = [deepcopy(item) for item in binding.get("resolved") or [] if isinstance(item, dict)] + feature_context = { + "sample_id": sample_id, + "feature_id": feature_id, + "atomic_id": compiled_feature.get("atomic_id"), + "prefix_feature_count": binding.get("prefix_feature_count"), + "verification_scope": "behavioural_strict_replay", + } + if len(source_sites) == len(compiled_sites) == len(resolved): + for index, ((location, source_selector), (_compiled_location, compiled_selector), resolved_record) in enumerate( + zip(source_sites, compiled_sites, resolved) + ): + entries.append({ + "schema": "cadfs_to_cdsl.selector_record.v1", + "entry_id": f"{sample_id}:{feature_id}:{location}", + **feature_context, + "source_location": location, + "source_selector": source_selector, + "compiled_selector": deepcopy(compiled_selector), + "resolved_record": resolved_record, + "mapping": "one_to_one", + "limitations": [ + "The source dataset has no per-feature Onshape query witness.", + "The compiled selector is verified only by a fresh local strict replay.", + "Runtime snapshot IDs are replay evidence, not portable topology identities.", + ], + }) + continue + entries.append({ + "schema": "cadfs_to_cdsl.selector_record.v1", + "entry_id": f"{sample_id}:{feature_id}:feature_group", + **feature_context, + "source_selectors": [ + {"location": location, "selector": selector} + for location, selector in source_sites + ], + "compiled_selectors": [ + {"location": location, "selector": deepcopy(selector)} + for location, selector in compiled_sites + ], + "resolved_records": resolved, + "mapping": "feature_group", + "limitations": [ + "The source query expanded or contracted during local binding, so this record does not claim a one-to-one mapping.", + "The source dataset has no per-feature Onshape query witness.", + "The compiled selector is verified only by a fresh local strict replay.", + "Runtime snapshot IDs are replay evidence, not portable topology identities.", + ], + }) + return entries + + +def _emit_strict_selector_record( + sample: Sample, + directory: Path, + provenance_candidate: dict[str, Any], + geometry_probe: dict[str, Any], + geometry_comparison: dict[str, Any] | None, + gold_step: Path, + *, + rebuild_timeout_seconds: float, + comparison_timeout_seconds: float, +) -> dict[str, Any]: + """Create and independently verify a compiled CDSL only after strict proof.""" + if not _strict_passed(geometry_comparison): + return { + "status": "not_emitted", + "reason": "geometry_probe_not_strict", + } + bound_cdsl = geometry_probe.get("bound_cdsl") + binding = geometry_probe.get("selector_binding") + if not isinstance(bound_cdsl, dict) or not isinstance(binding, list): + return { + "status": "not_emitted", + "reason": "geometry_probe_binding_missing", + } + + compiled_candidate = deepcopy(bound_cdsl) + compiled_path = directory / "selector_record.compiled.cdsl.json" + write_json(compiled_path, compiled_candidate) + compiled = _run_rebuild( + compiled_path, + directory / "selector_record.compiled.rebuild.step", + timeout_seconds=rebuild_timeout_seconds, + ) + _write_rebuild_artifacts(directory, "selector_record.compiled", compiled) + comparison = None + comparison_error = None + if compiled.get("status") == "rebuilt": + comparison, comparison_error = _run_comparison( + gold_step, + directory / "selector_record.compiled.rebuild.step", + directory / "selector_record.compiled.comparison.json", + timeout_seconds=comparison_timeout_seconds, + ) + if comparison is not None: + write_json(directory / "selector_record.compiled.comparison.json", comparison) + + result = { + "status": "not_emitted", + "compiled_cdsl": str(compiled_path), + "compiled_replay": _outcome_summary(compiled, comparison), + } + if comparison_error is not None: + result["compiled_replay"]["comparison_error"] = comparison_error + if not _strict_passed(comparison): + result["reason"] = "compiled_cdsl_not_strict" + return result + + records = _selector_record_entries(sample.sample_id, provenance_candidate, compiled_candidate, binding) + if not records: + result["reason"] = "no_featurescript_selector_binding" + return result + record_path = directory / "selector-record.json" + write_json(record_path, { + "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), + }, + "records": records, + }) + result.update({ + "status": "strict_replayed", + "record_count": len(records), + "record_path": str(record_path), + }) + return result + + +def _searchable_selector_sites(feature: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: + """Return direct selector roots that can be replaced as a search branch. + + The experimental search does not flatten an intersection query into an + unrelated member. It can search direct roots and extent references; other + query trees remain a bounded unsupported search outcome. + """ + sites = _selector_sites(feature) + return [ + (location, selector) + for location, selector in sites + if not isinstance(selector.get("intersection_of"), list) + ] + + +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.""" + 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) + return + prefix = "params." + suffix = ".reference" + if location.startswith(prefix) and location.endswith(suffix): + name = location[len(prefix):-len(suffix)] + 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}") + + +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.""" + 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 + + +def _resolve_search_selector(placeholder: dict[str, Any], *, registry: Any, active_body_id: str | None) -> Any: + """Mirror the legacy diagnostic binding policy without changing it.""" + from .selector_binding import _runtime_selector + + runtime_selector = _runtime_selector(placeholder) + resolution = registry.resolve(runtime_selector, active_body_id=active_body_id) + intent = placeholder.get("selector_intent") + if ( + resolution.status != "resolved" + and not isinstance(intent, dict) + and not runtime_selector.get("owner_match_required") + and not runtime_selector.get("output_role") + ): + fallback = dict(runtime_selector) + fallback.pop("owner_feature_id", None) + resolution = registry.resolve(fallback, active_body_id=active_body_id) + return resolution + + +def _search_candidate_records( + resolution: Any, + *, + maximum: int, + minimum_score: float = 0.8, +) -> tuple[list[dict[str, Any]], int]: + """Keep only candidates the normal resolver considers geometrically valid.""" + candidates = [ + item for item in resolution.candidates + if isinstance(item, dict) + and item.get("record_id") + and float(item.get("score") or 0.0) >= minimum_score + ] + candidates.sort(key=lambda item: (-float(item.get("score") or 0.0), str(item.get("record_id")))) + return [deepcopy(item) for item in candidates[:maximum]], len(candidates) + + +def _search_replay_worker(candidate: 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)) + 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) + code = first.blockers[0].code if first and first.blockers else "runtime_ineligible" + write_json(Path(result), {"status": "runtime_ineligible", "reason": code}) + return + bound_features = { + str(feature.get("id") or ""): feature + for feature in bound.get("features") or [] + if isinstance(feature, dict) + } + body_id_for_feature: dict[str, str | None] = {"__current__": None} + evidence: list[dict[str, Any]] = [] + for index, node in enumerate(execution.analysis.plan): + feature = node.source_feature + sites = _searchable_selector_sites(feature) + if len(sites) != len(_selector_sites(feature)): + write_json(Path(result), { + "status": "search_unsupported", + "feature_id": node.feature_id, + "reason": "nested_selector_tree", + "bound_cdsl": bound, + "selector_binding": evidence, + }) + return + resolved: list[dict[str, Any]] = [] + for location, placeholder in sites: + binding_feature_id = placeholder.get("binding_feature_id") + if binding_feature_id is None: + active_body_id = body_id_for_feature["__current__"] + elif isinstance(binding_feature_id, str) and binding_feature_id in body_id_for_feature: + active_body_id = body_id_for_feature[binding_feature_id] + else: + write_json(Path(result), { + "status": "search_failed", + "feature_id": node.feature_id, + "reason": "selector_binding_feature_missing_or_forward", + "bound_cdsl": bound, + "selector_binding": evidence, + }) + return + resolution = _resolve_search_selector( + placeholder, + registry=execution.session.topology, + active_body_id=active_body_id, + ) + if resolution.status != "resolved": + candidates, eligible_count = _search_candidate_records( + resolution, + maximum=maximum_candidates, + ) + if candidates: + write_json(Path(result), { + "status": "branchable_selector", + "feature_id": node.feature_id, + "source_location": location, + "resolution_status": resolution.status, + "diagnostic": resolution.diagnostic.as_dict() if resolution.diagnostic is not None else None, + "candidates": candidates, + "candidate_count": eligible_count, + "bound_cdsl": bound, + "selector_binding": evidence, + }) + return + code = resolution.diagnostic.code if resolution.diagnostic is not None else f"selector_{resolution.status}" + write_json(Path(result), { + "status": "search_failed", + "feature_id": node.feature_id, + "reason": code, + "bound_cdsl": bound, + "selector_binding": evidence, + }) + return + selected = list(resolution.records or ((resolution.record,) if resolution.record is not None else ())) + if not selected: + write_json(Path(result), { + "status": "search_failed", + "feature_id": node.feature_id, + "reason": "selector_not_found", + "bound_cdsl": bound, + "selector_binding": evidence, + }) + return + public_records = [record.public_dict() for record in selected] + selector, bound_selectors = _bound_selector(placeholder, public_records) + placeholder.clear() + placeholder.update(selector) + resolved.extend(bound_selectors) + feature["selectors"] = list({ + _selector_key(selector): selector + for selector in feature.get("selectors") or [] + }.values()) + if feature.get("atomic_id") == "pattern_mirror": + planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"] + if len(planes) != 1: + write_json(Path(result), { + "status": "search_failed", + "feature_id": node.feature_id, + "reason": "mirror_plane_binding_not_unique", + "bound_cdsl": bound, + "selector_binding": evidence, + }) + return + feature.setdefault("params", {})["mirror_plane"] = planes[0] + public_feature = bound_features[node.feature_id] + public_feature["selectors"] = deepcopy(feature.get("selectors") or []) + public_feature["params"] = deepcopy(feature.get("params") or {}) + evidence.append({ + "feature_id": node.feature_id, + "prefix_feature_count": index, + "selectors": public_feature["selectors"], + "resolved": resolved, + }) + try: + execution.execute_next(strict=True) + except Exception as error: + write_json(Path(result), { + "status": "search_failed", + "feature_id": node.feature_id, + "reason": str(error), + "bound_cdsl": bound, + "selector_binding": evidence, + }) + return + body_id_for_feature[node.feature_id] = execution.session.body_id + body_id_for_feature["__current__"] = execution.session.body_id + write_json(Path(result), { + "status": "rebuilt", + "bound_cdsl": bound, + "selector_binding": evidence, + "result": finalize_cdsl_execution(execution, Path(step)), + }) + + +def _run_search_replay(candidate_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), + worker, + timeout_seconds, + ) + if outcome == "completed": + result = read_json(worker) + worker.unlink(missing_ok=True) + return result + step.unlink(missing_ok=True) + return { + "status": "search_timeout" if outcome == "timeout" else "search_failed", + "reason": "search_worker_timeout" if outcome == "timeout" else "search_worker_failed", + } + + +def _public_branch_summary(result: dict[str, Any], choices: list[dict[str, Any]], comparison: dict[str, Any] | None = None) -> dict[str, Any]: + summary = { + "status": result.get("status"), + "feature_id": result.get("feature_id"), + "source_location": result.get("source_location"), + "reason": result.get("reason"), + "resolution_status": result.get("resolution_status"), + "choice_path": choices, + } + if comparison is not None: + summary["comparison"] = { + "decision": comparison.get("decision"), + "strict_passed": _strict_passed(comparison), + } + return {key: value for key, value in summary.items() if value is not None} + + +def _run_selector_search( + sample: Sample, + directory: Path, + provenance_candidate: dict[str, Any], + geometry_candidate: dict[str, Any], + gold_step: Path, + *, + rebuild_timeout_seconds: float, + comparison_timeout_seconds: float, + maximum_branches: int, + maximum_candidates: int, +) -> tuple[dict[str, Any], dict[str, Any] | None]: + """Explore selector alternatives, accepting only a unique strict winner. + + Each branch is re-executed from an untouched CDSL document. We therefore + never share mutable OCC topology between alternatives and cannot quietly + inherit the result of a previous candidate. + """ + if maximum_branches <= 0 or maximum_candidates <= 0: + 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), [])] + 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]] = [] + budget_exhausted = False + candidates_truncated = False + branch_index = 0 + while pending: + if branch_index >= maximum_branches: + budget_exhausted = True + break + candidate, choices = pending.pop(0) + fingerprint = json.dumps(candidate, sort_keys=True, separators=(",", ":")) + if fingerprint in seen: + continue + seen.add(fingerprint) + branch_index += 1 + branch_name = f"branch-{branch_index:03d}" + candidate_path = search_dir / f"{branch_name}.candidate.cdsl.json" + step_path = search_dir / f"{branch_name}.rebuild.step" + write_json(candidate_path, candidate) + outcome = _run_search_replay( + candidate_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): + candidates_truncated = True + branch_summaries.append(_public_branch_summary(outcome, choices)) + for candidate_record in candidates: + 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) + pending.append((child, [*choices, { + "feature_id": feature_id, + "source_location": location, + "record_id": candidate_record.get("record_id"), + "score": candidate_record.get("score"), + }])) + continue + comparison = None + if outcome.get("status") == "rebuilt": + comparison, comparison_error = _run_comparison( + gold_step, + step_path, + search_dir / f"{branch_name}.comparison.json", + timeout_seconds=comparison_timeout_seconds, + ) + if comparison is not None: + write_json(search_dir / f"{branch_name}.comparison.json", comparison) + elif comparison_error is not None: + outcome["comparison_error"] = comparison_error + terminals.append((outcome, candidate, choices, comparison)) + branch_summaries.append(_public_branch_summary(outcome, choices, comparison)) + if pending: + budget_exhausted = True + strict_winners = [item for item in terminals if _strict_passed(item[3])] + search_complete = not budget_exhausted and not candidates_truncated + report: dict[str, Any] = { + "status": "unique_strict" if search_complete and len(strict_winners) == 1 else "no_unique_strict", + "branches_executed": branch_index, + "maximum_branches": maximum_branches, + "maximum_candidates_per_selector": maximum_candidates, + "search_complete": search_complete, + "budget_exhausted": budget_exhausted, + "candidates_truncated": candidates_truncated, + "strict_winner_count": len(strict_winners), + "branches": branch_summaries, + } + if not search_complete: + report["reason"] = "search_budget_incomplete" + return report, None + if not strict_winners: + report["reason"] = "no_strict_branch" + return report, None + if len(strict_winners) > 1: + report["reason"] = "multiple_strict_branches" + report["strict_choice_paths"] = [item[2] for item in strict_winners] + return report, None + winner, _candidate, choices, comparison = strict_winners[0] + report["strict_choice_path"] = choices + selector_record = _emit_strict_selector_record( + sample, + search_dir, + provenance_candidate, + winner, + comparison, + gold_step, + rebuild_timeout_seconds=rebuild_timeout_seconds, + comparison_timeout_seconds=comparison_timeout_seconds, + ) + report["selector_record"] = selector_record + return report, selector_record if selector_record.get("status") == "strict_replayed" else None + + +def run_geometry_probe( + sample: Sample, + output: Path, + *, + rebuild_timeout_seconds: float = 30.0, + comparison_timeout_seconds: float = 60.0, + search_maximum_branches: int = 32, + search_maximum_candidates: int = 8, +) -> dict[str, Any]: + """Compare normal provenance replay with an explicitly heuristic replay. + + The geometry replay reuses only geometry/stable-ID fields already emitted as + diagnostic context. It never invents a selector, writes a new source + parameter, or changes the production candidate. + """ + featurescript = Path(sample.files["featurescript"]) + gold_step = Path(sample.files["step"]) + if not featurescript.is_file() or not gold_step.is_file(): + raise FileNotFoundError(f"{sample.sample_id}: the FeatureScript and source STEP are required") + directory = output / "samples" / sample.sample_id + directory.mkdir(parents=True, exist_ok=True) + result = lower_model(parse_featurescript(featurescript.read_text(encoding="utf-8"), sample.sample_id), {}) + if not isinstance(result.cdsl, dict): + raise ValueError(f"{sample.sample_id}: lowering produced no CDSL candidate") + + provenance_candidate = result.cdsl + geometry_candidate = deepcopy(provenance_candidate) + removed_intents = strip_provenance_intents(geometry_candidate) + write_json(directory / "provenance.candidate.cdsl.json", provenance_candidate) + write_json(directory / "geometry_probe.candidate.cdsl.json", geometry_candidate) + + provenance = _run_rebuild( + directory / "provenance.candidate.cdsl.json", + directory / "provenance.rebuild.step", + timeout_seconds=rebuild_timeout_seconds, + ) + geometry_probe = _run_rebuild( + directory / "geometry_probe.candidate.cdsl.json", + directory / "geometry_probe.rebuild.step", + timeout_seconds=rebuild_timeout_seconds, + ) + _write_rebuild_artifacts(directory, "provenance", provenance) + _write_rebuild_artifacts(directory, "geometry_probe", geometry_probe) + + comparison = None + comparison_error = None + if geometry_probe.get("status") == "rebuilt": + comparison, comparison_error = _run_comparison( + gold_step, + directory / "geometry_probe.rebuild.step", + directory / "geometry_probe.comparison.json", + timeout_seconds=comparison_timeout_seconds, + ) + if comparison is not None: + write_json(directory / "geometry_probe.comparison.json", comparison) + + selector_record = _emit_strict_selector_record( + sample, + directory, + provenance_candidate, + geometry_probe, + comparison, + gold_step, + rebuild_timeout_seconds=rebuild_timeout_seconds, + comparison_timeout_seconds=comparison_timeout_seconds, + ) + selector_search = {"status": "not_needed"} + if not _strict_passed(comparison): + selector_search, searched_record = _run_selector_search( + sample, + directory, + provenance_candidate, + geometry_candidate, + gold_step, + rebuild_timeout_seconds=rebuild_timeout_seconds, + comparison_timeout_seconds=comparison_timeout_seconds, + maximum_branches=search_maximum_branches, + maximum_candidates=search_maximum_candidates, + ) + if searched_record is not None: + selector_record = searched_record + + report = { + "schema": "cadfs_to_cdsl.selector_candidate_geometry_probe.v1", + "sample_id": sample.sample_id, + "source": {"featurescript": str(featurescript), "step": str(gold_step)}, + "strategy": { + "name": "geometry_diagnostic_context", + "removed_provenance_intent_fields": removed_intents, + "classification": "heuristic_only", + "limitations": [ + "The source dataset contains only a final STEP, not per-feature source checkpoints.", + "A final strict or RP pass does not prove that the FeatureScript selector is semantically correct.", + "This probe must not be used by the production resolver or counted as provenance-selector coverage.", + ], + }, + "provenance": _outcome_summary(provenance, None), + "geometry_probe": _outcome_summary(geometry_probe, comparison), + "selector_record": selector_record, + "selector_search": selector_search, + } + if comparison_error is not None: + report["geometry_probe"]["comparison_error"] = comparison_error + write_json(directory / "report.json", report) + return report + + +def _arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run an offline geometry-only selector candidate probe and strict record replay") + parser.add_argument("--input", type=Path, default=Path("data/cadfs-sample/CADFS_test")) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--sample-id", action="append", required=True) + parser.add_argument("--rebuild-timeout-seconds", type=float, default=30.0) + parser.add_argument("--comparison-timeout-seconds", type=float, default=60.0) + parser.add_argument("--search-maximum-branches", type=int, default=32) + parser.add_argument("--search-maximum-candidates", type=int, default=8) + return parser.parse_args() + + +def main() -> int: + args = _arguments() + samples = {sample.sample_id: sample for sample in scan_dataset(args.input, include_hashes=False)} + missing = sorted(set(args.sample_id) - samples.keys()) + if missing: + raise ValueError("unknown sample ids: " + ", ".join(missing)) + if args.rebuild_timeout_seconds <= 0 or args.comparison_timeout_seconds <= 0: + raise ValueError("candidate probe timeouts must be positive") + if args.search_maximum_branches < 0 or args.search_maximum_candidates < 0: + raise ValueError("search budgets cannot be negative") + reports = [ + run_geometry_probe( + samples[sample_id], + args.output, + rebuild_timeout_seconds=args.rebuild_timeout_seconds, + comparison_timeout_seconds=args.comparison_timeout_seconds, + search_maximum_branches=args.search_maximum_branches, + search_maximum_candidates=args.search_maximum_candidates, + ) + for sample_id in args.sample_id + ] + records: list[dict[str, Any]] = [] + for report in reports: + record = report.get("selector_record") or {} + record_path = record.get("record_path") if isinstance(record, dict) else None + if isinstance(record_path, str) and Path(record_path).is_file(): + payload = read_json(Path(record_path)) + records.extend(item for item in payload.get("records") or [] if isinstance(item, dict)) + write_manifest(args.output / "selector-records.jsonl", records) + write_json(args.output / "summary.json", { + "schema": "cadfs_to_cdsl.selector_candidate_geometry_probe_summary.v2", + "sample_count": len(reports), + "strict_selector_record_count": len(records), + "reports": reports, + }) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cadfs_to_cdsl/tests/test_integration.py b/cadfs_to_cdsl/tests/test_integration.py index 1a62bd0b..9ecf0ce6 100644 --- a/cadfs_to_cdsl/tests/test_integration.py +++ b/cadfs_to_cdsl/tests/test_integration.py @@ -10,7 +10,7 @@ from cadfs_to_cdsl.rebuild import rebuild_candidate class IntegrationTests(unittest.TestCase): - def test_known_rp_roundtrip_00000173(self): + def test_known_rp_source_preserves_prefix_when_selector_query_is_deferred(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0000/00000173.txt"; gold = root / "step_abc/0000/00000173.step" if not feature.exists() or not gold.exists(): self.skipTest("CADFS sample is not installed") @@ -18,11 +18,9 @@ class IntegrationTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp: rebuilt = Path(tmp) / "rebuild.step" result = rebuild_candidate(cdsl, rebuilt) - self.assertEqual(result["status"], "rebuilt") - comparison = compare_steps(gold, rebuilt) - self.assertFalse(comparison["strict"]["passed"]) - self.assertTrue(comparison["rp"]["passed"]) - self.assertEqual(comparison["decision"], "approximate_pass") + self.assertEqual(result["status"], "rebuild_failed") + self.assertEqual(result["error"]["message"], "f_F2: selector_query_unsupported during incremental replay") + self.assertEqual(result["last_executable_prefix"]["last_feature_id"], "f_F1") def test_standard_tapped_through_counterbore_00002243(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -43,9 +41,30 @@ class IntegrationTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp: rebuilt = Path(tmp) / "rebuild.step" - self.assertEqual(rebuild_candidate(result.cdsl, rebuilt)["status"], "rebuilt") - comparison = compare_steps(root / "step_abc/0000/00002243.step", rebuilt) - self.assertTrue(comparison["strict"]["passed"]) + outcome = rebuild_candidate(result.cdsl, rebuilt) + self.assertEqual(outcome["status"], "rebuild_failed") + 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_direct_sketch_vertex_holes_preserve_scoped_body_00406667(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0040/00406667.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + candidate = lower_model(parse_featurescript(feature.read_text(), "00406667"), {}).cdsl + holes = [item for item in candidate["features"] if item["atomic_id"] == "hole_wizard"] + self.assertEqual([item["id"] for item in holes], ["f_F3", "f_F5"]) + self.assertEqual([item["params"]["scope_feature_id"] for item in holes], ["f_F1", "f_F1"]) + self.assertEqual([item["params"]["positions"] for item in holes], [ + [{"mm": [0.0, 0.0, 0.0]}], [{"mm": [0.0, 26.69, 0.0]}], + ]) + + with tempfile.TemporaryDirectory() as tmp: + outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step") + self.assertEqual(outcome["status"], "rebuilt") + self.assertEqual( + [item["feature_id"] for item in outcome["result"]["feature_results"]], + ["f_F1", "f_F3", "f_F5"], + ) def test_rectilinear_fillet_arcs_00129362_preserve_recovered_sketch_geometry(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -75,9 +94,15 @@ class IntegrationTests(unittest.TestCase): fillet = next(item for item in result.cdsl["features"] if item["id"] == "f_F3") self.assertEqual(fillet["atomic_id"], "fillet") self.assertEqual(fillet["selectors"][0]["kind"], "face") + self.assertNotIn("geometry", fillet["selectors"][0]) + self.assertEqual(fillet["selectors"][0]["selector_intent"]["query_family"], "SWEPT_FACE") self.assertEqual( - fillet["selectors"][0]["geometry"], - {"axis_origin_mm": [0.0, 0.0, 0.0], "axis_direction": [0.0, -1.0, 0.0], "radius_mm": 45.66}, + fillet["selectors"][0]["selector_intent"]["source_entity"], + {"sketch_id": "F0", "entity_id": "E0"}, + ) + self.assertEqual( + fillet["selectors"][0]["selector_intent"]["derivation_policy"], + {"allowed": ["boundary"], "multiplicity": "one"}, ) chamfer = next(item for item in result.cdsl["features"] if item["id"] == "f_F5") @@ -101,18 +126,183 @@ class IntegrationTests(unittest.TestCase): self.assertNotIn("result_mode", next(item for item in result.cdsl["features"] if item["id"] == "f_F7")["params"]) self.assertEqual(next(item for item in result.cdsl["features"] if item["id"] == "f_F9")["params"]["result_mode"], "new_body") - from build123d import import_step with tempfile.TemporaryDirectory() as tmp: rebuilt = Path(tmp) / "rebuild.step" outcome = rebuild_candidate(result.cdsl, rebuilt) - self.assertEqual(outcome["status"], "rebuilt") - runtime_box = outcome["result"]["bbox_mm"] - imported_box = import_step(str(rebuilt)).bounding_box() - actual = [imported_box.min.X, imported_box.min.Y, imported_box.min.Z, imported_box.max.X, imported_box.max.Y, imported_box.max.Z] - expected = runtime_box["min"] + runtime_box["max"] - for value, target in zip(actual, expected): self.assertAlmostEqual(value, target, places=5) + self.assertEqual(outcome["status"], "rebuild_failed") + self.assertEqual(outcome["error"]["message"], "f_F5: selector_query_unsupported during incremental replay") + self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F4") - def test_inward_shells_00789939_lower_and_rebuild(self): + def test_swept_face_extent_continues_through_primary_cuts_00925274(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0092/00925274.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + candidate = lower_model(parse_featurescript(feature.read_text(), "00925274"), {}).cdsl + with tempfile.TemporaryDirectory() as tmp: + outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step") + + self.assertEqual(outcome["status"], "rebuilt") + result = outcome["result"] + self.assertEqual( + [item["feature_id"] for item in result["feature_results"]], + ["f_F1", "f_F3", "f_F4", "f_F6", "f_F7", "f_F9", "f_F10", "f_F12", "f_F13", "f_F15", "f_F17", "f_F18_plane", "f_F18"], + ) + f9_resolution = next( + item for item in result["selector_resolution"] if item["feature_id"] == "f_F9" + ) + self.assertEqual(f9_resolution["status"], "resolved") + self.assertEqual(f9_resolution["resolution_mode"], "kernel_lineage") + self.assertEqual(f9_resolution["evidence"]["result_records"], ["body:f_F6:face:0"]) + self.assertEqual( + [item["feature_id"] for item in f9_resolution["evidence"]["relations"]], + ["f_F1", "f_F3", "f_F6"], + ) + + def test_direct_hole_prism_swept_edge_resolves_00007264(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0000/00007264.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + 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.assertTrue(all( + selector["selector_intent"]["query_family"] == "SWEPT_EDGE" + for selector in fillet["selectors"] + )) + + with tempfile.TemporaryDirectory() as tmp: + outcome = rebuild_candidate(result.cdsl, Path(tmp) / "rebuild.step") + 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) + vertical_edges = [ + relation + for delta in outcome["result"]["topology_deltas"] + if not any(str(snapshot_id).startswith("transient:") for snapshot_id in delta["output_snapshot_ids"]) + for relation in delta["relations"] + if relation["source_kind"] == "vertex" + and relation["result_kind"] == "edge" + and relation["source_record_ids"] + ] + self.assertEqual( + len({(tuple(item["source_record_ids"]), tuple(item["result_record_ids"])) for item in vertical_edges}), + 4, + ) + self.assertTrue(all( + relation["coverage"] == "complete" and relation["lineage_status"] == "proven" + for relation in vertical_edges + )) + + def test_annular_swept_face_selector_00974931(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0097/00974931.txt" + gold = root / "step_abc/0097/00974931.step" + if not feature.exists() or not gold.exists(): self.skipTest("CADFS sample is not installed") + + candidate = lower_model(parse_featurescript(feature.read_text(), "00974931"), {}).cdsl + fillet = next(item for item in candidate["features"] if item["id"] == "f_F2") + selector = fillet["selectors"][0] + self.assertEqual(fillet["atomic_id"], "fillet") + 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"}, + ) + + with tempfile.TemporaryDirectory() as tmp: + rebuilt = Path(tmp) / "rebuild.step" + outcome = rebuild_candidate(candidate, rebuilt) + self.assertEqual(outcome["status"], "rebuild_failed") + + f2_resolution = next( + item for item in outcome["last_executable_prefix"]["result"]["selector_resolution"] + if item["feature_id"] == "f_F2" + ) + self.assertEqual(f2_resolution["status"], "resolved") + self.assertEqual(f2_resolution["resolution_mode"], "kernel_lineage") + self.assertEqual(f2_resolution["evidence"]["semantic_anchor"], { + "type": "source_entity", "sketch_id": "F0", "entity_id": "E0", + }) + self.assertEqual( + [item["feature_id"] for item in outcome["last_executable_prefix"]["result"]["feature_results"]], + ["f_F1", "f_F2"], + ) + self.assertEqual(outcome["error"]["message"], "f_F3: selector_query_unsupported during incremental replay") + + def test_direct_prism_cap_edge_lineage_00021014(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0002/00021014.txt" + gold = root / "step_abc/0002/00021014.step" + if not feature.exists() or not gold.exists(): self.skipTest("CADFS sample is not installed") + + candidate = lower_model(parse_featurescript(feature.read_text(), "00021014"), {}).cdsl + fillet = next(item for item in candidate["features"] if item["id"] == "f_F2") + cap_edges = [ + selector for selector in fillet["selectors"] + if (selector.get("selector_intent") or {}).get("query_family") == "CAP_EDGE" + ] + self.assertEqual(len(cap_edges), 9) + self.assertTrue(all("stable_id" not in selector and "geometry" not in selector for selector in cap_edges)) + self.assertEqual( + {selector["selector_intent"]["lineage_role"] for selector in cap_edges}, + {"extrude.start", "extrude.end"}, + ) + + with tempfile.TemporaryDirectory() as tmp: + rebuilt = Path(tmp) / "rebuild.step" + outcome = rebuild_candidate(candidate, rebuilt) + self.assertEqual(outcome["status"], "rebuilt") + comparison = compare_steps(gold, rebuilt) + + cap_resolutions = [ + item for item in outcome["result"]["selector_resolution"] + if item.get("feature_id") == "f_F2" + and item.get("evidence", {}).get("semantic_anchor", {}).get("type") == "source_entity" + ] + self.assertEqual(len(cap_resolutions), 10) + direct_caps = [ + item for item in cap_resolutions + if item["evidence"]["relations"][0].get("output_role") in {"extrude.start", "extrude.end"} + ] + self.assertEqual(len(direct_caps), 9) + self.assertTrue(all(item["resolution_mode"] == "kernel_lineage" for item in direct_caps)) + self.assertTrue(comparison["strict"]["passed"]) + + def test_direct_mixed_hole_cap_edge_lineage_00735367(self): + """A solver-split circular hole restores one proven source cap edge.""" + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0073/00735367.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + candidate = lower_model(parse_featurescript(feature.read_text(), "00735367"), {}).cdsl + fillet = next(item for item in candidate["features"] if item["id"] == "f_F2") + selector = fillet["selectors"][0] + self.assertEqual(selector["selector_intent"]["query_family"], "CAP_EDGE") + self.assertEqual(selector["selector_intent"]["source_entity"], {"sketch_id": "F0", "entity_id": "E1"}) + self.assertEqual(selector["selector_intent"]["lineage_role"], "extrude.end") + self.assertNotIn("geometry", selector) + self.assertNotIn("stable_id", selector) + + 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_F4: selector_query_unsupported during incremental replay") + prefix = outcome["last_executable_prefix"]["result"] + 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") + self.assertEqual(resolution["resolution_mode"], "kernel_lineage") + self.assertEqual(resolution["evidence"]["semantic_anchor"], { + "type": "source_entity", "sketch_id": "F0", "entity_id": "E1", + }) + self.assertEqual(resolution["evidence"]["relations"][0]["output_role"], "extrude.end") + self.assertTrue(resolution["evidence"]["relations"][0]["source_record_ids"]) + + def test_inward_shells_00789939_preserve_prefix_when_face_query_is_deferred(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0078/00789939.txt" if not feature.exists(): self.skipTest("CADFS sample is not installed") @@ -128,8 +318,10 @@ class IntegrationTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp: rebuilt = Path(tmp) / "rebuild.step" outcome = rebuild_candidate(result.cdsl, rebuilt) - self.assertEqual(outcome["status"], "rebuilt") - self.assertEqual(outcome["result"]["solid_count"], 2) + self.assertEqual(outcome["status"], "rebuild_failed") + self.assertEqual(outcome["error"]["message"], "f_F6: selector_query_unsupported during incremental replay") + self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F5") + self.assertEqual(outcome["last_executable_prefix"]["result"]["solid_count"], 2) def test_outward_cap_shells_lower_and_rebuild_prefixes(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -155,7 +347,10 @@ class IntegrationTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp: outcome = rebuild_candidate(prefix, Path(tmp) / "outward-shell.step") self.assertEqual(outcome["status"], "rebuilt") - self.assertEqual(outcome["result"]["solid_count"], 1) + self.assertEqual( + [item["feature_id"] for item in outcome["result"]["feature_results"]], + ["f_F1", "f_F2"], + ) def test_sweep_00542223_preserves_its_open_bspline_path(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -173,6 +368,42 @@ class IntegrationTests(unittest.TestCase): self.assertNotIn("F2", [item.get("feature_id") for item in result.diagnostics]) self.assertNotIn("F5", [item.get("feature_id") for item in result.diagnostics]) + def test_direct_sketch_wire_qbodytype_path_lowers_and_rebuilds_00896761(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0089/00896761.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + candidate = lower_model(parse_featurescript(feature.read_text(), "00896761"), {}).cdsl + sweep = next(item for item in candidate["features"] if item["id"] == "f_F2") + segment = sweep["params"]["path"]["segment"] + self.assertEqual(sweep["atomic_id"], "sweep_add") + self.assertEqual(segment["type"], "bspline") + self.assertEqual(segment["source_entity_id"], "E2") + self.assertEqual(segment["points"], [[0.0, 0.0], [87.21, 282.41]]) + self.assertEqual(segment["start_tangent"], [0.0, 936.12]) + self.assertEqual(segment["end_tangent"], [168.9, 48.72]) + + from engine.cdsl_engine.runtime import analyze_cdsl + self.assertTrue(analyze_cdsl(candidate).runtime_eligible) + with tempfile.TemporaryDirectory() as tmp: + outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step") + 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): + 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"]}) + 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") + def test_circular_pattern_00542223_preserves_all_sweep_arms(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0054/00542223.txt" @@ -193,7 +424,7 @@ class IntegrationTests(unittest.TestCase): self.assertAlmostEqual(bbox["max"][0], 52.14101625137762) self.assertAlmostEqual(bbox["max"][1], 37.5000001000001) - def test_fused_body_circular_copy_faces_bind_and_shell_00542223(self): + def test_fused_body_circular_copy_faces_preserve_shell_prefix_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") @@ -202,53 +433,20 @@ class IntegrationTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp: outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step") - self.assertEqual(outcome["status"], "rebuilt") - result = outcome["result"] - self.assertIn("f_F7", [item["feature_id"] for item in result["feature_results"]]) - shell_selectors = [ - item for item in result["selector_resolution"] - if item["feature_id"] == "f_F7" - ] - self.assertEqual( - {item["selector"]["owner_feature_id"] for item in shell_selectors}, - {"f_F5", "f_F6.c1.f_F5", "f_F6.c2.f_F5", "f_F1"}, - ) - self.assertTrue(all(item["status"] == "resolved" for item in shell_selectors)) + 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") - def test_face_chamfer_ignores_periodic_seams_00111611(self): + def test_face_chamfer_source_query_is_not_geometry_bound_00111611(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0011/00111611.txt" if not feature.exists(): self.skipTest("CADFS sample is not installed") - from copy import deepcopy - from engine.cdsl_engine.capabilities import CapabilityAnalyzer, sketch_ids_required_by_contract - from engine.cdsl_engine.runtime import EXECUTORS, ExecutionSession, _execute_node, _selector_edges - from engine.cdsl_engine.sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches from cadfs_to_cdsl.selector_binding import bind_candidate_selectors candidate = lower_model(parse_featurescript(feature.read_text(), "00111611"), {}).cdsl - bound, _ = bind_candidate_selectors(candidate) - resolved = resolve_required_sketches( - deepcopy(bound), sketch_ids_required_by_contract(bound), errors={}, - ) - analysis = CapabilityAnalyzer( - atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS, - ).analyze(resolved) - session = ExecutionSession( - sketches={str(item["id"]): item for item in resolved["geometry"]["sketches"]}, - nodes={node.feature_id: node for node in analysis.plan}, - ) - - for node in analysis.plan: - if node.feature_id == "f_F5": - edges = _selector_edges(node, session, tangent_propagation=True) - break - _execute_node(node, session) - else: - self.fail("F5 chamfer was not planned") - - self.assertEqual(len(edges), 8) - self.assertTrue(all(str(edge.geom_type).endswith("CIRCLE") for edge in edges)) + with self.assertRaisesRegex(ValueError, "f_F5: selector_query_unsupported after prefix rebuild"): + bind_candidate_selectors(candidate) def test_circular_remove_pattern_replays_cut_sources_00159804(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 69075369..84aea462 100644 --- a/cadfs_to_cdsl/tests/test_lowering.py +++ b/cadfs_to_cdsl/tests/test_lowering.py @@ -6,7 +6,12 @@ from pathlib import Path from cadfs_to_cdsl.compare import compare_steps from cadfs_to_cdsl.featurescript_parser import parse_featurescript from cadfs_to_cdsl.ir import Call -from cadfs_to_cdsl.lowering import _arc, _contours, _global, _number, _record_single_body_successor, lower_model +from cadfs_to_cdsl.lowering import ( + UnsupportedCapability, _arc, _contours, _curve_endpoint_tangent, + _direct_hole_location, _direct_line_angle_axis, _direct_sketch_wire_path, + _direct_boolean_intersection_selector, _direct_primary_cut_intersection_selector, _global, _number, + _record_single_body_successor, lower_model, +) from cadfs_to_cdsl.pipeline import compare_one, convert_one, rebuild_one from cadfs_to_cdsl.rebuild import rebuild_candidate from cadfs_to_cdsl.dataset import Sample @@ -14,7 +19,92 @@ from cadfs_to_cdsl.dataset import scan_dataset from cadfs_to_cdsl.tests.test_parser import SOURCE, TRANSFORM_SOURCE +LINE_ANGLE_TWO_ENTITY_SOURCE = '''FeatureScript 1511; +import(path : "onshape/std/geometry.fs", version : "1511.0"); +const mm = millimeter; +const VERTEX = EntityType.VERTEX; +const EDGE = EntityType.EDGE; +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(0,0)*mm,"end":v(0,10)*mm}); skLineSegment(sketch,"E2",{"start":v(0,5)*mm,"end":v(10,5)*mm}); skPoint(sketch,"P0",{"position":v(0,10)*mm}); skPoint(sketch,"P1",{"position":v(10,0)*mm}); skSolve(sketch); } + { var Q0; Q0=sQuery(id+"F0.wireOp",EDGE,"E0"); var Q1; Q1=sQuery(id+"F0.wireOp",EDGE,"E1"); cPlane(context,id+"F1",{"entities":qUnion([Q0,Q1]),"cplaneType":CPlaneType.LINE_ANGLE,"angle":0*degree}); } + { var Q0; Q0=sQuery(id+"F0.wireOp",EDGE,"E0"); var Q1; Q1=sQuery(id+"F0.wireOp",EDGE,"E2"); cPlane(context,id+"F2",{"entities":qUnion([Q0,Q1]),"cplaneType":CPlaneType.LINE_ANGLE,"angle":0*degree}); } + { var Q0; Q0=sQuery(id+"F0.wireOp",EDGE,"E0"); var Q1; Q1=sQuery(id+"F0.wireOp",VERTEX,"P0"); cPlane(context,id+"F3",{"entities":qUnion([Q0,Q1]),"cplaneType":CPlaneType.LINE_ANGLE,"angle":0*degree}); } + { var Q0; Q0=sQuery(id+"F0.wireOp",VERTEX,"P0"); var Q1; Q1=sQuery(id+"F0.wireOp",EDGE,"E0"); cPlane(context,id+"F4",{"entities":qUnion([Q0,Q1]),"cplaneType":CPlaneType.LINE_ANGLE,"angle":90*degree,"oppositeDirection":true}); } + { var Q0; Q0=sQuery(id+"F0.wireOp",EDGE,"E0"); var Q1; Q1=sQuery(id+"F0.wireOp",VERTEX,"P1"); cPlane(context,id+"F5",{"entities":qUnion([Q0,Q1]),"cplaneType":CPlaneType.LINE_ANGLE,"angle":0*degree}); } + { var Q0; Q0=sQuery(id+"F0.wireOp",EDGE,"E0"); var Q1; Q1=qCreatedBy(id+"F1.planeOp",FACE); cPlane(context,id+"F6",{"entities":qUnion([Q0,Q1]),"cplaneType":CPlaneType.LINE_ANGLE,"angle":0*degree}); } +}''' + + class LoweringTests(unittest.TestCase): + def test_direct_sketch_wire_path_accepts_only_one_nonconstruction_line_or_bspline(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", [[ + Call("qConstructionFilter", [ + Call("qBodyType", [ + Call("qCreatedBy", [Call("__binary__", ["id", "+", "F0"]), "EDGE"]), + "BodyType.WIRE", + ]), + "ConstructionObject.NO", + ]), + ]]) + sketches = {"F0": {"workplane": plane}} + entities = {"F0": {"E0": line, "axis": {"type": "line", "start": [0.0, 0.0], "end": [0.0, 5.0], "construction": True}}} + + self.assertEqual( + _direct_sketch_wire_path( + query, sketches, entities, featurescript_version="1511", + standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + ), + ("F0", "E0", line, sketches["F0"]), + ) + self.assertIsNone(_direct_sketch_wire_path( + query, sketches, entities, featurescript_version="1512", + standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + )) + self.assertIsNone(_direct_sketch_wire_path( + query, sketches, entities, featurescript_version="1511", + standard_library="onshape/std/geometry.fs", standard_library_version="1512.0", + )) + self.assertIsNone(_direct_sketch_wire_path( + query, sketches, {"F0": {"E0": line, "E1": {**line, "source_entity_id": "E1"}}}, + featurescript_version="1511", standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + )) + self.assertIsNone(_direct_sketch_wire_path( + Call("qBodyType", [Call("qCreatedBy", ["F0", "EDGE"]), "BodyType.WIRE"]), + sketches, entities, featurescript_version="1511", + standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + )) + self.assertIsNone(_direct_sketch_wire_path( + query.args[0][0], sketches, entities, featurescript_version="1511", + standard_library="onshape/std/geometry.fs", standard_library_version="1511.0", + )) + + 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]} + sketches = {"F2": {"workplane": plane}} + entities = {"F2": { + "P0": {"type": "point", "point": [1.0, 2.0]}, + "C0": {"type": "circle", "center": [3.0, 4.0], "radius_mm": 1.0}, + "L0": {"type": "line", "start": [5.0, 6.0], "end": [7.0, 8.0]}, + "A0": {"type": "arc", "start": [9.0, 10.0], "end": [11.0, 12.0]}, + }} + + def vertex(token: str) -> Call: + return Call("sQuery", ["F2.wireOp", "VERTEX", token]) + + self.assertEqual(_direct_hole_location(vertex("P0"), sketches, entities), ([1.0, 2.0, 0.0], plane)) + self.assertEqual(_direct_hole_location(vertex("C0.center"), sketches, entities), ([3.0, 4.0, 0.0], plane)) + self.assertEqual(_direct_hole_location(vertex("L0.end"), sketches, entities), ([7.0, 8.0, 0.0], plane)) + self.assertEqual(_direct_hole_location(vertex("A0.start"), sketches, entities), ([9.0, 10.0, 0.0], plane)) + 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_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) @@ -53,7 +143,7 @@ class LoweringTests(unittest.TestCase): self.assertAlmostEqual(arc["center"][1], 0.5) self.assertAlmostEqual(arc["radius_mm"], math.hypot(2.5, 0.5)) - def test_direct_extrude_and_two_section_loft_swept_edges_bind_for_fillet(self): + def test_direct_extrude_lineage_succeeds_and_unregistered_loft_query_preserves_prefix(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" cases = { "00000715": ("f_F2", 2, True), @@ -67,14 +157,37 @@ class LoweringTests(unittest.TestCase): fillet = next(item for item in result.cdsl["features"] if item["id"] == fillet_id) self.assertEqual(len(fillet["selectors"]), selector_count) self.assertTrue(all(item["owner_feature_id"] == fillet["depends_on"][0] for item in fillet["selectors"])) - self.assertTrue(all("bbox_mm" in item["geometry"] for item in fillet["selectors"])) - self.assertEqual(["curve_type" in item["geometry"] for item in fillet["selectors"]], [line_type] * selector_count) + if sample_id == "00000715": + self.assertTrue(all("geometry" not in item for item in fillet["selectors"])) + self.assertTrue(all( + item["selector_intent"]["query_family"] == "SWEPT_EDGE" + and item["selector_intent"]["derivation_policy"] == {"allowed": ["boundary"], "multiplicity": "one"} + and len(item["selector_intent"]["source_entities"]) == 2 + for item in fillet["selectors"] + )) + else: + 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( + item["selector_intent"]["query_family"] == "SWEPT_EDGE" + and item["selector_intent"]["evidence"] == "feature_script_query" + and item["selector_intent"]["derivation_policy"] == {"allowed": ["continuation"], "multiplicity": "none"} + and item["selector_intent"]["source_query"]["featurescript_version"] == "1793" + for item in fillet["selectors"] + )) with tempfile.TemporaryDirectory() as directory: rebuilt = Path(directory) / "rebuild.step" outcome = rebuild_candidate(result.cdsl, rebuilt) - self.assertEqual(outcome["status"], "rebuilt") - comparison = compare_steps(root / "step_abc" / sample_id[:4] / f"{sample_id}.step", rebuilt) - self.assertTrue(comparison["strict"]["passed"]) + if sample_id == "00005267": + self.assertEqual(outcome["status"], "rebuild_failed") + self.assertEqual(outcome["error"]["message"], "f_F4: selector_query_unsupported during incremental replay") + self.assertTrue(rebuilt.exists()) + self.assertEqual(outcome["last_executable_prefix"]["failed_feature_id"], "f_F4") + self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F3") + else: + self.assertEqual(outcome["status"], "rebuilt") + comparison = compare_steps(root / "step_abc" / sample_id[:4] / f"{sample_id}.step", rebuilt) + self.assertTrue(comparison["strict"]["passed"]) def test_swept_edge_requires_one_direct_shared_source_endpoint(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -93,7 +206,7 @@ class LoweringTests(unittest.TestCase): "message": "swept edge source endpoint provenance is unsupported", }]) - def test_direct_full_revolve_swept_circle_edges_bind_for_dressups(self): + def test_full_revolve_swept_circle_edges_remain_deferred_with_prefixes(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" for sample_id, dressup_id, selector_count in ( ("00048326", "f_F3", 2), @@ -109,14 +222,17 @@ class LoweringTests(unittest.TestCase): selector["geometry"].get("curve_type") == "circle" and len(selector["geometry"].get("circle_center_mm") or []) == 3 and selector["geometry"].get("radius_mm", 0) > 0 + 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"] )) with tempfile.TemporaryDirectory() as directory: rebuilt = Path(directory) / "rebuild.step" outcome = rebuild_candidate(result.cdsl, rebuilt) - self.assertEqual(outcome["status"], "rebuilt") - comparison = compare_steps(root / "step_abc" / sample_id[:4] / f"{sample_id}.step", rebuilt) - self.assertTrue(comparison["strict"]["passed"]) + 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) def test_full_revolve_swept_circle_requires_one_direct_shared_source_endpoint(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -135,7 +251,7 @@ class LoweringTests(unittest.TestCase): "message": "swept edge source endpoint provenance is unsupported", }, result.diagnostics) - def test_equivalent_imprint_profile_retains_direct_full_revolve_swept_edges(self): + 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" result = lower_model(parse_featurescript(source.read_text(), "00112257"), {}) @@ -144,17 +260,16 @@ class LoweringTests(unittest.TestCase): self.assertEqual(len(fillet["selectors"]), 5) self.assertTrue(all( selector["geometry"].get("curve_type") == "circle" + and selector["selector_intent"]["query_family"] == "SWEPT_EDGE" + and selector["selector_intent"]["derivation_policy"]["multiplicity"] == "none" for selector in fillet["selectors"] )) from cadfs_to_cdsl.selector_binding import bind_candidate_selectors prefix = dict(result.cdsl) prefix["features"] = result.cdsl["features"][:2] - bound, evidence = bind_candidate_selectors(prefix) - selectors = next(item for item in bound["features"] if item["id"] == "f_F2")["selectors"] - self.assertEqual(len(evidence), 1) - self.assertEqual(len(selectors), 5) - self.assertTrue(all(selector.get("snapshot_id") for selector in selectors)) + with self.assertRaisesRegex(ValueError, "f_F2: selector_query_unsupported after prefix rebuild"): + bind_candidate_selectors(prefix) def test_changed_imprint_profile_does_not_inherit_full_revolve_source_edges(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -689,6 +804,30 @@ class LoweringTests(unittest.TestCase): self.assertEqual(end["offset_mm"], 3.0) self.assertEqual(end["reference"]["kind"], "body") self.assertEqual(end["reference"]["owner_feature_id"], "f_F1") + self.assertEqual(end["reference"]["source"], "runtime_snapshot") + self.assertNotIn("stable_id", end["reference"]) + self.assertEqual(end["reference"]["selector_intent"]["query_family"], "SWEPT_BODY") + self.assertEqual(end["reference"]["selector_intent"]["evidence"], "active_body_member") + self.assertEqual( + end["reference"]["selector_intent"]["derivation_policy"], + {"allowed": ["boundary"], "multiplicity": "one"}, + ) + fillet = next(item for item in result.cdsl["features"] if item["id"] == "f_F4") + self.assertEqual(len(fillet["selectors"]), 1) + self.assertEqual(fillet["selectors"][0]["selector_intent"]["query_family"], "CAP_EDGE") + self.assertEqual( + fillet["selectors"][0]["selector_intent"]["derivation_policy"], + {"allowed": ["boundary", "continuation"], "multiplicity": "one"}, + ) + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + + mixed_evidence = deepcopy(result.cdsl) + mixed_reference = next( + item for item in mixed_evidence["features"] if item["id"] == "f_F3" + )["params"]["end_condition"]["reference"] + mixed_reference["stable_id"] = "body:f_F1" + with self.assertRaisesRegex(ValueError, "invalid SWEPT_BODY member contract"): + validate_semantic_cdsl(mixed_evidence) def test_up_to_surface_extrude_captures_the_cap_face(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -700,9 +839,24 @@ class LoweringTests(unittest.TestCase): self.assertEqual(end["type"], "up_to_surface") self.assertEqual(end["reference"]["kind"], "face") self.assertEqual(end["reference"]["owner_feature_id"], "f_F1") - self.assertEqual(end["reference"]["geometry"]["plane_offset_mm"], 0.0) + self.assertEqual(end["reference"]["output_role"], "extrude.start") + self.assertEqual(end["reference"]["source"], "runtime_snapshot") + self.assertNotIn("geometry", end["reference"]) + self.assertEqual(end["reference"]["selector_intent"]["query_family"], "CAP_FACE") + self.assertEqual(end["reference"]["selector_intent"]["evidence"], "operation_role") swept = next(item for item in result.cdsl["features"] if item["id"] == "f_F6") - self.assertEqual(swept["params"]["end_condition"]["reference"]["geometry"]["radius_mm"], 24.3) + swept_reference = swept["params"]["end_condition"]["reference"] + self.assertEqual(swept_reference["selector_intent"]["query_family"], "SWEPT_FACE") + self.assertEqual( + swept_reference["selector_intent"]["derivation_policy"], + {"allowed": ["boundary", "continuation"], "multiplicity": "one"}, + ) + self.assertEqual(swept_reference["selector_intent"]["evidence"], "kernel_history") + self.assertEqual(swept_reference["selector_intent"]["source_entity"], {"sketch_id": "F0", "entity_id": "E0"}) + self.assertEqual(swept_reference["source"], "runtime_snapshot") + self.assertNotIn("stable_id", swept_reference) + self.assertNotIn("binding_feature_id", swept_reference) + self.assertNotIn("geometry", swept_reference) mirror = next(item for item in result.cdsl["features"] if item["id"] == "f_F18") self.assertTrue(mirror["params"]["mirror_current_body"]) @@ -756,6 +910,160 @@ class LoweringTests(unittest.TestCase): self.assertAlmostEqual(plane["origin_mm"][0], -12.15) self.assertAlmostEqual(plane["origin_mm"][1], -24.3 * math.sqrt(3) / 2) + def test_line_angle_direct_circle_uses_the_featurescript_circle_axis(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0003/00030209.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00030209"), {}) + self.assertEqual(result.status, "converted_partial") + planes = { + item["id"]: item["params"]["plane"] + for item in result.cdsl["features"] + if item["atomic_id"] == "reference_plane" + } + expected_centers = { + "f_F1": [45.78, 0.0, 26.2], + "f_F2": [13.9, 0.0, 26.5], + "f_F4": [-17.08, 0.0, 23.46], + "f_F5": [-44.4, 0.0, 19.8], + } + self.assertEqual(set(planes), set(expected_centers)) + for feature_id, center in expected_centers.items(): + with self.subTest(feature_id=feature_id): + plane = planes[feature_id] + self.assertEqual(plane["origin_mm"], center) + self.assertEqual(plane["x_dir"], [0.0, -1.0, 0.0]) + self.assertAlmostEqual(plane["normal"][0], 1.0) + self.assertAlmostEqual(plane["normal"][1], 0.0) + self.assertAlmostEqual(plane["normal"][2], 0.0) + self.assertFalse(any( + diagnostic.get("operation") == "cPlane" + for diagnostic in result.diagnostics + )) + + opposite_source = feature.read_text().replace( + '"angle" : 90 * degree, "width"', + '"angle" : 90 * degree, "oppositeDirection" : true, "width"', + 1, + ) + opposite = lower_model(parse_featurescript(opposite_source, "line-angle-circle-opposite"), {}) + opposite_plane = next( + item for item in opposite.cdsl["features"] if item["id"] == "f_F1" + )["params"]["plane"] + self.assertAlmostEqual(opposite_plane["normal"][0], -1.0) + self.assertAlmostEqual(opposite_plane["normal"][1], 0.0) + self.assertAlmostEqual(opposite_plane["normal"][2], 0.0) + + def test_line_angle_two_entities_follow_axis_plane_order_and_swap_rules(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + cases = { + # F8 selects Right plane first, then the direct F7 line. The + # standard library must swap them before calculating the frame. + "00298026": ("f_F8", [0.0, -1.0, 0.0], [0.43837114678907757, 0.0, -0.898794046299167]), + # F1 supplies the direct line first and the default plane second. + "00586156": ("f_F1", [0.0, 0.0, 1.0], [-math.sqrt(0.5), -math.sqrt(0.5), 0.0]), + } + for sample_id, (feature_id, expected_x, expected_normal) 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), {}) + plane = next(item for item in result.cdsl["features"] if item["id"] == feature_id)["params"]["plane"] + self.assertEqual(plane["x_dir"], expected_x) + for actual, expected in zip(plane["normal"], expected_normal): + self.assertAlmostEqual(actual, expected) + self.assertFalse(any( + diagnostic.get("operation") == "cPlane" + and diagnostic.get("feature_id") == feature_id.removeprefix("f_") + for diagnostic in result.diagnostics + )) + + 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") + planes = { + item["id"]: item["params"]["plane"] + for item in result.cdsl["features"] + if item["atomic_id"] == "reference_plane" + } + # Nonparallel axes, parallel offset axes, and an axis plus a direct + # point each use the exact source direction, rather than a sketch + # workplane frame. + for feature_id in ("f_F1", "f_F2", "f_F3"): + self.assertEqual(planes[feature_id]["x_dir"], [1.0, 0.0, 0.0]) + self.assertEqual(planes[feature_id]["normal"], [0.0, 0.0, 1.0]) + # A point selected before its axis triggers FeatureScript's swap. The + # signed angle also follows oppositeDirection. + self.assertEqual(planes["f_F4"]["x_dir"], [1.0, 0.0, 0.0]) + self.assertAlmostEqual(planes["f_F4"]["normal"][0], 0.0) + self.assertAlmostEqual(planes["f_F4"]["normal"][1], 1.0) + self.assertAlmostEqual(planes["f_F4"]["normal"][2], 0.0) + # A cPlane output is represented as qCreatedBy(...planeOp, FACE), + # not a sketch source. It is accepted only because F1 is an existing + # reference-plane frame, and it supplies the true zero-angle side. + self.assertEqual(planes["f_F6"]["x_dir"], [1.0, 0.0, 0.0]) + self.assertEqual(planes["f_F6"]["normal"], [0.0, 0.0, -1.0]) + self.assertIn({ + "code": "feature_deferred", + "feature_id": "F5", + "operation": "cPlane", + "message": "line-angle reference selection is degenerate", + }, result.diagnostics) + + def test_line_angle_direct_axis_refuses_a_topology_derived_query(self): + direct = Call("sQuery", ["F0.wireOp", "EDGE", "E0"], 1) + derived = Call("makeQuery", [ + "F1.opExtrude", "CAP_EDGE", "EDGE", + {"derivedFrom": direct, "isStart": False}, + ], 2) + sketch_by_source = {"F0": {"workplane": { + "origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "normal": [0.0, 0.0, 1.0], + }}} + entity_by_sketch = {"F0": {"E0": { + "type": "line", "start": [0.0, 0.0], "end": [10.0, 0.0], "source_entity_id": "E0", + }}} + 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_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) + sketch_by_source = {"F0": {"workplane": { + "origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "normal": [0.0, 0.0, 1.0], + }}} + entity_by_sketch = {"F0": {"E0": { + "type": "line", "start": [0.0, 0.0], "end": [10.0, 0.0], "source_entity_id": "E0", + }}} + self.assertIsNone(_direct_line_angle_axis(wrapped, sketch_by_source, entity_by_sketch)) + + model = parse_featurescript(LINE_ANGLE_TWO_ENTITY_SOURCE, "line-angle-wrapped-axis") + plane = next(item for item in model.features if item.feature_id == "F1") + plane.params["entities"].args[0][0] = wrapped + result = lower_model(model, {}) + self.assertIn({ + "code": "feature_deferred", + "feature_id": "F1", + "operation": "cPlane", + "message": "line-angle reference selection is unsupported", + }, result.diagnostics) + self.assertIn("f_F2", {item["id"] for item in result.cdsl["features"]}) + + def test_line_angle_rejects_a_circle_center_vertex_as_an_axis(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0003/00030209.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + source = feature.read_text().replace( + 'Q0=sQuery(id+"F0.wireOp",EDGE,"E0");', + 'Q0=sQuery(id+"F0.wireOp",VERTEX,"E0.center");', + 1, + ) + result = lower_model(parse_featurescript(source, "line-angle-circle-center"), {}) + self.assertIn({ + "code": "feature_deferred", + "feature_id": "F1", + "operation": "cPlane", + "message": "line-angle reference selection is unsupported", + }, result.diagnostics) + def test_open_nonconstruction_geometry_is_preserved_as_reference(self): source = SOURCE.replace('skSolve(sketch);', 'skLineSegment(sketch, "open", {"start":v(0, 0) * mm, "end":v(20, 0) * mm}); skSolve(sketch);', 1) result = lower_model(parse_featurescript(source, "open-profile"), {}) @@ -931,7 +1239,7 @@ class LoweringTests(unittest.TestCase): [("outer", 25.5), ("inner", 19.0)], ) - def test_mixed_extrude_rebuilds_surface_limited_chamfer_history(self): + def test_mixed_extrude_preserves_prefix_when_cap_edge_chamfer_query_is_deferred(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0071/00710855.txt" if not feature.exists(): self.skipTest("CADFS sample is not installed") @@ -941,10 +1249,10 @@ class LoweringTests(unittest.TestCase): from cadfs_to_cdsl.rebuild import rebuild_candidate rebuilt = rebuild_candidate(result.cdsl, Path(tmp) / "rebuilt.step") - self.assertEqual(rebuilt["status"], "rebuilt") - chamfer = next(item for item in rebuilt["result"]["feature_results"] if item["feature_id"] == "f_F10") - self.assertIn("chamfer_surface_limited", [item["code"] for item in chamfer["diagnostics"]]) - self.assertAlmostEqual(rebuilt["result"]["volume_mm3"], 72577.33528261917, places=6) + self.assertEqual(rebuilt["status"], "rebuild_failed") + self.assertEqual(rebuilt["error"]["message"], "f_F10: selector_query_unsupported during incremental replay") + self.assertEqual(rebuilt["last_executable_prefix"]["failed_feature_id"], "f_F10") + self.assertEqual(rebuilt["last_executable_prefix"]["last_feature_id"], "f_F9") def test_fit_spline_loft_lowers_to_executable_loft_add(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -1048,7 +1356,7 @@ class LoweringTests(unittest.TestCase): "kind": "face", "owner_feature_id": "f_F3", "output_role": "extrude.start", "source": "runtime_snapshot", "confidence": 1.0, }) - self.assertEqual(selector["selector_intent_version"], "1.0") + self.assertNotIn("selector_intent_version", selector) self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE") from engine.cdsl_engine.runtime import rebuild_cdsl @@ -1130,21 +1438,19 @@ class LoweringTests(unittest.TestCase): }) # F9 rotates F4 only after F5/F7 have consumed it. It cannot be baked - # into the earlier F4 sketch; the unsupported body continuation stays - # explicit and F1--F7 remains an executable prefix. + # into the earlier F4 sketch. The CAP_EDGE geometry is preserved for + # diagnostics, but no source-qualified cap-edge lineage exists for + # this two-sided profile contract. prefix = deepcopy(result.cdsl) prefix["features"] = [ item for item in prefix["features"] if item["id"] in {"f_F1", "f_F2", "f_F4", "f_F5", "f_F7"} ] - from engine.cdsl_engine.runtime import rebuild_cdsl with tempfile.TemporaryDirectory() as directory: - rebuilt = rebuild_cdsl(prefix, Path(directory) / "two-sided-cap-prefix.step") - self.assertFalse(rebuilt["runtime_diagnostics"]) - self.assertEqual( - [item["feature_id"] for item in rebuilt["feature_results"]], - ["f_F1", "f_F2", "f_F4", "f_F5", "f_F7"], - ) + rebuilt = rebuild_candidate(prefix, Path(directory) / "two-sided-cap-prefix.step") + self.assertEqual(rebuilt["status"], "rebuild_failed") + self.assertEqual(rebuilt["error"]["message"], "f_F7: selector_query_unsupported during incremental replay") + self.assertEqual(rebuilt["last_executable_prefix"]["last_feature_id"], "f_F5") from engine.cdsl_engine.runtime import analyze_cdsl f9 = next(item for item in analyze_cdsl(result.cdsl).as_dict()["feature_results"] if item["feature_id"] == "f_F9") @@ -1338,6 +1644,41 @@ class LoweringTests(unittest.TestCase): ["E14", "E14"], ) + def test_direct_imprint_profile_preserves_selector_intent_and_f1_checkpoint(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") + + result = lower_model(parse_featurescript(feature.read_text(), "00354246"), {}) + 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"]} + profile = sketches[features["f_F1"]["sketch_id"]]["profile"] + self.assertEqual(profile["type"], "planar_imprint") + self.assertEqual(profile["selections"], [ + {"source_entity_id": "E6.MirrorCS", "face_side": -1.0}, + {"source_entity_id": "E0.MirrorCS", "face_side": 1.0}, + ]) + + selectors = features["f_F2"]["selectors"] + self.assertEqual(len(selectors), 7) + self.assertTrue(all( + selector["selector_intent"]["query_family"] == "SWEPT_FACE" + and selector["selector_intent"]["derivation_policy"] == { + "allowed": ["continuation"], "multiplicity": "none", + } + for selector in selectors + )) + 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.assertTrue(rebuilt.exists()) + self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F1") + def test_direct_translation_transform_updates_the_source_revolve(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0011/00111611.txt" @@ -1512,6 +1853,121 @@ class LoweringTests(unittest.TestCase): diagnostics = [item for item in result.diagnostics if item.get("operation") == "cPlane"] self.assertFalse(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" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(), "00827798"), {}) + plane = next(item for item in result.cdsl["features"] if item["id"] == "f_F1") + self.assertEqual(plane["atomic_id"], "reference_plane") + self.assertFalse(any( + item.get("operation") == "cPlane" and item.get("feature_id") == "F1" + for item in result.diagnostics + )) + expected = [107.86, 0.0, -176.04] + magnitude = math.sqrt(sum(value * value for value in expected)) + for actual, value in zip(plane["params"]["plane"]["normal"], expected): + self.assertAlmostEqual(actual, value / magnitude) + + second_feature = root / "featurescript_rp/0084/00845891.txt" + if second_feature.exists(): + second = lower_model(parse_featurescript(second_feature.read_text(), "00845891"), {}) + self.assertEqual( + next(item for item in second.cdsl["features"] if item["id"] == "f_F3")["atomic_id"], + "reference_plane", + ) + self.assertFalse(any( + item.get("operation") == "cPlane" and item.get("feature_id") == "F3" + for item in second.diagnostics + )) + + without_end_derivative = feature.read_text().replace( + ', "endDerivative": vector(107.86, -176.04) * mm', "", 1, + ) + rejected = lower_model(parse_featurescript(without_end_derivative, "00827798-no-end-derivative"), {}) + self.assertIn({ + "code": "unsupported_engine_capability", + "capability": "reference_plane:curve_point", + "feature_id": "F1", + "operation": "cPlane", + "message": "CURVE_POINT B-spline endpoint has no exported tangent", + }, rejected.diagnostics) + + def test_curve_endpoint_tangent_uses_arc_orientation_without_a_chord_fallback(self): + arc = { + "type": "arc", "start": [1.0, 0.0], "end": [0.0, 1.0], + "center": [0.0, 0.0], "clockwise": False, + } + self.assertEqual(_curve_endpoint_tangent(arc, "start"), [0.0, 1.0]) + self.assertEqual(_curve_endpoint_tangent(arc, "end"), [-1.0, 0.0]) + with self.assertRaisesRegex(UnsupportedCapability, "direct source-curve endpoint"): + _curve_endpoint_tangent(arc, "internal") + + def test_curve_point_plane_uses_exact_bspline_interpolation_vertex_tangents(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + cases = { + "00456498": ("f_F1", "F1", [141.23, 0.0, -13.99]), + "00812557": ("f_F2", "F2", [0.0, 191.15, -139.74]), + } + for sample_id, (cdsl_feature_id, source_feature_id, expected) in cases.items(): + with self.subTest(sample_id=sample_id): + source = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt" + if not source.exists(): + self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(source.read_text(), sample_id), {}) + plane = next(item for item in result.cdsl["features"] if item["id"] == cdsl_feature_id) + self.assertEqual(plane["atomic_id"], "reference_plane") + magnitude = math.sqrt(sum(value * value for value in expected)) + for actual, value in zip(plane["params"]["plane"]["normal"], expected): + self.assertAlmostEqual(actual, value / magnitude, places=8) + self.assertFalse(any( + item.get("operation") == "cPlane" and item.get("feature_id") == source_feature_id + for item in result.diagnostics + )) + + no_derivatives = (root / "featurescript_rp/0045/00456498.txt").read_text().replace( + ', "startDerivative": vector(0, 64.52) * mm, "endDerivative": vector(141.23, -13.99) * mm', "", 1, + ) + rejected = lower_model(parse_featurescript(no_derivatives, "00456498-no-derivatives"), {}) + self.assertIn({ + "code": "unsupported_engine_capability", + "capability": "reference_plane:curve_point", + "feature_id": "F1", + "operation": "cPlane", + "message": "CURVE_POINT B-spline interpolation point has no complete source interpolation data", + }, rejected.diagnostics) + + derived = (root / "featurescript_rp/0045/00456498.txt").read_text().replace( + '"E0.2.internal"', '"E0.2.endSnap0"', 1, + ) + rejected_derived = lower_model(parse_featurescript(derived, "00456498-derived-vertex"), {}) + self.assertIn({ + "code": "unsupported_engine_capability", + "capability": "reference_plane:curve_point", + "feature_id": "F1", + "operation": "cPlane", + "message": "CURVE_POINT point must name a direct source-curve endpoint or B-spline interpolation vertex", + }, rejected_derived.diagnostics) + + def test_curve_point_plane_uses_a_direct_arc_endpoint_tangent(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0005/00057273.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + source = feature.read_text().replace( + 'EDGE,"E1"', 'EDGE,"E0"', 1, + ).replace( + 'VERTEX,"E1.end"', 'VERTEX,"E0.end"', 1, + ) + result = lower_model(parse_featurescript(source, "00057273-arc-curve-point"), {}) + plane = next(item for item in result.cdsl["features"] if item["id"] == "f_F1") + self.assertEqual(plane["atomic_id"], "reference_plane") + self.assertEqual(plane["params"]["plane"]["origin_mm"], [-24.6, 0.0, 0.0]) + self.assertEqual(plane["params"]["plane"]["normal"], [0.0, -1.0, 0.0]) + self.assertFalse(any( + item.get("operation") == "cPlane" and item.get("feature_id") == "F1" + for item in result.diagnostics + )) + def test_offset_reference_plane_honors_opposite_direction_without_reversing_its_frame(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0077/00777619.txt" @@ -1571,7 +2027,7 @@ class LoweringTests(unittest.TestCase): )) self.assertNotIn("F8", {item.get("feature_id") for item in result.diagnostics}) - def test_shell_lowers_and_executes_direct_linear_extrude_swept_faces(self): + def test_shell_resolves_direct_prism_swept_faces_through_kernel_lineage(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0059/00594348.txt" if not feature.exists(): self.skipTest("CADFS sample is not installed") @@ -1582,18 +2038,105 @@ class LoweringTests(unittest.TestCase): shell = result.cdsl["features"][shell_index] self.assertEqual(shell["atomic_id"], "shell") self.assertEqual([item["owner_feature_id"] for item in shell["selectors"]], ["f_F1", "f_F1"]) - self.assertTrue(all("normal" in item["geometry"] for item in shell["selectors"])) - - prefix = deepcopy(result.cdsl) - prefix["features"] = prefix["features"][:shell_index + 1] + self.assertTrue(all("geometry" not in item and "stable_id" not in item for item in shell["selectors"])) + self.assertTrue(all(item["selector_intent"]["query_family"] == "SWEPT_FACE" for item in shell["selectors"])) + self.assertTrue(all(item["selector_intent"]["evidence"] == "kernel_history" for item in shell["selectors"])) + self.assertTrue(all( + item["selector_intent"]["derivation_policy"] == {"allowed": ["boundary"], "multiplicity": "one"} + for item in shell["selectors"] + )) with tempfile.TemporaryDirectory() as directory: - outcome = rebuild_candidate(prefix, Path(directory) / "swept-face-shell.step") + outcome = rebuild_candidate(result.cdsl, Path(directory) / "swept-face-shell.step") self.assertEqual(outcome["status"], "rebuilt") - self.assertEqual( - [item["status"] for item in outcome["result"]["feature_results"]], - ["executed", "executed"], + resolutions = [ + item for item in outcome["result"]["selector_resolution"] + if item["feature_id"] == "f_F2" + ] + self.assertEqual(len(resolutions), 2) + self.assertTrue(all(item["resolution_mode"] == "kernel_lineage" for item in resolutions)) + self.assertTrue(all( + item["evidence"]["relations"][0]["source_kind"] == "edge" + and item["evidence"]["relations"][0]["result_kind"] == "face" + for item in resolutions + )) + + 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")) + for sample_id, output_role in cases: + with self.subTest(sample_id=sample_id): + feature = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + result = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), sample_id), {}) + self.assertEqual(result.status, "converted_complete") + shell_index = next(index for index, item in enumerate(result.cdsl["features"]) if item["id"] == "f_F2") + shell = result.cdsl["features"][shell_index] + self.assertEqual(shell["atomic_id"], "shell") + self.assertEqual(len(shell["selectors"]), 1) + selector = shell["selectors"][0] + self.assertEqual(selector["owner_feature_id"], "f_F1") + self.assertEqual(selector["output_role"], output_role) + self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE") + self.assertEqual(selector["selector_intent"]["evidence"], "operation_role") + self.assertNotIn("stable_id", selector) + self.assertNotIn("binding_feature_id", selector) + self.assertNotIn("geometry", selector) + + prefix = deepcopy(result.cdsl) + prefix["features"] = prefix["features"][:shell_index + 1] + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(prefix, Path(directory) / f"{sample_id}-cap-face-shell.step") + self.assertEqual(outcome["status"], "rebuilt") + resolution = next( + item for item in outcome["result"]["selector_resolution"] + if item["feature_id"] == "f_F2" + ) + self.assertEqual(resolution["status"], "resolved") + self.assertEqual(resolution["resolution_mode"], "operation_role") + self.assertEqual(resolution["selected"]["output_roles"], [output_role]) + + def test_shell_direct_prism_swept_face_rejects_unregistered_source_version(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0059/00594348.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + source = feature.read_text(encoding="utf-8").replace("FeatureScript 1511;", "FeatureScript 1793;", 1) + result = lower_model(parse_featurescript(source, "00594348-version-1793"), {}) + shell = next(item for item in result.cdsl["features"] if item["id"] == "f_F2") + self.assertTrue(all( + item["selector_intent"]["evidence"] == "feature_script_query" + and item["selector_intent"]["derivation_policy"]["multiplicity"] == "none" + for item in shell["selectors"] + )) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(result.cdsl, Path(directory) / "swept-face-shell-1793.step") + self.assertEqual(outcome["status"], "rebuild_failed") + self.assertEqual(outcome["error"]["message"], "f_F2: selector_query_unsupported during incremental replay") + self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F1") + + def test_shell_direct_prism_swept_face_rejects_unregistered_standard_library_revision(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0059/00594348.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + source = feature.read_text(encoding="utf-8").replace( + 'import(path : "onshape/std/geometry.fs", version : "1511.0");', + 'import(path : "onshape/std/geometry.fs", version : "1512.0");', + 1, ) - self.assertFalse(outcome["result"].get("diagnostics", [])) + result = lower_model(parse_featurescript(source, "00594348-library-1512"), {}) + shell = next(item for item in result.cdsl["features"] if item["id"] == "f_F2") + self.assertTrue(all( + item["selector_intent"]["source_query"]["featurescript_version"] == "1511" + and item["selector_intent"]["source_query"]["standard_library"] == "onshape/std/geometry.fs" + and item["selector_intent"]["source_query"]["standard_library_version"] == "1512.0" + for item in shell["selectors"] + )) + with tempfile.TemporaryDirectory() as directory: + outcome = rebuild_candidate(result.cdsl, Path(directory) / "swept-face-shell-library-1512.step") + self.assertEqual(outcome["status"], "rebuild_failed") + self.assertEqual(outcome["error"]["message"], "f_F2: selector_query_unsupported during incremental replay") + self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F1") def test_shell_offset_face_requires_true_dependency_cap_evidence(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -1623,13 +2166,14 @@ class LoweringTests(unittest.TestCase): outcome = rebuild_candidate(result.cdsl, rebuilt) self.assertTrue(rebuilt.exists()) self.assertEqual(outcome["status"], "rebuild_failed") - self.assertEqual(outcome["error"]["type"], "RuntimeExecutionError") + 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) - def test_pattern_copy_selector_binding_rebuilds_the_full_history(self): + def test_pattern_copy_intersection_query_preserves_its_prefix_without_instance_geometry_binding(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0042/00423838.txt" if not feature.exists(): self.skipTest("CADFS sample is not installed") @@ -1639,14 +2183,15 @@ class LoweringTests(unittest.TestCase): rebuilt = Path(directory) / "selector-prefix.step" outcome = rebuild_candidate(result.cdsl, rebuilt) self.assertTrue(rebuilt.exists()) - self.assertEqual(outcome["status"], "rebuilt") - resolved = [ - item for item in outcome["result"]["selector_resolution"] - if item["feature_id"] == "f_F7" - and item["selector"].get("owner_feature_id") == "f_F4.c4.f_F1" - ] - self.assertEqual(len(resolved), 2) - self.assertTrue(all(item["status"] == "resolved" for item in resolved)) + 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") + 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( + [item["selector_intent"]["query_family"] for item in reference["intersection_of"][:2]], + ["COPY", "COPY"], + ) def test_shell_rejects_swept_faces_without_direct_linear_extrude_ownership(self): root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" @@ -1698,6 +2243,10 @@ class LoweringTests(unittest.TestCase): def test_cap_face_selector_retains_versioned_provenance_intent(self): source = SOURCE.replace( + "FeatureScript 1511;", + 'FeatureScript 1511;\nimport(path : "onshape/std/geometry.fs", version : "1511.0");', + 1, + ).replace( '\n});\n', '\n extrude(context, id + "F2", {"entities":makeQuery(id + "F1.opExtrude", "CAP_FACE", FACE, {"isStart":false}), "depth":10 * mm});\n});\n', ) @@ -1705,12 +2254,127 @@ class LoweringTests(unittest.TestCase): self.assertEqual(result.status, "converted_complete") selector = next(item for item in result.cdsl["features"] if item["id"] == "f_F2")["selectors"][0] self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE") - self.assertEqual(selector["selector_intent_version"], "1.0") + self.assertNotIn("selector_intent_version", selector) 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"]["evidence"], "operation_role") from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + def test_direct_boolean_intersection_lowering_keeps_two_source_contract(self): + base = lower_model(parse_featurescript(SOURCE, "intersection-intent"), {}).cdsl + sketch = base["geometry"]["sketches"][0] + direct = {**base["features"][0], "id": "f_F2", "name": "F2"} + feature_by_id = { + "f_F1": base["features"][0], + "f_F2": direct, + "f_F3": { + "id": "f_F3", "atomic_id": "boolean_bodies", + "params": { + "operation": "subtract", "target_feature_ids": ["f_F1"], + "tool_feature_ids": ["f_F2"], "keep_tools": False, + }, + }, + } + cap = Call("makeQuery", ["F1.opExtrude", "CAP_FACE", "EntityType.FACE", {"isStart": False}]) + source_edge = Call("sQuery", ["F0.wireOp", "EntityType.EDGE", "E0"]) + swept = Call("makeQuery", [ + "F2.opExtrude", "SWEPT_FACE", "EntityType.FACE", + {"disambiguationData": [Call("OSD", [[source_edge]])]}, + ]) + query = Call("makeQuery", [ + "F3.boolean.opBoolean", "INTERSECT", "EntityType.EDGE", {"derivedFrom": [cap, swept]}, + ]) + selector = _direct_boolean_intersection_selector( + query, + feature_by_id=feature_by_id, + feature_frames={"F2": {"profile_source": "F0"}}, + sketch_by_source={"F0": sketch}, + sketches_by_id={sketch["id"]: sketch}, + entity_by_sketch={"F0": {"E0": {"type": "circle", "center": [0, 0], "radius_mm": 9.53}}}, + featurescript_version="1511", + ) + + self.assertIsNotNone(selector) + self.assertNotIn("stable_id", selector) + intent = selector["selector_intent"] + self.assertEqual(intent["derivation_policy"], {"allowed": ["intersection"], "multiplicity": "one"}) + self.assertEqual(intent["intersection_sources"], [ + {"query_family": "CAP_FACE", "owner_feature_id": "f_F1", "output_role": "extrude.end"}, + {"query_family": "SWEPT_FACE", "owner_feature_id": "f_F2", "source_entity": {"sketch_id": "F0", "entity_id": "E0"}}, + ]) + + def test_direct_primary_cut_intersection_requires_no_unverified_disambiguation(self): + base = lower_model(parse_featurescript(SOURCE, "primary-intersection-intent"), {}).cdsl + target = base["features"][0] + target_sketch = base["geometry"]["sketches"][0] + tool_sketch = deepcopy(target_sketch) + tool_sketch.update({"id": "sketch_F2", "source_sketch_id": "F2"}) + if tool_sketch["profile"].get("type") == "circle": + tool_sketch["profile"]["source_entity_id"] = "E1" + else: + for contour in tool_sketch["profile"]["contours"]: + for segment in contour["segments"]: + segment["source_entity_id"] = "E1" + tool = { + "id": "f_F3", "atomic_id": "extrude_cut_blind", "sketch_id": "sketch_F2", + "params": {"distance_mm": 10, "end_condition": {"type": "blind"}}, + } + cap = Call("makeQuery", ["F1.opExtrude", "CAP_FACE", "EntityType.FACE", {"isStart": False}]) + tool_edge = Call("sQuery", ["F2.wireOp", "EntityType.EDGE", "E1"]) + swept = Call("makeQuery", [ + "F3.opExtrude", "SWEPT_FACE", "EntityType.FACE", + {"disambiguationData": [Call("OSD", [[tool_edge]])]}, + ]) + query = Call("makeQuery", [ + "F3.boolean.opBoolean", "INTERSECT", "EntityType.EDGE", {"derivedFrom": [cap, swept]}, + ]) + kwargs = { + "feature_by_id": {"f_F1": target, "f_F3": tool}, + "feature_frames": {"F3": {"profile_source": "F2"}}, + "sketch_by_source": {"F2": tool_sketch}, + "sketches_by_id": {tool_sketch["id"]: tool_sketch, target_sketch["id"]: target_sketch}, + "entity_by_sketch": {"F2": {"E1": {"type": "circle", "center": [0, 0], "radius_mm": 9.53}}}, + "previous": ["f_F1", "f_F3"], + "featurescript_version": "1511", + } + selector = _direct_primary_cut_intersection_selector(query, **kwargs) + + self.assertIsNotNone(selector) + self.assertEqual(selector["owner_feature_id"], "f_F3") + self.assertEqual(selector["selector_intent"]["intersection_sources"][1], { + "query_family": "SWEPT_FACE", "owner_feature_id": "f_F3", + "source_entity": {"sketch_id": "F2", "entity_id": "E1"}, + }) + 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_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" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + + result = lower_model(parse_featurescript(feature.read_text(), "00020311"), {}) + f4 = next(item for item in result.cdsl["features"] if item["id"] == "f_F4") + self.assertTrue(all( + selector["kind"] == "edge" + and selector["selector_intent"]["query_family"] == "INTERSECT" + and selector["selector_intent"]["derivation_policy"]["multiplicity"] == "none" + for selector in f4["selectors"] + )) + 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) / "00020311.step") + self.assertTrue((Path(directory) / "00020311.step").exists()) + self.assertEqual(outcome["status"], "rebuild_failed") + 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_conversion_writes_status_and_sidecars(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 4d9a9952..ac0aabd2 100644 --- a/cadfs_to_cdsl/tests/test_parser.py +++ b/cadfs_to_cdsl/tests/test_parser.py @@ -58,10 +58,16 @@ class ParserTests(unittest.TestCase): def test_source_version_and_standard_library_are_retained(self): source = '''FeatureScript 1511; import(path : "onshape/std/geometry.fs", version : "1511.0"); + import(version : "1511.0", path : "onshape/std/common.fs"); export const f = defineFeature(function(context, id, definition) {});''' model = parse_featurescript(source, "versioned") self.assertEqual(model.featurescript_version, "1511") self.assertEqual(model.standard_library, "onshape/std/geometry.fs") + self.assertEqual(model.standard_library_version, "1511.0") + self.assertEqual(model.standard_library_imports, [ + {"path": "onshape/std/geometry.fs", "version": "1511.0"}, + {"path": "onshape/std/common.fs", "version": "1511.0"}, + ]) def test_block_scoped_query_aliases_do_not_use_the_last_assignment(self): source = r''' diff --git a/cadfs_to_cdsl/tests/test_selector_binding.py b/cadfs_to_cdsl/tests/test_selector_binding.py index ef0130c7..d9c5ebfa 100644 --- a/cadfs_to_cdsl/tests/test_selector_binding.py +++ b/cadfs_to_cdsl/tests/test_selector_binding.py @@ -138,27 +138,45 @@ class SelectorBindingTests(unittest.TestCase): self.assertEqual(len(binding["resolved"]), 1) self.assertEqual(binding["resolved"][0]["output_roles"], ["extrude.end"]) - def test_shell_offset_face_role_binds_only_its_true_dependency_source(self) -> None: + def test_rebuild_candidate_uses_one_incremental_replay(self) -> None: + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0001/00016195.txt" + if not feature.exists(): self.skipTest("CADFS sample is not installed") + candidate = lower_model(parse_featurescript(feature.read_text(), "00016195"), {}) + + # The production path must bind and export from one live session. If + # it called the old compatibility binder, this patched entry point + # would fail while rebuilding selector-bearing prefixes. + with tempfile.TemporaryDirectory() as directory: + with patch( + "engine.cdsl_engine.runtime.rebuild_cdsl", + side_effect=AssertionError("production rebuild invoked legacy prefix replay"), + ): + outcome = rebuild_candidate(candidate.cdsl, Path(directory) / "00016195.step") + + self.assertEqual(outcome["status"], "rebuilt") + self.assertEqual( + [item["feature_id"] for item in outcome["result"]["feature_results"]], + [item["id"] for item in candidate.cdsl["features"]], + ) + self.assertTrue(outcome["selector_binding"]) + + def test_shell_offset_face_preserves_true_dependency_but_rejects_unproven_cap_selector(self) -> None: root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0010/00107631.txt" if not feature.exists(): self.skipTest("CADFS sample is not installed") candidate = lower_model(parse_featurescript(feature.read_text(), "00107631"), {}) self.assertEqual(candidate.status, "converted_complete") - # F2 retains F1's end-cap provenance, but F3 also selects the distinct - # start-cap plane. Its direct owner has no geometry-qualified active - # candidate, so binding must consider the unique active F2 face without - # weakening the exact qualified OFFSET_FACE role. + # F2 consumes its immediate direct CAP_FACE output role. F3 retains + # its exact OFFSET_FACE true-dependency intent, but its separate + # start-cap removal selector is deferred and must not rebind + # geometrically. prefix = deepcopy(candidate.cdsl) prefix["features"] = prefix["features"][:3] - bound, evidence = bind_candidate_selectors(prefix) - - first, offset = bound["features"][-1]["selectors"] - self.assertEqual(first["owner_feature_id"], "f_F2") - self.assertEqual(first["stable_id"], "body:f_F2:face:1") - self.assertEqual(first["snapshot_id"], "body:f_F2:face:1") - self.assertEqual(first["source"], "runtime_snapshot") - self.assertEqual(first["confidence"], 1.0) + first, offset = prefix["features"][-1]["selectors"] + self.assertEqual(first["selector_intent"]["query_family"], "CAP_FACE") + self.assertEqual(first["selector_intent"]["derivation_policy"]["multiplicity"], "none") self.assertEqual({key: offset[key] for key in ( "kind", "owner_feature_id", "output_role", "output_role_source", "source", "confidence", )}, { @@ -168,33 +186,25 @@ class SelectorBindingTests(unittest.TestCase): }) self.assertEqual(offset["selector_intent"]["query_family"], "OFFSET_FACE") self.assertEqual(offset["selector_intent"]["disambiguation"]["type"], "true_dependency") - binding = next(item for item in evidence if item["feature_id"] == "f_F3") - self.assertEqual(binding["resolved"][0]["snapshot_id"], "body:f_F2:face:1") - self.assertEqual(binding["resolved"][1]["record_id"], "body:f_F2:face:5") + with self.assertRaisesRegex(ValueError, "f_F3: selector_query_unsupported after prefix rebuild"): + bind_candidate_selectors(prefix) - # Binding must not hide the OCC feasibility boundary. The requested - # second shell currently produces an invalid shape, so preserve the - # F2 prefix instead of changing thickness or removal faces. with tempfile.TemporaryDirectory() as directory: outcome = rebuild_candidate(candidate.cdsl, Path(directory) / "00107631.step") self.assertEqual(outcome["status"], "rebuild_failed") - self.assertEqual(outcome["error"]["type"], "RuntimeExecutionError") - self.assertEqual(outcome["error"]["message"], "OCC shell operation produced an invalid shape") + self.assertEqual(outcome["error"]["message"], "f_F3: selector_query_unsupported during incremental replay") self.assertEqual(outcome["last_executable_prefix"]["failed_feature_id"], "f_F3") self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F2") - def test_intersection_vertex_binds_a_pattern_copy_with_exact_instance_owner_evidence(self) -> None: + def test_intersection_vertex_preserves_outer_copy_provenance_without_geometry_binding(self) -> None: root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" feature = root / "featurescript_rp/0042/00423838.txt" if not feature.exists(): self.skipTest("CADFS sample is not installed") candidate = lower_model(parse_featurescript(feature.read_text(), "00423838"), {}) reference = next(item for item in candidate.cdsl["features"] if item["id"] == "f_F7")["params"]["end_condition"]["reference"] self.assertTrue(all(item["owner_match_required"] for item in reference["intersection_of"][:2])) - - bound, evidence = bind_candidate_selectors(candidate.cdsl) - reference = next(item for item in bound["features"] if item["id"] == "f_F7")["params"]["end_condition"]["reference"] + self.assertEqual(reference["selector_intent"]["query_family"], "INTERSECT") copy_components = reference["intersection_of"][:2] - self.assertTrue(all(item["owner_feature_id"] == "f_F4.c4.f_F1" for item in copy_components)) - self.assertTrue(all(item.get("snapshot_id") for item in copy_components)) - binding = next(item for item in evidence if item["feature_id"] == "f_F7") - self.assertGreaterEqual(len(binding["resolved"]), 2) + self.assertEqual([item["selector_intent"]["query_family"] for item in copy_components], ["COPY", "COPY"]) + with self.assertRaisesRegex(ValueError, "f_F7: selector_query_unsupported after prefix rebuild"): + bind_candidate_selectors(candidate.cdsl) diff --git a/cadfs_to_cdsl/tests/test_selector_candidate_demo.py b/cadfs_to_cdsl/tests/test_selector_candidate_demo.py new file mode 100644 index 00000000..0cb18996 --- /dev/null +++ b/cadfs_to_cdsl/tests/test_selector_candidate_demo.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import tempfile +import unittest +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 + + +def _contains_selector_intent(value: object) -> bool: + if isinstance(value, dict): + return "selector_intent" in value or any(_contains_selector_intent(child) for child in value.values()) + if isinstance(value, list): + return any(_contains_selector_intent(child) for child in value) + return False + + +class SelectorCandidateDemoTests(unittest.TestCase): + def test_search_candidates_excludes_records_below_normal_resolution_threshold(self) -> None: + resolution = type("Resolution", (), { + "candidates": ( + {"record_id": "low", "score": 0.5}, + {"record_id": "high-b", "score": 1.0}, + {"record_id": "high-a", "score": 1.0}, + {"record_id": "zero", "score": 0.0}, + ), + })() + candidates, count = _search_candidate_records(resolution, maximum=8) + self.assertEqual(count, 2) + self.assertEqual([item["record_id"] for item in candidates], ["high-a", "high-b"]) + + def test_strip_provenance_intents_removes_nested_selector_metadata(self) -> None: + value = { + "selector_intent": {"version": "1.0"}, + "params": {"reference": {"selector_intent_version": "1.0"}}, + } + self.assertEqual(strip_provenance_intents(value), 2) + self.assertEqual(value, {"params": {"reference": {}}}) + + 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" + step = root / "step_abc/0000/00002243.step" + if not feature.exists() or not step.exists(): + self.skipTest("CADFS sample is not installed") + sample = Sample("00002243", {"featurescript": str(feature), "step": str(step)}) + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) + report = run_geometry_probe(sample, output) + record = read_json(output / "samples" / sample.sample_id / "selector-record.json") + compiled = read_json(output / "samples" / sample.sample_id / "selector_record.compiled.cdsl.json") + self.assertEqual(report["provenance"]["rebuild_status"], "rebuild_failed") + self.assertEqual(report["geometry_probe"]["rebuild_status"], "rebuilt") + self.assertEqual(report["geometry_probe"]["comparison"], { + "decision": "strict_pass", + "strict_passed": True, + "rp_passed": True, + }) + self.assertEqual(report["strategy"]["classification"], "heuristic_only") + self.assertEqual(report["selector_record"]["status"], "strict_replayed") + self.assertEqual(report["selector_record"]["record_count"], 2) + self.assertEqual(record["verification"], { + "geometry_probe_strict": True, + "compiled_cdsl_strict": True, + "compiled_cdsl": report["selector_record"]["compiled_cdsl"], + }) + self.assertEqual([entry["mapping"] for entry in record["records"]], ["one_to_one", "one_to_one"]) + self.assertTrue(all(entry["source_selector"]["selector_intent"]["query_family"] == "CAP_EDGE" for entry in record["records"])) + self.assertFalse(_contains_selector_intent(compiled)) diff --git a/outputs/cadfs_four_run_comparison_20260910.md b/outputs/cadfs_four_run_comparison_20260910.md new file mode 100644 index 00000000..ae743b18 --- /dev/null +++ b/outputs/cadfs_four_run_comparison_20260910.md @@ -0,0 +1,73 @@ +# CADFS 四次全量输出对比 + +## 统计口径 + +- 四次运行均使用同一批 9,347 个 CADFS 样本;日期使用各 `full_run_report.md` 的实际生成时间。 +- `生成可执行 STEP` 按 `rebuild.step` 工件存在计数。它与最终 runtime 状态为 `rebuilt` 不同:后者失败时,系统仍可能保留最后可执行的 STEP 前缀。 +- 能力缺口为受影响样本数,同一样本可能命中多个能力项,因此不能横向相加为失败样本总数。 +- executor 原子操作新增,表示引擎可调度该操作;不代表所有 FeatureScript 参数、selector、Body 生命周期和拓扑来源语义都已完整支持。 + +## 输出成功数量 + +| 指标 | 2026-09-02 | 2026-09-07 | 2026-09-08 | 2026-09-09 | 首次到最新变化 | +| --- | ---: | ---: | ---: | ---: | ---: | +| 生成候选 CDSL | 8,108 (86.74%) | 7,112 (76.09%) | 8,633 (92.36%) | 8,942 (95.67%) | +834 (+8.93pp) | +| 绑定 CDSL | 1,773 (18.97%) | 5,554 (59.42%) | 6,134 (65.62%) | 8,364 (89.48%) | +6,591 (+70.51pp) | +| 生成可执行 STEP | 1,812 (19.39%) | 5,554 (59.42%) | 6,134 (65.62%) | 8,176 (87.47%) | +6,364 (+68.08pp) | +| 最终 runtime 为 `rebuilt` | 1,811 (19.38%) | 5,554 (59.42%) | 6,134 (65.62%) | 6,055 (64.78%) | +4,244 (+45.40pp) | +| 完成几何比较 | 1,719 (18.39%) | 5,250 (56.17%) | 6,090 (65.16%) | 6,007 (64.27%) | +4,288 (+45.88pp) | +| RP 通过 | 1,159 (12.40%) | 1,358 (14.53%) | 2,031 (21.73%) | 2,253 (24.10%) | +1,094 (+11.70pp) | +| 严格通过 | 578 (6.18%) | 654 (7.00%) | 905 (9.68%) | 989 (10.58%) | +411 (+4.40pp) | + +结论:候选 CDSL 在第二次运行短暂下降后恢复至 95.67%。可执行 STEP 从 1,812 增至 8,176,是最明显的产出改善。9 月 9 日的最终 `rebuilt` 数比上次少 79,但可执行 STEP 多 2,042,表明工件存在数与终态重建成功数在该轮明显分离,不能将两者混为同一成功指标。几何比较数较 9 月 8 日少 83,RP 和严格通过数则继续增长。 + +## 新增执行能力 + +| 运行阶段 | 新增 executor 原子操作 | 能力结论 | +| --- | --- | --- | +| 2026-09-02 | 基线:盲拉伸/双侧拉伸/切除、圆角、倒角、孔、镜像/线性阵列、参考轴/面、回转等 | 基础建模链路已可执行,但 `loft`、`sweep`、`shell` 等仍在已知不支持清单中。 | +| 2026-09-07 | `box_add`、`cylinder_add`、`extrude_cut_through`、`extrude_cut_two_sided`、`loft_add`、`pattern_circular`、`thread_add`、`thread_cut` | `loft` 不再列入已知不支持 FeatureScript 操作;圆周阵列、螺纹和更多拉伸方式进入执行器。 | +| 2026-09-08 | `bend_add`、`boolean_bodies`、`extrude_add_blind_with_hole`、`extrude_surface`、`loft_add_with_cap_face`、`revolve_surface`、`shell`、`sweep_add` | 增加多 Body 布尔、壳、扫掠、曲面回转和带孔拉伸执行路径;但 `shell`、`sweep` 仍有大量 source-lowering/selector 缺口。 | +| 2026-09-09 | `delete_bodies`、`extrude_from_face`、`gear_add`、`rack_add`、`transform_bodies` | Body 删除、从面拉伸、齿轮/齿条和 Body 变换进入执行器;对应的 query、copy、pattern 等变体仍需按 contract 和拓扑来源继续覆盖。 | + +## 已优化能力 + +下表使用每次 `summary.json` 的能力缺口计数。`减少` 表示该能力在运行中被记录为缺口的样本减少,不表示该特征已经完全支持。 + +| 能力缺口 | 09-02 | 09-07 | 09-08 | 09-09 | 首次到最新变化 | 结论 | +| --- | ---: | ---: | ---: | ---: | ---: | --- | +| `extrude` | 3,861 | 3,600 | 1,197 | 899 | -2,962 | 最大覆盖改善;仍需处理 profile/extent/selector 变体。 | +| `fillet` | 1,914 | 1,904 | 1,417 | 1,084 | -830 | 明显下降,但仍是最新运行的最大单项缺口。 | +| `shell` | 696 | 696 | 57 | 43 | -653 | 执行器加入壳操作后主缺口大幅下降;面选择器问题仍存在。 | +| `revolve` | 741 | 662 | 344 | 200 | -541 | 回转主路径覆盖持续改善。 | +| `extrude_profile_topology:intersect` | 382 | 383 | 362 | 33 | -349 | 末轮拓扑交集选择器覆盖有显著改善。 | +| `chamfer` | 589 | 588 | 381 | 265 | -324 | 降幅明确,运行时构造失败仍是主要剩余问题。 | +| `hole` | 623 | 582 | 386 | 369 | -254 | 孔特征覆盖提升,但仍影响 369 个样本。 | +| `loft` | 308 | 107 | 68 | 66 | -242 | `loft_add` 后快速下降,cap-face 等拓扑变体仍未完成。 | +| `sweep` | 326 | 326 | 133 | 122 | -204 | `sweep_add` 后主缺口下降,path 选择器仍是限制。 | +| `circularPattern` | 228 | 139 | 74 | 51 | -177 | 圆周阵列持续改善。 | +| `booleanBodies` | 187 | 187 | 92 | 35 | -152 | 多 Body 布尔执行路径加入后下降;target 选择仍有独立缺口。 | +| `mirror` | 262 | 241 | 158 | 140 | -122 | 镜像覆盖逐步提升。 | +| `cPlane` | 147 | 168 | 84 | 69 | -78 | 基准面覆盖提升,但不同行/点/曲线来源仍需处理。 | +| `extrude_profile_topology:cap_face` | 200 | 201 | 183 | 112 | -88 | cap-face 来源仍是拉伸后续特征的重要阻塞点。 | +| `extrude_profile_topology:cap_edge` | 133 | 134 | 91 | 88 | -45 | cap-edge 改善较慢,仍需稳定的边来源语义。 | +| `extrude_profile_topology:swept_face` | 99 | 99 | 94 | 94 | -5 | 几乎没有改善,是后续优先的 selector/provenance 问题。 | + +此外,`revolve_surface`(367/369)和 `extrude_extent:up_to_surface`(95/100)仅在前两次运行列为能力缺口,后两次未再报告;这与新增 `revolve_surface`、扩展拉伸执行路径的时间点一致,但仅能证明该缺口不再被当前报告记录,不能替代逐参数、逐拓扑来源的全量验收。 + +## 最新仍需处理的能力 + +| 最新能力缺口 | 受影响样本 | 观察 | +| --- | ---: | --- | +| `fillet` | 1,084 | 最大单项缺口;同时存在内核半径构造失败。 | +| `extrude` | 899 | 主操作已大幅改善,但 profile 和 extent 变体仍广泛存在。 | +| `hole` | 369 | 仍是前三大操作缺口。 | +| `chamfer` | 265 | 除覆盖缺口外,运行时 `Failed creating a chamfer` 仍高频。 | +| `revolve` | 200 | 主路径已改善,曲面/选择器变体需继续验证。 | +| `shell_face_selector` | 158 | 壳体删除面的 selector/provenance 仍是主要限制。 | +| `mirror` / `sweep` | 140 / 122 | 基础路径可用,但来源与路径选择语义尚未完整覆盖。 | +| `extrude_profile_topology:*` | cap-face 112、swept-face 94、cap-edge 88 | 拉伸后面/边拓扑来源是后续特征链断点。 | + +## 结论 + +四次运行的主要进展是从“可转换但大量 deferred”转向“多数样本可产出 CDSL、绑定 CDSL 和可执行 STEP”。最新运行中,95.67% 有候选 CDSL、89.48% 有绑定 CDSL、87.47% 保留可执行 STEP;但 RP 通过仅为 24.10%,严格通过为 10.58%,说明后续重点应从扩大可执行工件覆盖转向提高几何保真与拓扑选择器稳定性。特别是 `fillet`、`extrude` 及其 cap/swept face/edge 来源、壳体面选择器和倒角内核构造,仍应以通用 contract、body graph 和 selector provenance 方式推进,而不是按单一样本修补。 diff --git a/outputs/cadfs_previous_run_analysis_20260910/cadfs_previous_run_analysis.xlsx b/outputs/cadfs_previous_run_analysis_20260910/cadfs_previous_run_analysis.xlsx new file mode 100644 index 00000000..3063d2a1 Binary files /dev/null and b/outputs/cadfs_previous_run_analysis_20260910/cadfs_previous_run_analysis.xlsx differ diff --git a/outputs/cadfs_previous_run_analysis_20260910/cadfs_previous_run_analysis.xlsx.inspect.ndjson b/outputs/cadfs_previous_run_analysis_20260910/cadfs_previous_run_analysis.xlsx.inspect.ndjson new file mode 100644 index 00000000..0750c3e8 --- /dev/null +++ b/outputs/cadfs_previous_run_analysis_20260910/cadfs_previous_run_analysis.xlsx.inspect.ndjson @@ -0,0 +1,471 @@ +{"kind":"workbook","id":"wb/4vzucq","sheets":2,"tables":2} +{"kind":"sheet","id":"ws/bxn16e","name":"结果总览","index":0,"range":"A1:H27","address":"A1:H27","tables":1,"formulas":18} +{"kind":"table","sheet":"结果总览","address":"A1:H27","rows":27,"cols":8,"values":[[null,null,null,null,null,null,null,null],["CADFS 到 CDSL 上一版全量运行基线",null,null,null,null,null,null,null],["归档运行时间","2026-09-09T22:42:52",null,"总样本",9347,null,null,null],[null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null],["指标","本次(上一版全量运行)","上次归档","变化","占总样本","结论",null,"统计口径"],["生成候选 CDSL",8942,"n.a.","n.a.",0.9566705894939552,"转化完成或部分完成后保留 candidate CDSL。",null,"本表将提供的旧版 output 作为后续对照基线。"],["绑定 CDSL",8364,"n.a.","n.a.",0.8948325665989088,"完成 selector/reference 绑定的工件数。",null,"未提供更早归档,故“上次归档”和“变化”保留为 n.a.。"],["生成可执行 STEP",8176,"n.a.","n.a.",0.8747191612282016,"按工件存在计数;与最终重建状态分开呈现。",null,"可执行 STEP 文件存在数与最终 rebuild 成功数分别统计。"],["最终重建成功",6055,"n.a.","n.a.",0.6478014336150637,"运行时最终状态为 rebuilt。",null,"能力缺口与诊断计数均可能在同一样本上重叠。"],["完成几何比较",6007,"n.a.","n.a.",0.6426660960736065,"有 strict 或 RP 判定的比较报告。",null,null],["RP 通过",2253,"n.a.","n.a.",0.24103990585214508,"严格通过与近似通过之和。",null,null],["严格通过",989,"n.a.","n.a.",0.1058093505937734,"同时满足 strict 比较阈值。",null,null],["转换完成",6232,"n.a.","n.a.",0.6667379907991869,"CDSL 转换无 deferred feature。",null,null],["转换部分完成",2710,"n.a.","n.a.",0.2899325986947684,"保留可执行前缀,但存在 deferred feature。",null,null],[null,null,null,null,null,null,null,null],["最终状态分布",null,null,null,null,null,null,null],["最终状态","样本数","占总样本","接受级别","说明",null,null,null],["rebuilt_strict",989,0.1058093505937734,"严格接受","严格比较通过。",null,null,null],["rebuilt_approximate",1264,0.13523055525837166,"RP 接受","工程相似通过,但未达到 strict。",null,null,null],["rebuilt_rejected",3754,0.40162619022146145,"未接受","已生成并比较,但未通过 RP。",null,null,null],["rebuild_failed",2413,0.25815769765700225,"未接受","最终 rebuild 失败;可执行 STEP 工件另按文件存在计数。",null,null,null],["runtime_ineligible",465,0.04974858243286616,"未接受","运行时判定不具备执行资格。",null,null,null],["deferred_no_executable_feature",396,0.0423665347170215,"未接受","没有可执行 feature。",null,null,null],["comparison_timeout",48,0.005135337541457152,"未接受","几何比较超过 60 秒。",null,null,null],["rebuild_timeout",9,0.000962875789023216,"未接受","rebuild 超时。",null,null,null],["parse_failed",9,0.000962875789023216,"未接受","解析或 lowering 失败。",null,null,null]]} +{"kind":"region","sheet":"结果总览","address":"A6:F15","rows":10,"cols":6,"nonEmpty":60,"text":60,"maxTextLength":29,"preview":[["指标","本次(上一版全量运行)","上次归档","变化","占总样本","结论"],["生成候选 CDSL","8942","n.a.","n.a.","0.9566705894939552","转化完成或部分完成后保留 candidate CDSL。"],["绑定 CDSL","8364","n.a.","n.a.","0.8948325665989088","完成 selector/reference 绑定的工件数。"],["生成可执行 STEP","8176","n.a.","n.a.","0.8747191612282016","按工件存在计数;与最终重建状态分开呈现。"],["最终重建成功","6055","n.a.","n.a.","0.6478014336150637","运行时最终状态为 rebuilt。"]],"previewAddress":"A6:F10","previewRows":5,"previewCols":6} +{"kind":"region","sheet":"结果总览","address":"A17:F27","rows":11,"cols":6,"nonEmpty":51,"blank":15,"text":51,"maxTextLength":34,"preview":[["最终状态分布",null,null,null,null,null],["最终状态","样本数","占总样本","接受级别","说明",null],["rebuilt_strict","989","0.1058093505937734","严格接受","严格比较通过。",null],["rebuilt_approximate","1264","0.13523055525837166","RP 接受","工程相似通过,但未达到 strict。",null],["rebuilt_rejected","3754","0.40162619022146145","未接受","已生成并比较,但未通过 RP。",null]],"previewAddress":"A17:F21","previewRows":5,"previewCols":6} +{"kind":"region","sheet":"结果总览","address":"A2:F3","rows":2,"cols":6,"nonEmpty":5,"blank":7,"text":5,"maxTextLength":22,"preview":[["CADFS 到 CDSL 上一版全量运行基线",null,null,null,null,null],["归档运行时间","2026-09-09T22:42:52",null,"总样本","9347",null]],"previewAddress":"A2:F3","previewRows":2,"previewCols":6} +{"kind":"region","sheet":"结果总览","address":"H6:H15","rows":10,"cols":1,"nonEmpty":5,"blank":5,"text":5,"maxTextLength":34,"preview":[["统计口径"],["本表将提供的旧版 output 作为后续对照基线。"],["未提供更早归档,故“上次归档”和“变化”保留为 n.a.。"],["可执行 STEP 文件存在数与最终 rebuild 成功数分别统计。"],["能力缺口与诊断计数均可能在同一样本上重叠。"]],"previewAddress":"H6:H10","previewRows":5,"previewCols":1} +{"kind":"formula","sheet":"结果总览","address":"E7","formula":"=B7/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"E8","formula":"=B8/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"E9","formula":"=B9/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"E10","formula":"=B10/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"E11","formula":"=B11/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"E12","formula":"=B12/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"E13","formula":"=B13/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"E14","formula":"=B14/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"E15","formula":"=B15/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"C19","formula":"=B19/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"C20","formula":"=B20/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"C21","formula":"=B21/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"C22","formula":"=B22/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"C23","formula":"=B23/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"C24","formula":"=B24/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"C25","formula":"=B25/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"C26","formula":"=B26/$E$3"} +{"kind":"formula","sheet":"结果总览","address":"C27","formula":"=B27/$E$3"} +{"kind":"computedStyle","sheet":"结果总览","for":"A2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":16,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{},"wrapText":false,"horizontalAlignment":"general","styleId":2}} +{"kind":"computedStyle","sheet":"结果总览","for":"A3","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"52616B"},"gradientStops":[],"pictureEffects":[]}},"border":{},"wrapText":false,"horizontalAlignment":"general","styleId":4}} +{"kind":"computedStyle","sheet":"结果总览","for":"B3","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"52616B"},"gradientStops":[],"pictureEffects":[]}},"border":{},"wrapText":false,"horizontalAlignment":"general","styleId":4}} +{"kind":"computedStyle","sheet":"结果总览","for":"D3","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"52616B"},"gradientStops":[],"pictureEffects":[]}},"border":{},"wrapText":false,"horizontalAlignment":"general","styleId":4}} +{"kind":"computedStyle","sheet":"结果总览","for":"E3","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":11,"typeface":"Carlito"},"border":{},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":5}} +{"kind":"computedStyle","sheet":"结果总览","for":"A6","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"结果总览","for":"B6","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"结果总览","for":"C6","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"结果总览","for":"D6","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"结果总览","for":"E6","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"结果总览","for":"F6","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"结果总览","for":"H6","style":{"fill":{"type":1,"color":{"type":1,"value":"E8EFF4"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"24435A"},"gradientStops":[],"pictureEffects":[]}},"border":{},"wrapText":false,"horizontalAlignment":"general","styleId":8}} +{"kind":"computedStyle","sheet":"结果总览","for":"A7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":20}} +{"kind":"computedStyle","sheet":"结果总览","for":"B7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":23}} +{"kind":"computedStyle","sheet":"结果总览","for":"C7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":20}} +{"kind":"computedStyle","sheet":"结果总览","for":"D7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":20}} +{"kind":"computedStyle","sheet":"结果总览","for":"E7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":26}} +{"kind":"computedStyle","sheet":"结果总览","for":"F7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":20}} +{"kind":"computedStyle","sheet":"结果总览","for":"H7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"italic":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"52616B"},"gradientStops":[],"pictureEffects":[]}},"border":{},"wrapText":true,"horizontalAlignment":"general","styleId":11}} +{"kind":"computedStyle","sheet":"结果总览","for":"A8","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"B8","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"结果总览","for":"C8","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"D8","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"E8","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":27}} +{"kind":"computedStyle","sheet":"结果总览","for":"F8","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"H8","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"italic":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"52616B"},"gradientStops":[],"pictureEffects":[]}},"border":{},"wrapText":true,"horizontalAlignment":"general","styleId":11}} +{"kind":"computedStyle","sheet":"结果总览","for":"A9","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"B9","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"结果总览","for":"C9","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"D9","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"E9","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":27}} +{"kind":"computedStyle","sheet":"结果总览","for":"F9","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"H9","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"italic":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"52616B"},"gradientStops":[],"pictureEffects":[]}},"border":{},"wrapText":true,"horizontalAlignment":"general","styleId":11}} +{"kind":"computedStyle","sheet":"结果总览","for":"A10","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"B10","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"结果总览","for":"C10","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"D10","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"E10","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":27}} +{"kind":"computedStyle","sheet":"结果总览","for":"F10","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"H10","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"italic":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"52616B"},"gradientStops":[],"pictureEffects":[]}},"border":{},"wrapText":true,"horizontalAlignment":"general","styleId":11}} +{"kind":"computedStyle","sheet":"结果总览","for":"A11","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"B11","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"结果总览","for":"C11","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"D11","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"E11","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":27}} +{"kind":"computedStyle","sheet":"结果总览","for":"F11","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"A12","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"B12","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"结果总览","for":"C12","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"D12","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"E12","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":27}} +{"kind":"computedStyle","sheet":"结果总览","for":"F12","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"A13","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"B13","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"结果总览","for":"C13","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"D13","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"E13","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":27}} +{"kind":"computedStyle","sheet":"结果总览","for":"F13","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"A14","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"B14","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"结果总览","for":"C14","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"D14","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"E14","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":27}} +{"kind":"computedStyle","sheet":"结果总览","for":"F14","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"结果总览","for":"A15","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":22}} +{"kind":"computedStyle","sheet":"结果总览","for":"B15","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":25}} +{"kind":"computedStyle","sheet":"结果总览","for":"C15","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":22}} +{"kind":"computedStyle","sheet":"结果总览","for":"D15","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":22}} +{"kind":"computedStyle","sheet":"结果总览","for":"E15","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":28}} +{"kind":"computedStyle","sheet":"结果总览","for":"F15","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":22}} +{"kind":"computedStyle","sheet":"结果总览","for":"A17","style":{"fill":{"type":1,"color":{"type":1,"value":"E8EFF4"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"24435A"},"gradientStops":[],"pictureEffects":[]}},"border":{},"wrapText":false,"horizontalAlignment":"general","styleId":29}} +{"kind":"computedStyle","sheet":"结果总览","for":"A18","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"结果总览","for":"B18","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"结果总览","for":"C18","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"结果总览","for":"D18","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"结果总览","for":"E18","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"结果总览","for":"A19","style":{"fill":{"type":1,"color":{"type":1,"value":"EDF7F0"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":30}} +{"kind":"computedStyle","sheet":"结果总览","for":"B19","style":{"fill":{"type":1,"color":{"type":1,"value":"EDF7F0"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":31}} +{"kind":"computedStyle","sheet":"结果总览","for":"C19","style":{"fill":{"type":1,"color":{"type":1,"value":"EDF7F0"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":32}} +{"kind":"computedStyle","sheet":"结果总览","for":"D19","style":{"fill":{"type":1,"color":{"type":1,"value":"EDF7F0"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":30}} +{"kind":"computedStyle","sheet":"结果总览","for":"E19","style":{"fill":{"type":1,"color":{"type":1,"value":"EDF7F0"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":30}} +{"kind":"computedStyle","sheet":"结果总览","for":"A20","style":{"fill":{"type":1,"color":{"type":1,"value":"EDF7F0"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":33}} +{"kind":"computedStyle","sheet":"结果总览","for":"B20","style":{"fill":{"type":1,"color":{"type":1,"value":"EDF7F0"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":34}} +{"kind":"computedStyle","sheet":"结果总览","for":"C20","style":{"fill":{"type":1,"color":{"type":1,"value":"EDF7F0"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":35}} +{"kind":"computedStyle","sheet":"结果总览","for":"D20","style":{"fill":{"type":1,"color":{"type":1,"value":"EDF7F0"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":33}} +{"kind":"computedStyle","sheet":"结果总览","for":"E20","style":{"fill":{"type":1,"color":{"type":1,"value":"EDF7F0"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":33}} +{"kind":"computedStyle","sheet":"结果总览","for":"A21","style":{"fill":{"type":1,"color":{"type":1,"value":"FFF4E5"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":36}} +{"kind":"computedStyle","sheet":"结果总览","for":"B21","style":{"fill":{"type":1,"color":{"type":1,"value":"FFF4E5"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":37}} +{"kind":"computedStyle","sheet":"结果总览","for":"C21","style":{"fill":{"type":1,"color":{"type":1,"value":"FFF4E5"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":38}} +{"kind":"computedStyle","sheet":"结果总览","for":"D21","style":{"fill":{"type":1,"color":{"type":1,"value":"FFF4E5"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":36}} +{"kind":"computedStyle","sheet":"结果总览","for":"E21","style":{"fill":{"type":1,"color":{"type":1,"value":"FFF4E5"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":36}} +{"kind":"computedStyle","sheet":"结果总览","for":"A22","style":{"fill":{"type":1,"color":{"type":1,"value":"FFF4E5"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":36}} +{"kind":"computedStyle","sheet":"结果总览","for":"B22","style":{"fill":{"type":1,"color":{"type":1,"value":"FFF4E5"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":37}} +{"kind":"computedStyle","sheet":"结果总览","for":"C22","style":{"fill":{"type":1,"color":{"type":1,"value":"FFF4E5"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":38}} +{"kind":"computedStyle","sheet":"结果总览","for":"D22","style":{"fill":{"type":1,"color":{"type":1,"value":"FFF4E5"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":36}} +{"kind":"computedStyle","sheet":"结果总览","for":"E22","style":{"fill":{"type":1,"color":{"type":1,"value":"FFF4E5"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":36}} +{"kind":"computedStyle","sheet":"结果总览","for":"A23","style":{"fill":{"type":1,"color":{"type":1,"value":"FFF4E5"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":36}} +{"kind":"computedStyle","sheet":"结果总览","for":"B23","style":{"fill":{"type":1,"color":{"type":1,"value":"FFF4E5"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":37}} +{"kind":"computedStyle","sheet":"结果总览","for":"C23","style":{"fill":{"type":1,"color":{"type":1,"value":"FFF4E5"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":38}} +{"kind":"computedStyle","sheet":"结果总览","for":"D23","style":{"fill":{"type":1,"color":{"type":1,"value":"FFF4E5"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":36}} +{"kind":"computedStyle","sheet":"结果总览","for":"E23","style":{"fill":{"type":1,"color":{"type":1,"value":"FFF4E5"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":36}} +{"kind":"computedStyle","sheet":"结果总览","for":"A24","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":39}} +{"kind":"computedStyle","sheet":"结果总览","for":"B24","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":40}} +{"kind":"computedStyle","sheet":"结果总览","for":"C24","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":41}} +{"kind":"computedStyle","sheet":"结果总览","for":"D24","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":39}} +{"kind":"computedStyle","sheet":"结果总览","for":"E24","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":39}} +{"kind":"computedStyle","sheet":"结果总览","for":"A25","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":39}} +{"kind":"computedStyle","sheet":"结果总览","for":"B25","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":40}} +{"kind":"computedStyle","sheet":"结果总览","for":"C25","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":41}} +{"kind":"computedStyle","sheet":"结果总览","for":"D25","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":39}} +{"kind":"computedStyle","sheet":"结果总览","for":"E25","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":39}} +{"kind":"computedStyle","sheet":"结果总览","for":"A26","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":39}} +{"kind":"computedStyle","sheet":"结果总览","for":"B26","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":40}} +{"kind":"computedStyle","sheet":"结果总览","for":"C26","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":41}} +{"kind":"computedStyle","sheet":"结果总览","for":"D26","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":39}} +{"kind":"computedStyle","sheet":"结果总览","for":"E26","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":39}} +{"kind":"computedStyle","sheet":"结果总览","for":"A27","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":42}} +{"kind":"computedStyle","sheet":"结果总览","for":"B27","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":43}} +{"kind":"computedStyle","sheet":"结果总览","for":"C27","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"0.00%","wrapText":false,"horizontalAlignment":"general","styleId":44}} +{"kind":"computedStyle","sheet":"结果总览","for":"D27","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":42}} +{"kind":"computedStyle","sheet":"结果总览","for":"E27","style":{"fill":{"type":1,"color":{"type":1,"value":"FDEDEC"},"gradientStops":[],"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":42}} +{"kind":"sheet","id":"ws/lnyeid","name":"能力缺口","index":1,"range":"A1:F60","address":"A1:F60","tables":1} +{"kind":"table","sheet":"能力缺口","address":"A1:F60","rows":60,"cols":6,"values":[[null,null,null,null,null,null],["上一版能力缺口与运行时故障",null,null,null,null,null],["能力缺口按 affected sample 计数,跨能力项不互斥。",null,null,null,null,null],[null,null,null,null,null,null],[null,null,null,null,null,null],["类别","能力 / 指标","上次","本次","变化","结论"],["特征操作","fillet","n.a.",1084,"n.a.","影响 1,084 个样本(11.60%);最大单项能力缺口。"],["特征操作","extrude","n.a.",899,"n.a.","影响 899 个样本(9.62%);主操作缺口,相关 profile topology 子项另行统计。"],["特征操作","hole","n.a.",369,"n.a.","影响 369 个样本(3.95%);归档运行记录为能力缺口。"],["特征操作","chamfer","n.a.",265,"n.a.","影响 265 个样本(2.84%);归档运行记录为能力缺口。"],["特征操作","revolve","n.a.",200,"n.a.","影响 200 个样本(2.14%);归档运行记录为能力缺口。"],["拓扑来源 / 选择器","shell_face_selector","n.a.",158,"n.a.","影响 158 个样本(1.69%);依赖面/边/Body 选择器绑定。"],["特征操作","mirror","n.a.",140,"n.a.","影响 140 个样本(1.50%);归档运行记录为能力缺口。"],["特征操作","sweep","n.a.",122,"n.a.","影响 122 个样本(1.31%);归档运行记录为能力缺口。"],["拓扑来源 / 选择器","extrude_profile_topology:cap_face","n.a.",112,"n.a.","影响 112 个样本(1.20%);挤出 profile 的拓扑来源不具唯一/可用绑定。"],["拓扑来源 / 选择器","extrude_profile_topology:swept_face","n.a.",94,"n.a.","影响 94 个样本(1.01%);挤出 profile 的拓扑来源不具唯一/可用绑定。"],["拓扑来源 / 选择器","extrude_profile_topology:cap_edge","n.a.",88,"n.a.","影响 88 个样本(0.94%);挤出 profile 的拓扑来源不具唯一/可用绑定。"],["特征操作","cPlane","n.a.",69,"n.a.","影响 69 个样本(0.74%);归档运行记录为能力缺口。"],["特征操作","sweep_path","n.a.",68,"n.a.","影响 68 个样本(0.73%);归档运行记录为能力缺口。"],["特征操作","loft","n.a.",66,"n.a.","影响 66 个样本(0.71%);归档运行记录为能力缺口。"],["Body 生命周期 / Pattern","circularPattern","n.a.",51,"n.a.","影响 51 个样本(0.55%);归档运行记录为能力缺口。"],["特征操作","shell","n.a.",43,"n.a.","影响 43 个样本(0.46%);归档运行记录为能力缺口。"],["Body 生命周期 / Pattern","booleanBodies","n.a.",35,"n.a.","影响 35 个样本(0.37%);涉及多 Body 或生命周期语义。"],["拓扑来源 / 选择器","extrude_profile_topology:intersect","n.a.",33,"n.a.","影响 33 个样本(0.35%);挤出 profile 的拓扑来源不具唯一/可用绑定。"],["Body 生命周期 / Pattern","boolean_bodies_targets","n.a.",29,"n.a.","影响 29 个样本(0.31%);涉及多 Body 或生命周期语义。"],["Body 生命周期 / Pattern","transform_pattern_copy","n.a.",20,"n.a.","影响 20 个样本(0.21%);涉及多 Body 或生命周期语义。"],["拓扑来源 / 选择器","extrude_profile_topology:offset_face","n.a.",17,"n.a.","影响 17 个样本(0.18%);挤出 profile 的拓扑来源不具唯一/可用绑定。"],["拓扑来源 / 选择器","loft_profile_topology:cap_face","n.a.",17,"n.a.","影响 17 个样本(0.18%);归档运行记录为能力缺口。"],["Body 生命周期 / Pattern","deleteBodies","n.a.",16,"n.a.","影响 16 个样本(0.17%);涉及多 Body 或生命周期语义。"],["特征操作","extrude_draft_extent","n.a.",16,"n.a.","影响 16 个样本(0.17%);归档运行记录为能力缺口。"],["拓扑来源 / 选择器","extrude_profile_topology:swept_edge","n.a.",15,"n.a.","影响 15 个样本(0.16%);挤出 profile 的拓扑来源不具唯一/可用绑定。"],["Body 生命周期 / Pattern","transform","n.a.",14,"n.a.","影响 14 个样本(0.15%);涉及多 Body 或生命周期语义。"],["拓扑来源 / 选择器","loft_profile_topology:swept_face","n.a.",9,"n.a.","影响 9 个样本(0.10%);归档运行记录为能力缺口。"],["拓扑来源 / 选择器","transform_body_query","n.a.",9,"n.a.","影响 9 个样本(0.10%);依赖面/边/Body 选择器绑定。"],["特征操作","sweep_remove","n.a.",6,"n.a.","影响 6 个样本(0.06%);归档运行记录为能力缺口。"],["拓扑来源 / 选择器","transform_translation_direction","n.a.",6,"n.a.","影响 6 个样本(0.06%);涉及多 Body 或生命周期语义。"],["Body 生命周期 / Pattern","delete_bodies","n.a.",3,"n.a.","影响 3 个样本(0.03%);涉及多 Body 或生命周期语义。"],["拓扑来源 / 选择器","extrude_cap_edge_profile","n.a.",2,"n.a.","影响 2 个样本(0.02%);归档运行记录为能力缺口。"],["Body 生命周期 / Pattern","transform_body_lifecycle","n.a.",2,"n.a.","影响 2 个样本(0.02%);涉及多 Body 或生命周期语义。"],["拓扑来源 / 选择器","transform_translation_entity","n.a.",2,"n.a.","影响 2 个样本(0.02%);涉及多 Body 或生命周期语义。"],["Body 生命周期 / Pattern","circular_pattern_remove_source","n.a.",1,"n.a.","影响 1 个样本(0.01%);归档运行记录为能力缺口。"],["拓扑来源 / 选择器","extrude_profile_topology:mid_cap_edge","n.a.",1,"n.a.","影响 1 个样本(0.01%);挤出 profile 的拓扑来源不具唯一/可用绑定。"],["拓扑来源 / 选择器","loft_profile_topology:cap_edge","n.a.",1,"n.a.","影响 1 个样本(0.01%);归档运行记录为能力缺口。"],["拓扑来源 / 选择器","loft_profile_topology:cap_vertex","n.a.",1,"n.a.","影响 1 个样本(0.01%);归档运行记录为能力缺口。"],["拓扑来源 / 选择器","shell_offset_face_selector","n.a.",1,"n.a.","影响 1 个样本(0.01%);依赖面/边/Body 选择器绑定。"],[null,null,null,null,null,null],["主要 runtime / rebuild 错误",null,null,null,null,null],["类别","能力 / 指标","上次","本次","变化","结论"],["运行时故障","ValueError: f_F2: selector_not_found after prefix rebuild","n.a.",327,"n.a.","选择器绑定失败;示例:00581751, 00084354, 00793553, 00693283, 00489012"],["运行时故障","RuntimeExecutionError: Failed creating a chamfer, try a smaller length value(s)","n.a.",192,"n.a.","倒角内核构造失败;示例:00349222, 00821712, 00747143, 00937537, 00922678"],["运行时故障","ValueError: f_F4: selector_not_found after prefix rebuild","n.a.",188,"n.a.","选择器绑定失败;示例:00328824, 00353546, 00906080, 00200997, 00557249"],["运行时故障","ValueError: f_F3: selector_not_found after prefix rebuild","n.a.",184,"n.a.","选择器绑定失败;示例:00282797, 00879806, 00529865, 00693284, 00653097"],["运行时故障","RuntimeExecutionError: BRep_API: command not done","n.a.",144,"n.a.","运行时 / 内核失败;示例:00690401, 00772261, 00770536, 00645703, 00403841"],["运行时故障","ValueError: f_F5: selector_not_found after prefix rebuild","n.a.",106,"n.a.","选择器绑定失败;示例:00584224, 00429745, 00886099, 00210339, 00262311"],["运行时故障","ValueError: f_F6: selector_not_found after prefix rebuild","n.a.",88,"n.a.","选择器绑定失败;示例:00163083, 00649535, 00041461, 00973073, 00743581"],["运行时故障","RuntimeExecutionError: OCC union operation produced an invalid shape","n.a.",67,"n.a.","布尔合并形体无效;示例:00040327, 00746835, 00273838, 00279950, 00588423"],["运行时故障","RuntimeExecutionError: Failed creating a fillet with radius of 5.0, try a smaller value or use max_fillet() to find the largest valid fillet radius","n.a.",56,"n.a.","圆角内核构造失败;示例:00790175, 00927539, 00393365, 00712315, 00258812"],["运行时故障","RuntimeExecutionError: OCC extrude operation did not produce a valid solid","n.a.",54,"n.a.","拉伸几何或拓扑不满足内核条件;示例:00091478, 00912612, 00280067, 00740958, 00478148"],["运行时故障","RuntimeExecutionError: extent target is not reached by the profile","n.a.",52,"n.a.","运行时 / 内核失败;示例:00527933, 00304645, 00346060, 00175329, 00614599"],["运行时故障","RuntimeExecutionError: extrude not supported for ","n.a.",52,"n.a.","拉伸几何或拓扑不满足内核条件;示例:00243209, 00962538, 00570834, 00995368, 00462646"]]} +{"kind":"region","sheet":"能力缺口","address":"A6:F45","rows":40,"cols":6,"nonEmpty":240,"text":240,"maxTextLength":51,"preview":[["类别","能力 / 指标","上次","本次","变化","结论"],["特征操作","fillet","n.a.","1084","n.a.","影响 1,084 个样本(11.60%);最大单项能力缺口。"],["特征操作","extrude","n.a.","899","n.a.","影响 899 个样本(9.62%);主操作缺口,相关 profile topology 子项另行统计。"],["特征操作","hole","n.a.","369","n.a.","影响 369 个样本(3.95%);归档运行记录为能力缺口。"],["特征操作","chamfer","n.a.","265","n.a.","影响 265 个样本(2.84%);归档运行记录为能力缺口。"]],"previewAddress":"A6:F10","previewRows":5,"previewCols":6} +{"kind":"region","sheet":"能力缺口","address":"A47:F60","rows":14,"cols":6,"nonEmpty":79,"blank":5,"text":79,"maxTextLength":147,"preview":[["主要 runtime / rebuild 错误",null,null,null,null,null],["类别","能力 / 指标","上次","本次","变化","结论"],["运行时故障","ValueError: f_F2: selector_not_found after prefix rebuild","n.a.","327","n.a.","选择器绑定失败;示例:00581751, 00084354, 00793553, 00693283, 00489012"],["运行时故障","RuntimeExecutionError: Failed creating a chamfer, try a smaller length value(s)","n.a.","192","n.a.","倒角内核构造失败;示例:00349222, 00821712, 00747143, 00937537, 00922678"],["运行时故障","ValueError: f_F4: selector_not_found after prefix rebuild","n.a.","188","n.a.","选择器绑定失败;示例:00328824, 00353546, 00906080, 00200997, 00557249"]],"previewAddress":"A47:F51","previewRows":5,"previewCols":6} +{"kind":"computedStyle","sheet":"能力缺口","for":"A2","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":16,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{},"wrapText":false,"horizontalAlignment":"general","styleId":2}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A3","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"italic":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"52616B"},"gradientStops":[],"pictureEffects":[]}},"border":{},"wrapText":false,"horizontalAlignment":"general","styleId":9}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A6","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B6","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C6","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D6","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E6","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F6","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":20}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":20}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":20}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":23}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":20}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F7","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":45}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A8","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B8","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C8","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D8","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E8","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F8","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A9","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B9","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C9","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D9","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E9","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F9","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A10","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B10","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C10","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D10","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E10","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F10","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A11","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B11","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C11","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D11","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E11","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F11","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A12","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B12","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C12","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D12","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E12","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F12","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A13","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B13","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C13","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D13","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E13","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F13","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A14","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B14","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C14","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D14","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E14","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F14","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A15","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B15","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C15","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D15","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E15","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F15","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A16","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B16","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C16","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D16","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E16","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F16","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A17","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B17","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C17","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D17","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E17","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F17","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A18","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B18","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C18","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D18","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E18","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F18","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A19","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B19","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C19","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D19","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E19","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F19","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A20","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B20","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C20","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D20","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E20","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F20","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A21","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B21","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C21","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D21","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E21","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F21","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A22","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B22","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C22","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D22","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E22","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F22","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A23","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B23","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C23","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D23","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E23","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F23","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A24","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B24","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C24","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D24","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E24","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F24","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A25","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B25","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C25","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D25","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E25","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F25","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A26","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B26","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C26","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D26","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E26","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F26","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A27","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B27","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C27","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D27","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E27","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F27","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A28","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B28","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C28","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D28","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E28","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F28","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A29","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B29","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C29","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D29","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E29","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F29","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A30","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B30","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C30","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D30","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E30","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F30","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A31","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B31","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C31","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D31","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E31","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F31","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A32","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B32","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C32","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D32","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E32","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F32","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A33","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B33","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C33","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D33","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E33","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F33","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A34","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B34","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C34","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D34","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E34","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F34","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A35","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B35","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C35","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D35","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E35","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F35","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A36","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B36","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C36","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D36","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E36","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F36","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A37","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B37","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C37","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D37","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E37","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F37","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A38","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B38","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C38","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D38","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E38","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F38","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A39","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B39","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C39","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D39","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E39","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F39","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A40","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B40","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C40","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D40","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E40","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F40","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A41","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B41","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C41","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D41","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E41","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F41","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A42","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B42","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C42","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D42","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E42","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F42","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A43","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B43","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C43","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D43","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E43","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F43","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A44","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B44","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C44","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D44","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E44","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F44","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A45","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":22}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B45","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":22}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C45","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":22}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D45","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":25}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E45","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":22}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F45","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":47}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A47","style":{"fill":{"type":1,"color":{"type":1,"value":"E8EFF4"},"gradientStops":[],"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"24435A"},"gradientStops":[],"pictureEffects":[]}},"border":{},"wrapText":false,"horizontalAlignment":"general","styleId":29}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A48","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B48","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C48","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D48","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E48","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":false,"horizontalAlignment":"center","styleId":15}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F48","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"bold":true,"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"1F2933"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"C9D2D9"}}},"wrapText":true,"horizontalAlignment":"center","styleId":49}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A49","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":20}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B49","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":20}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C49","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":20}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D49","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":23}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E49","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":20}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F49","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":45}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A50","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B50","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C50","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D50","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E50","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F50","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A51","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B51","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C51","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D51","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E51","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F51","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A52","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B52","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C52","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D52","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E52","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F52","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A53","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B53","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C53","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D53","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E53","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F53","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A54","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B54","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C54","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D54","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E54","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F54","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A55","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B55","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C55","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D55","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E55","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F55","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A56","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B56","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C56","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D56","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E56","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F56","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A57","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B57","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C57","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D57","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E57","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F57","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A58","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B58","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C58","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D58","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E58","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F58","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A59","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B59","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C59","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D59","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":24}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E59","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":21}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F59","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}},"bottom":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":46}} +{"kind":"computedStyle","sheet":"能力缺口","for":"A60","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":22}} +{"kind":"computedStyle","sheet":"能力缺口","for":"B60","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":22}} +{"kind":"computedStyle","sheet":"能力缺口","for":"C60","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":22}} +{"kind":"computedStyle","sheet":"能力缺口","for":"D60","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"numberFormat":"#,##0","wrapText":false,"horizontalAlignment":"general","styleId":25}} +{"kind":"computedStyle","sheet":"能力缺口","for":"E60","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":false,"horizontalAlignment":"general","styleId":22}} +{"kind":"computedStyle","sheet":"能力缺口","for":"F60","style":{"fill":{"type":3,"gradientStops":[],"pattern":{"patternType":1},"pictureEffects":[]},"font":{"fontSize":10,"typeface":"Arial","fill":{"type":0,"color":{"type":1,"value":"252C32"},"gradientStops":[],"pictureEffects":[]}},"border":{"top":{"style":"thin","color":{"type":1,"value":"E6EAED"}}},"wrapText":true,"horizontalAlignment":"general","styleId":47}} \ No newline at end of file diff --git a/outputs/cadfs_previous_run_analysis_20260910/verification.txt b/outputs/cadfs_previous_run_analysis_20260910/verification.txt new file mode 100644 index 00000000..8185ecfd --- /dev/null +++ b/outputs/cadfs_previous_run_analysis_20260910/verification.txt @@ -0,0 +1,3 @@ +{"kind":"table","sheet":"结果总览","address":"A2:F27","rows":26,"cols":6,"values":[["CADFS 到 CDSL 上一版全量运行基线",null,null,null,null,null],["归档运行时间","2026-09-09T22:42:52",null,"总样本",9347,null],[null,null,null,null,null,null],[null,null,null,null,null,null],["指标","本次(上一版全量运行)","上次归档","变化","占总样本","结论"],["生成候选 CDSL",8942,"n.a.","n.a.",0.9566705894939552,"转化完成或部分完成后保留 candidate CDSL。"],["绑定 CDSL",8364,"n.a.","n.a.",0.8948325665989088,"完成 selector/reference 绑定的工件数。"],["生成可执行 STEP",8176,"n.a.","n.a.",0.8747191612282016,"按工件存在计数;与最终重建状态分开呈现。"],["最终重建成功",6055,"n.a.","n.a.",0.6478014336150637,"运行时最终状态为 rebuilt。"],["完成几何比较",6007,"n.a.","n.a.",0.6426660960736065,"有 strict 或 RP 判定的比较报告。"],["RP 通过",2253,"n.a.","n.a.",0.24103990585214508,"严格通过与近似通过之和。"],["严格通过",989,"n.a.","n.a.",0.1058093505937734,"同时满足 strict 比较阈值。"],["转换完成",6232,"n.a.","n.a.",0.6667379907991869,"CDSL 转换无 deferred feature。"],["转换部分完成",2710,"n.a.","n.a.",0.2899325986947684,"保留可执行前缀,但存在 deferred feature。"],[null,null,null,null,null,null],["最终状态分布",null,null,null,null,null],["最终状态","样本数","占总样本","接受级别","说明",null],["rebuilt_strict",989,0.1058093505937734,"严格接受","严格比较通过。",null],["rebuilt_approximate",1264,0.13523055525837166,"RP 接受","工程相似通过,但未达到 strict。",null],["rebuilt_rejected",3754,0.40162619022146145,"未接受","已生成并比较,但未通过 RP。",null],["rebuild_failed",2413,0.25815769765700225,"未接受","最终 rebuild 失败;可执行 STEP 工件另按文件存在计数。",null],["runtime_ineligible",465,0.04974858243286616,"未接受","运行时判定不具备执行资格。",null],["deferred_no_executable_feature",396,0.0423665347170215,"未接受","没有可执行 feature。",null],["comparison_timeout",48,0.005135337541457152,"未接受","几何比较超过 60 秒。",null],["rebuild_timeout",9,0.000962875789023216,"未接受","rebuild 超时。",null],["parse_failed",9,0.000962875789023216,"未接受","解析或 lowering 失败。",null]]} +{"kind":"table","sheet":"能力缺口","address":"A2:F60","rows":59,"cols":6,"values":[["上一版能力缺口与运行时故障",null,null,null,null,null],["能力缺口按 affected sample 计数,跨能力项不互斥。",null,null,null,null,null],[null,null,null,null,null,null],[null,null,null,null,null,null],["类别","能力 / 指标","上次","本次","变化","结论"],["特征操作","fillet","n.a.",1084,"n.a.","影响 1,084 个样本(11.60%);最大单项能力缺口。"],["特征操作","extrude","n.a.",899,"n.a.","影响 899 个样本(9.62%);主操作缺口,相关 profile topology 子项另行统计。"],["特征操作","hole","n.a.",369,"n.a.","影响 369 个样本(3.95%);归档运行记录为能力缺口。"],["特征操作","chamfer","n.a.",265,"n.a.","影响 265 个样本(2.84%);归档运行记录为能力缺口。"],["特征操作","revolve","n.a.",200,"n.a.","影响 200 个样本(2.14%);归档运行记录为能力缺口。"],["拓扑来源 / 选择器","shell_face_selector","n.a.",158,"n.a.","影响 158 个样本(1.69%);依赖面/边/Body 选择器绑定。"],["特征操作","mirror","n.a.",140,"n.a.","影响 140 个样本(1.50%);归档运行记录为能力缺口。"],["特征操作","sweep","n.a.",122,"n.a.","影响 122 个样本(1.31%);归档运行记录为能力缺口。"],["拓扑来源 / 选择器","extrude_profile_topology:cap_face","n.a.",112,"n.a.","影响 112 个样本(1.20%);挤出 profile 的拓扑来源不具唯一/可用绑定。"],["拓扑来源 / 选择器","extrude_profile_topology:swept_face","n.a.",94,"n.a.","影响 94 个样本(1.01%);挤出 profile 的拓扑来源不具唯一/可用绑定。"],["拓扑来源 / 选择器","extrude_profile_topology:cap_edge","n.a.",88,"n.a.","影响 88 个样本(0.94%);挤出 profile 的拓扑来源不具唯一/可用绑定。"],["特征操作","cPlane","n.a.",69,"n.a.","影响 69 个样本(0.74%);归档运行记录为能力缺口。"],["特征操作","sweep_path","n.a.",68,"n.a.","影响 68 个样本(0.73%);归档运行记录为能力缺口。"],["特征操作","loft","n.a.",66,"n.a.","影响 66 个样本(0.71%);归档运行记录为能力缺口。"],["Body 生命周期 / Pattern","circularPattern","n.a.",51,"n.a.","影响 51 个样本(0.55%);归档运行记录为能力缺口。"],["特征操作","shell","n.a.",43,"n.a.","影响 43 个样本(0.46%);归档运行记录为能力缺口。"],["Body 生命周期 / Pattern","booleanBodies","n.a.",35,"n.a.","影响 35 个样本(0.37%);涉及多 Body 或生命周期语义。"],["拓扑来源 / 选择器","extrude_profile_topology:intersect","n.a.",33,"n.a.","影响 33 个样本(0.35%);挤出 profile 的拓扑来源不具唯一/可用绑定。"],["Body 生命周期 / Pattern","boolean_bodies_targets","n.a.",29,"n.a.","影响 29 个样本(0.31%);涉及多 Body 或生命周期语义。"],["Body 生命周期 / Pattern","transform_pattern_copy","n.a.",20,"n.a.","影响 20 个样本(0.21%);涉及多 Body 或生命周期语义。"],["拓扑来源 / 选择器","extrude_profile_topology:offset_face","n.a.",17,"n.a.","影响 17 个样本(0.18%);挤出 profile 的拓扑来源不具唯一/可用绑定。"],["拓扑来源 / 选择器","loft_profile_topology:cap_face","n.a.",17,"n.a.","影响 17 个样本(0.18%);归档运行记录为能力缺口。"],["Body 生命周期 / Pattern","deleteBodies","n.a.",16,"n.a.","影响 16 个样本(0.17%);涉及多 Body 或生命周期语义。"],["特征操作","extrude_draft_extent","n.a.",16,"n.a.","影响 16 个样本(0.17%);归档运行记录为能力缺口。"],["拓扑来源 / 选择器","extrude_profile_topology:swept_edge","n.a.",15,"n.a.","影响 15 个样本(0.16%);挤出 profile 的拓扑来源不具唯一/可用绑定。"],["Body 生命周期 / Pattern","transform","n.a.",14,"n.a.","影响 14 个样本(0.15%);涉及多 Body 或生命周期语义。"],["拓扑来源 / 选择器","loft_profile_topology:swept_face","n.a.",9,"n.a.","影响 9 个样本(0.10%);归档运行记录为能力缺口。"],["拓扑来源 / 选择器","transform_body_query","n.a.",9,"n.a.","影响 9 个样本(0.10%);依赖面/边/Body 选择器绑定。"],["特征操作","sweep_remove","n.a.",6,"n.a.","影响 6 个样本(0.06%);归档运行记录为能力缺口。"],["拓扑来源 / 选择器","transform_translation_direction","n.a.",6,"n.a.","影响 6 个样本(0.06%);涉及多 Body 或生命周期语义。"],["Body 生命周期 / Pattern","delete_bodies","n.a.",3,"n.a.","影响 3 个样本(0.03%);涉及多 Body 或生命周期语义。"],["拓扑来源 / 选择器","extrude_cap_edge_profile","n.a.",2,"n.a.","影响 2 个样本(0.02%);归档运行记录为能力缺口。"],["Body 生命周期 / Pattern","transform_body_lifecycle","n.a.",2,"n.a.","影响 2 个样本(0.02%);涉及多 Body 或生命周期语义。"],["拓扑来源 / 选择器","transform_translation_entity","n.a.",2,"n.a.","影响 2 个样本(0.02%);涉及多 Body 或生命周期语义。"],["Body 生命周期 / Pattern","circular_pattern_remove_source","n.a.",1,"n.a.","影响 1 个样本(0.01%);归档运行记录为能力缺口。"],["拓扑来源 / 选择器","extrude_profile_topology:mid_cap_edge","n.a.",1,"n.a.","影响 1 个样本(0.01%);挤出 profile 的拓扑来源不具唯一/可用绑定。"],["拓扑来源 / 选择器","loft_profile_topology:cap_edge","n.a.",1,"n.a.","影响 1 个样本(0.01%);归档运行记录为能力缺口。"],["拓扑来源 / 选择器","loft_profile_topology:cap_vertex","n.a.",1,"n.a.","影响 1 个样本(0.01%);归档运行记录为能力缺口。"],["拓扑来源 / 选择器","shell_offset_face_selector","n.a.",1,"n.a.","影响 1 个样本(0.01%);依赖面/边/Body 选择器绑定。"],[null,null,null,null,null,null],["主要 runtime / rebuild 错误",null,null,null,null,null],["类别","能力 / 指标","上次","本次","变化","结论"],["运行时故障","ValueError: f_F2: selector_not_found after prefix rebuild","n.a.",327,"n.a.","选择器绑定失败;示例:00581751, 00084354, 00793553, 00693283, 00489012"],["运行时故障","RuntimeExecutionError: Failed creating a chamfer, try a smaller length value(s)","n.a.",192,"n.a.","倒角内核构造失败;示例:00349222, 00821712, 00747143, 00937537, 00922678"],["运行时故障","ValueError: f_F4: selector_not_found after prefix rebuild","n.a.",188,"n.a.","选择器绑定失败;示例:00328824, 00353546, 00906080, 00200997, 00557249"],["运行时故障","ValueError: f_F3: selector_not_found after prefix rebuild","n.a.",184,"n.a.","选择器绑定失败;示例:00282797, 00879806, 00529865, 00693284, 00653097"],["运行时故障","RuntimeExecutionError: BRep_API: command not done","n.a.",144,"n.a.","运行时 / 内核失败;示例:00690401, 00772261, 00770536, 00645703, 00403841"],["运行时故障","ValueError: f_F5: selector_not_found after prefix rebuild","n.a.",106,"n.a.","选择器绑定失败;示例:00584224, 00429745, 00886099, 00210339, 00262311"],["运行时故障","ValueError: f_F6: selector_not_found after prefix rebuild","n.a.",88,"n.a.","选择器绑定失败;示例:00163083, 00649535, 00041461, 00973073, 00743581"],["运行时故障","RuntimeExecutionError: OCC union operation produced an invalid shape","n.a.",67,"n.a.","布尔合并形体无效;示例:00040327, 00746835, 00273838, 00279950, 00588423"],["运行时故障","RuntimeExecutionError: Failed creating a fillet with radius of 5.0, try a smaller value or use max_fillet() to find the largest valid fillet radius","n.a.",56,"n.a.","圆角内核构造失败;示例:00790175, 00927539, 00393365, 00712315, 00258812"],["运行时故障","RuntimeExecutionError: OCC extrude operation did not produce a valid solid","n.a.",54,"n.a.","拉伸几何或拓扑不满足内核条件;示例:00091478, 00912612, 00280067, 00740958, 00478148"],["运行时故障","RuntimeExecutionError: extent target is not reached by the profile","n.a.",52,"n.a.","运行时 / 内核失败;示例:00527933, 00304645, 00346060, 00175329, 00614599"],["运行时故障","RuntimeExecutionError: extrude not supported for ","n.a.",52,"n.a.","拉伸几何或拓扑不满足内核条件;示例:00243209, 00962538, 00570834, 00995368, 00462646"]]} +{"kind":"notice","message":"Cell search matched 0 entries."} \ No newline at end of file