diff --git a/backend/app/cad_agent/domain/operation_contract.py b/backend/app/cad_agent/domain/operation_contract.py index 1b37029a..36c41f33 100644 --- a/backend/app/cad_agent/domain/operation_contract.py +++ b/backend/app/cad_agent/domain/operation_contract.py @@ -47,6 +47,7 @@ def validate_operation_contract(contract: dict[str, Any]) -> None: required = { "atomic_id", "contract_version", "fragment_shape", "author_params_schema", "selector_policy", "server_injected_paths", "reference_policy", "semantic_preflight", "candidate_verifiers", + "runtime_capability", } if not required.issubset(contract) or set(contract) - required - {"contract_hash", "registry_revision"}: raise OperationContractError("Operation contract has unknown or missing fields") @@ -126,3 +127,13 @@ def validate_operation_contract(contract: dict[str, Any]) -> None: raise OperationContractError("Operation candidate_verifiers are invalid") if len(set(contract["candidate_verifiers"])) != len(contract["candidate_verifiers"]): raise OperationContractError("Operation candidate verifiers are duplicated") + runtime_capability = contract["runtime_capability"] + required_capability_flags = { + "body_mutating", "requires_active_body", "replayable", "requires_selector", "open_profile_ok", + } + if ( + not isinstance(runtime_capability, dict) + or set(runtime_capability) != required_capability_flags + or not all(isinstance(value, bool) for value in runtime_capability.values()) + ): + raise OperationContractError("Operation runtime capability is invalid") diff --git a/backend/engine/cdsl_engine/README.md b/backend/engine/cdsl_engine/README.md index fe1789aa..9980ef70 100644 --- a/backend/engine/cdsl_engine/README.md +++ b/backend/engine/cdsl_engine/README.md @@ -4,17 +4,53 @@ This package rebuilds `cad.cdsl.llm.v1` models through the CDSL-only path: `semantic validation -> capability analysis -> sketch resolution -> session runtime -> STEP` -`runtime.py` owns the executor registry, an `ExecutionSession`, and the -feature/topology lifecycle. `build123d_adapter.py` is the only layer that -creates or mutates B-rep objects. `runtime_types.py` owns runtime-neutral -feature, context, selector, and topology contracts. `llm_compiler.py` and -`llm_engine.py` remain available for legacy engine-plan compatibility but are -not used by `run_cdsl_only`. +## Package layout + +| Module | Responsibility | +|---|---| +| `specs.py` | Vector math, plane/axis helpers, parametric feature specs (no kernel deps) | +| `topology.py` | Diagnostics, planning contracts, `TopologyRegistry`, selector resolution | +| `capabilities.py` | `CapabilityAnalyzer`: preflight blockers before any geometry runs | +| `sketch_solver.py` | Profile expansion (circle / polygon / analytic contours) | +| `session.py` | `ExecutionSession` and the kernel-facing `GeometryAdapter` protocol | +| `runtime_base.py` | Shared error types and the `ExtentVector` value | +| `extents.py` | End-condition planning (blind / through / up-to-surface / ...) | +| `pattern_transform.py` | Translate/mirror/rotate parameter algebra for pattern replay | +| `registry.py` | `EXECUTORS`, the `atomic_executor` decorator, and `execute_node` dispatch | +| `executors/` | One module per executor family; importing the package registers all | +| `build123d_adapter.py` | The only layer that creates or mutates B-rep objects | +| `topology.py` / `runtime_types.py` | Historical re-export shim (`runtime_types`) | +| `translator/` | Frozen SolidWorks-exact code generation (`ir` / `codegen` / `runtime_lib`) | +| `legacy/` | Frozen legacy engine paths (`llm_compiler`, `llm_engine`, exact rebuilds) | +| `rebuild.py` | Legacy three-way facade plus `compare_with_gold` acceptance | + +`runtime.py` keeps the `analyze_cdsl` / `rebuild_cdsl` entry points and +re-exports the historical names. `llm_compiler.py` and `llm_engine.py` are +compatibility shims for the frozen `legacy/` implementations and are not used +by `run_cdsl_only`. + +## Adding an atomic operation + +1. Add the operation contract to `profile_schema.json` (`operation_contracts`), + including its `runtime_capability` flags — this is the single source of + truth for preflight classification. +2. Add a decorated executor function in exactly one `executors/.py` + module (`@atomic_executor("...")`); the shared registry never changes. +3. Add the atomic id to `ALL_ATOMIC_IDS` in `registry.py` (registration fails + fast on unknown or duplicate ids, and `executors/__init__` fails if any + declared id has no registered executor). +4. Update `cdsl_schema.json` in the same change. +5. Extend `backend/tests/test_profile_schema.py` fixtures if the contract + shape changed. + +Multiple people can add different operations in parallel without touching a +shared registry file: the only shared edits are the two schema documents. Supported profiles are defined by `SHAPE_GENERATORS` in `sketch_solver.py`. -Supported feature atomic operations are defined by `EXECUTORS` in `runtime.py`. -Their human-readable contract is in `profile_schema.json`; the complete, -machine-enforced CDSL object contract is in `cdsl_schema.json`. +Supported feature atomic operations are defined by `EXECUTORS` (populated from +`executors/` at import time). Their human-readable contract is in +`profile_schema.json`; the complete, machine-enforced CDSL object contract is +in `cdsl_schema.json`. The Studio only accepts self-contained profile data and requires successful `engine=cdsl_only` output. It never uses the legacy translator fallback or `compiler_context`. @@ -24,10 +60,11 @@ The Studio only accepts self-contained profile data and requires successful `profile_schema.json` and `cdsl_schema.json` together are the source of truth for the engine contract exposed to the CAD Agent and the backend validator. Any addition, removal, rename, or parameter-contract change in -`sketch_solver.py`, `runtime.py`, or the build adapter must update both files +`sketch_solver.py`, `executors/`, or the build adapter must update both files in the same change. -`backend/tests/test_profile_schema.py` fails when the registered profiles or -supported atomic operations diverge from the document. +`backend/tests/test_profile_schema.py` fails when the registered profiles, +supported atomic operations, or `runtime_capability` flags diverge from the +document. ## Batch baseline diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 1daa9c0f..7d3d8086 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -33,6 +33,11 @@ from .runtime_types import ( TopologyDelta, TopologyDeltaRelation, TopologyRecord, Vector3, canonical_plane_signature, ) +from .topology_export import ( + body_geometry as _body_geometry_impl, + surface_geometry as _surface_geometry_impl, + topology_records as _topology_records_impl, +) def _vector(value: list[float] | tuple[float, float, float]) -> Vector: @@ -1971,294 +1976,15 @@ class Build123dGeometryAdapter: @staticmethod def body_geometry(body: Any) -> dict[str, Any]: - # 汇总主体基本几何信息:包围盒与体积。 - bbox = body.bounding_box() - # A feature history can contain several body IDs while still ending in - # one connected solid (for example, a base extrusion followed by hole - # cuts). Count the current OCC result, never feature history entries. - solids = list(body.solids()) if hasattr(body, "solids") else [body] - return { - "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], - "volume_mm3": float(body.volume), - "solid_count": len(solids), - } + # 汇总主体基本几何信息:包围盒与体积(实现见 topology_export)。 + return _body_geometry_impl(body) @staticmethod def surface_geometry(surface: Any) -> dict[str, Any]: - # 曲面结果不参与实体 body 聚合;只保存后续 selector 所需的独立拓扑摘要。 - bbox = surface.bounding_box() - return { - "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], - "area_mm2": float(surface.area), - "face_count": len(surface.faces()), - } + # 曲面结果的独立拓扑摘要(实现见 topology_export)。 + return _surface_geometry_impl(surface) @staticmethod def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: - # 从主体导出全部面/边/顶点拓扑记录,供后续特征选择与引用。 - records: list[TopologyRecord] = [] - faces = list(body.faces()) - edges = list(body.edges()) - vertices = list(body.vertices()) - - def index_for(shape: Any, candidates: list[Any]) -> int | None: - """Map a subshape returned by a face/edge back to body topology.""" - # 用 is_same 把面/边的子形状映射回主体拓扑列表的下标。 - for index, candidate in enumerate(candidates): - if shape.is_same(candidate): - return index - return None - - # 1. 建立邻接索引:每条边关联的面集合(edge_faces)。 - edge_faces: list[set[int]] = [set() for _edge in edges] - for face_index, face in enumerate(faces): - for edge in face.edges(): - edge_index = index_for(edge, edges) - if edge_index is not None: - edge_faces[edge_index].add(face_index) - # 2. 建立邻接索引:每个顶点关联的边集合(vertex_edges)。 - vertex_edges: list[set[int]] = [set() for _vertex in vertices] - for edge_index, edge in enumerate(edges): - for vertex in edge.vertices(): - vertex_index = index_for(vertex, vertices) - if vertex_index is not None: - vertex_edges[vertex_index].add(edge_index) - - def edge_signature(edge_index: int) -> str: - # 边的特征签名:几何类型 + 长度 + 相邻面数,用作面邻接指纹。 - edge = edges[edge_index] - return ":".join(( - str(edge.geom_type).split(".")[-1].lower(), - f"{float(edge.length):.6f}", - str(len(edge_faces[edge_index])), - )) - - # 3. 导出面记录:含包围盒、中心、法向、面积、曲面类型与邻接签名; - # 平面面额外写入规范化法向与平面偏移,便于后续按平面匹配。 - # 圆柱面还保存轴、半径和共享边关联的平面面。这使 verifier 能从 - # 实际 B-rep 证明孔是否连接两个方向相反的外部平面,而不是根据 - # author 传入的 blind-depth 文字猜测“贯穿”。 - face_edge_indexes: list[set[int]] = [] - face_geometries: list[dict[str, Any]] = [] - for index, face in enumerate(faces): - bbox = face.bounding_box() - center = face.center() - normal = face.normal_at() - boundary_edge_indexes = [ - edge_index - for edge in face.edges() - if (edge_index := index_for(edge, edges)) is not None - ] - geometry = { - "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], - "center_mm": [center.X, center.Y, center.Z], "normal": [normal.X, normal.Y, normal.Z], - "area_mm2": float(face.area), "surface_type": str(face.geom_type).split(".")[-1].lower(), - "adjacency_signature": sorted(edge_signature(edge_index) for edge_index in boundary_edge_indexes), - } - if geometry["surface_type"] == "plane": - plane_normal, plane_offset = canonical_plane_signature( - (normal.X, normal.Y, normal.Z), (center.X, center.Y, center.Z), - ) - geometry["plane_normal"] = list(plane_normal) - geometry["plane_offset_mm"] = plane_offset - boundary_loops: list[list[list[float]]] = [] - for wire in face.wires(): - samples: list[list[float]] = [] - for edge in wire.edges(): - curve_type = str(edge.geom_type).split(".")[-1].lower() - fractions = [step / 16 for step in range(16)] if curve_type in {"circle", "ellipse"} else [0.0] - for fraction in fractions: - point = edge.position_at(fraction) - value = [float(point.X), float(point.Y), float(point.Z)] - if not samples or sum((value[axis] - samples[-1][axis]) ** 2 for axis in range(3)) > 1e-12: - samples.append(value) - if len(samples) >= 3: - boundary_loops.append(samples) - if boundary_loops: - geometry["boundary_loops_mm"] = boundary_loops - elif geometry["surface_type"] in {"cylinder", "cone"}: - axis = face.axis_of_rotation - if axis is None: - # Build123d can omit this optional OCC property for valid - # swept rotational faces. Keep their generic B-rep record - # so a selector-free workflow remains executable; do not - # invent axis/radius evidence for an axis-based selector. - pass - else: - direction = axis.direction - origin = axis.position - geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z] - geometry["axis_direction"] = [direction.X, direction.Y, direction.Z] - raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None - if geometry["surface_type"] == "cone": - boundary_radii: list[float] = [] - for edge in face.edges(): - if str(edge.geom_type).split(".")[-1].lower() != "circle": - continue - try: - boundary_radii.append(float(edge.radius)) - except ValueError: - continue - geometry["boundary_radii_mm"] = sorted(boundary_radii) - geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None - # ``through`` alone describes a cylinder spanning two opposed - # planar faces. That applies to both a through bore and the - # outside wall of a cylindrical extrusion. Classify the B-rep - # face by its oriented normal so downstream acceptance claims - # can prove holes without mistaking an exterior wall for one. - unit_axis = (direction.X, direction.Y, direction.Z) - radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z) - axial_projection = sum(radial[component] * unit_axis[component] for component in range(3)) - radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3)) - radial_length = sum(component * component for component in radial) ** 0.5 - if geometry["surface_type"] == "cylinder": - # OCC can report a cylinder surface with ``radius=None`` - # after a non-planar-side Boolean cut. The face centre is - # still on that cylinder, so its perpendicular distance to - # the rotation axis is an equivalent measured radius. Do - # not fail an otherwise valid build merely because that - # optional OCC convenience property is absent. - if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)): - geometry["radius_mm"] = float(raw_cylinder_radius) - elif radial_length > 1e-9: - geometry["radius_mm"] = radial_length - if radial_length > 1e-9: - normal_components = (normal.X, normal.Y, normal.Z) - alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length - geometry["radial_normal_alignment"] = alignment - geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown" - else: - geometry["cylinder_role"] = "unknown" - face_edge_indexes.append(set(boundary_edge_indexes)) - face_geometries.append(geometry) - records.append(TopologyRecord( - record_id=f"{body_id}:face:{index}", kind="face", feature_id=feature_id, body_id=body_id, value=face, - geometry=geometry, - )) - plane_indexes = [index for index, geometry in enumerate(face_geometries) if geometry["surface_type"] == "plane"] - - def directly_linked_planes(face_index: int) -> list[int]: - return [ - plane_index - for plane_index in plane_indexes - if face_edge_indexes[face_index].intersection(face_edge_indexes[plane_index]) - ] - - def same_inner_rotational_channel(first: int, second: int) -> bool: - """Whether two inner rotational faces share one B-rep bore channel.""" - if not face_edge_indexes[first].intersection(face_edge_indexes[second]): - return False - left, right = face_geometries[first], face_geometries[second] - if left.get("cylinder_role") != "inner" or right.get("cylinder_role") != "inner": - return False - left_axis, right_axis = left.get("axis_direction"), right.get("axis_direction") - left_origin, right_origin = left.get("axis_origin_mm"), right.get("axis_origin_mm") - if not all(isinstance(value, list) and len(value) == 3 for value in (left_axis, right_axis, left_origin, right_origin)): - return False - try: - left_direction = tuple(float(value) for value in left_axis) - right_direction = tuple(float(value) for value in right_axis) - offset = tuple(float(left_origin[index]) - float(right_origin[index]) for index in range(3)) - except (TypeError, ValueError): - return False - alignment = sum(left_direction[index] * right_direction[index] for index in range(3)) - if abs(alignment) < 1.0 - 1e-6: - return False - axial_offset = sum(offset[index] * left_direction[index] for index in range(3)) - radial_offset = tuple(offset[index] - axial_offset * left_direction[index] for index in range(3)) - return sum(value * value for value in radial_offset) ** 0.5 <= 1e-5 - - inner_rotational_indexes = [ - index - for index, geometry in enumerate(face_geometries) - if geometry["surface_type"] in {"cylinder", "cone"} and geometry.get("cylinder_role") == "inner" - ] - - def channel_plane_indexes(start: int) -> list[int]: - """Collect endpoint planes through joined, co-axial inner faces. - - A countersink or counterbore splits a physical bore into a cone and - a cylinder. The cylinder has only one direct planar neighbour, so - direct adjacency alone cannot prove that the complete channel exits - the part. Traverse shared B-rep edges only across co-axial inner - rotational faces, then inspect the channel's actual plane ends. - """ - pending = [start] - visited: set[int] = set() - endpoints: set[int] = set() - while pending: - index = pending.pop() - if index in visited: - continue - visited.add(index) - endpoints.update(directly_linked_planes(index)) - pending.extend( - candidate - for candidate in inner_rotational_indexes - if candidate not in visited and same_inner_rotational_channel(index, candidate) - ) - return sorted(endpoints) - - def spans_opposed_planes(linked: list[int], axis: Any) -> bool: - if not isinstance(axis, list) or len(axis) != 3: - return False - try: - direction = tuple(float(value) for value in axis) - except (TypeError, ValueError): - return False - return any( - sum(float(face_geometries[first]["normal"][component]) * float(face_geometries[second]["normal"][component]) for component in range(3)) <= -0.99 - and all(abs(sum(float(face_geometries[position]["normal"][component]) * direction[component] for component in range(3))) >= 0.99 for position in (first, second)) - for first in linked - for second in linked - if first < second - ) - - for index, geometry in enumerate(face_geometries): - if geometry["surface_type"] != "cylinder": - continue - linked = directly_linked_planes(index) - geometry["connected_plane_ids"] = [records[plane_index].record_id for plane_index in linked] - channel_linked = channel_plane_indexes(index) if geometry.get("cylinder_role") == "inner" else linked - geometry["channel_connected_plane_ids"] = [records[plane_index].record_id for plane_index in channel_linked] - geometry["through"] = spans_opposed_planes(channel_linked, geometry.get("axis_direction")) - # 4. 导出边记录:含包围盒、中心、长度、曲线类型与相邻面数;端点坐标可用时附加。 - for index, edge in enumerate(edges): - bbox = edge.bounding_box() - center = edge.center() - vertices = edge.vertices() - geometry = { - "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], - "center_mm": [center.X, center.Y, center.Z], "length_mm": float(edge.length), - "curve_type": str(edge.geom_type).split(".")[-1].lower(), - "adjacent_face_count": len(edge_faces[index]), - } - if geometry["curve_type"] == "circle": - # ``Edge.center()`` is a point on a periodic circle, not its - # geometric centre. Preserve the OCC circle data separately - # so a provenance-backed rotational selector can distinguish - # concentric full circles at different axial locations. - try: - circle_center = edge.arc_center - radius = float(edge.radius) - values = (circle_center.X, circle_center.Y, circle_center.Z, radius) - except (AttributeError, TypeError, ValueError): - values = () - if values and all(math.isfinite(float(value)) for value in values) and radius > 0: - geometry["circle_center_mm"] = [circle_center.X, circle_center.Y, circle_center.Z] - geometry["radius_mm"] = radius - if vertices: - geometry["start_mm"] = list(vertices[0]) - geometry["end_mm"] = list(vertices[-1]) - records.append(TopologyRecord( - record_id=f"{body_id}:edge:{index}", kind="edge", feature_id=feature_id, body_id=body_id, value=edge, - geometry=geometry, - )) - # 5. 导出顶点记录:含坐标与关联边数。 - for index, vertex in enumerate(vertices): - point = [vertex.X, vertex.Y, vertex.Z] - records.append(TopologyRecord( - record_id=f"{body_id}:vertex:{index}", kind="vertex", feature_id=feature_id, body_id=body_id, value=vertex, - geometry={"center_mm": point, "incident_edge_count": len(vertex_edges[index])}, - )) - return records + # 从主体导出全部面/边/顶点拓扑记录(实现见 topology_export)。 + return _topology_records_impl(body, feature_id, body_id) diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index fe09e454..67c664f1 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -19,31 +19,8 @@ from .runtime_types import ( from .operation_contracts import materialized_feature_contracts -_SELECTOR_REQUIRED = frozenset({"extrude_add_blind_with_hole", "extrude_from_face", "loft_add_with_cap_face", "fillet", "chamfer", "shell"}) _SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_") -# 开放轮廓(closed=false / role=open)只有"刀具截面补槽口边闭合后作切除"的 -# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。 -_OPEN_PROFILE_ATOMICS = frozenset({"extrude_cut_blind", "extrude_cut_through"}) -_PRIMARY_ATOMICS = frozenset({ - "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face", "extrude_surface", - "extrude_cut_through", - "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", - "hole_counterbore", "sphere_add", "box_add", "cylinder_add", -}) _HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"}) -_ACTIVE_BODY_REQUIRED = frozenset({ - "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", - "loft_add_with_cap_face", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer", "shell", - # thread_cut 是 cut 型特征:必须在已有主体(宿主)上做布尔差,不能凭空 - # 造实体;无宿主时按 active_body 前置阻止而非让 executor 在 None 上崩溃。 - "thread_cut", -}) -_BODY_MUTATING_ATOMICS = frozenset({ - "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face", - "extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", - "revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add", - "thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", *_HOLE_ATOMICS, "fillet", "chamfer", "shell", -}) # ``ExecutionSession.body_members`` only contains independently selectable # body outputs. A normal additive/cut/dress-up feature replaces the active # aggregate, while ``result_mode: new_body`` and ``keep_tools`` are the two @@ -51,14 +28,6 @@ _BODY_MUTATING_ATOMICS = frozenset({ # members can additionally expose a proven COPY instance; replayed/fused # patterns remain ineligible because no exact instance ownership exists. _PATTERN_ATOMICS = frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"}) -# A pattern may replay a previous pattern as well as a direct body mutation. -# Context-only features have no geometry definition to instance. thread_add, -# thread_cut, bend_add, gear_add and rack_add are excluded: pattern -# translation does not yet move their parametric axis/frame, so a replayed -# instance would silently re-run at the original location. -_REPLAYABLE_ATOMICS = ( - _BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut", "bend_add", "gear_add", "rack_add"}) -) | frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"}) _SUPPORTED_EXTENTS = frozenset({ "blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind", "up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body", @@ -71,6 +40,39 @@ _EXTENT_TARGET_KINDS = { } +def _runtime_capabilities() -> dict[str, dict[str, bool]]: + """Return the per-atomic runtime capability flags from the schema registry. + + ``profile_schema.json.operation_contracts[*].runtime_capability`` is the + single source of truth for these classifications. Adding an atomic + operation updates one JSON contract instead of editing code-level sets. + """ + path = Path(__file__).with_name("profile_schema.json") + contracts = materialized_feature_contracts(json.loads(path.read_text(encoding="utf-8"))) + return {atomic_id: contract["runtime_capability"] for atomic_id, contract in contracts.items()} + + +_RUNTIME_CAPABILITIES = _runtime_capabilities() +_SELECTOR_REQUIRED = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["requires_selector"]) +# 开放轮廓(closed=false / role=open)只有"刀具截面补槽口边闭合后作切除"的 +# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。 +_OPEN_PROFILE_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["open_profile_ok"]) +_PRIMARY_ATOMICS = frozenset({ + "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face", "extrude_surface", + "extrude_cut_through", + "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", + "hole_counterbore", "sphere_add", "box_add", "cylinder_add", +}) +_ACTIVE_BODY_REQUIRED = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["requires_active_body"]) +_BODY_MUTATING_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["body_mutating"]) +# A pattern may replay a previous pattern as well as a direct body mutation. +# Context-only features have no geometry definition to instance. thread_add, +# thread_cut, bend_add, gear_add and rack_add are excluded: pattern +# translation does not yet move their parametric axis/frame, so a replayed +# instance would silently re-run at the original location. +_REPLAYABLE_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["replayable"]) + + def _mappings(value: Any): """Yield nested feature mappings for capability-only contract checks.""" if isinstance(value, dict): diff --git a/backend/engine/cdsl_engine/executors/__init__.py b/backend/engine/cdsl_engine/executors/__init__.py new file mode 100644 index 00000000..ef3cbcf8 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/__init__.py @@ -0,0 +1,28 @@ +"""Executor modules for the session runtime. + +Importing this package registers every atomic executor into +``cdsl_engine.registry.EXECUTORS`` exactly once, then verifies that the +registry covers the complete declared atomic-id set. Executor modules must +not import each other; shared helpers live in ``common``. +""" + +from __future__ import annotations + +from ..registry import ALL_ATOMIC_IDS, EXECUTORS +from . import ( # noqa: F401 (importing the modules performs registration) + bodies, + context, + dressup, + extrude, + holes, + loft_sweep, + parametric, + patterns, + primitives, + revolve, + surfaces, +) + +_missing = sorted(ALL_ATOMIC_IDS - set(EXECUTORS)) +if _missing: + raise RuntimeError(f"atomic ids without a registered executor: {_missing}") diff --git a/backend/engine/cdsl_engine/executors/bodies.py b/backend/engine/cdsl_engine/executors/bodies.py new file mode 100644 index 00000000..a2b1e17a --- /dev/null +++ b/backend/engine/cdsl_engine/executors/bodies.py @@ -0,0 +1,124 @@ +"""Body-graph executors (boolean_bodies / transform_bodies / delete_bodies). + +These operate on explicitly named body members instead of the aggregate +session body, so adjacent independent solids never accidentally become tools +or targets of one another. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..specs import transform_copy_member_id +from ..topology import FeaturePlanNode, FeatureResult, TopologyDelta +from .common import _combine_members, _member_sources + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # booleanBodies 总是作用于 source feature 的明确 body 输出,不能回退为 + # 当前聚合 body。这样相邻独立实体不会意外成为工具或目标。 + params = node.params + target_ids = _member_sources( + node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs", + ) + tool_ids = _member_sources( + node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs", + ) + targets = {feature_id: session.body_members[feature_id] for feature_id in target_ids} + tools = {feature_id: session.body_members[feature_id] for feature_id in tool_ids} + target = _combine_members(session, targets) + tool = _combine_members(session, tools) + operation = str(params.get("operation") or "") + topology_delta: TopologyDelta | None = None + if operation == "union": + result, topology_delta = session.adapter.fuse_with_topology_delta(target, tool) + elif operation == "subtract": + result, topology_delta = session.adapter.cut_with_topology_delta(target, tool) + elif operation == "intersect": + result, topology_delta = session.adapter.intersect_with_topology_delta(target, tool) + else: + raise ValueError(f"unsupported booleanBodies operation {operation!r}") + members = { + feature_id: body + for feature_id, body in session.body_members.items() + if feature_id not in set(target_ids + tool_ids) + } + members[node.feature_id] = result + if bool(params.get("keep_tools")): + members.update(tools) + session.register_body( + node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta, + ) + return session.result(node) + + +@atomic_executor("boolean_bodies") +def _boolean_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_boolean_bodies(node, session) + + +def _execute_transform_bodies(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # FeatureScript transform targets explicit bodies. Do not move the + # aggregate session body, because it may include unrelated members. + source_ids = _member_sources( + node, session, "source_feature_ids", pattern_instance_parameter="pattern_instance_refs", allow_transform_copies=True, + ) + make_copy = bool(node.params.get("make_copy")) + direct_sources = node.params.get("source_feature_ids") or [] + if make_copy and isinstance(direct_sources, list) and len(direct_sources) > 1: + # The aggregate is only an export compound. Each source transform has + # its own B-rep builder and is the only output a later COPY query may + # select. Do not attach an aggregate topology delta to source members. + members = dict(session.body_members) + members.update({ + transform_copy_member_id(node.feature_id, source_id): session.adapter.transform( + session.body_members[source_id], dict(node.params.get("transform") or {}), + ) + for source_id in source_ids + }) + session.register_body( + node.feature_id, _combine_members(session, members), body_members=members, + ) + return session.result(node) + source = _combine_members(session, {feature_id: session.body_members[feature_id] for feature_id in source_ids}) + transformed, topology_delta = session.adapter.transform_with_topology_delta( + source, dict(node.params.get("transform") or {}), + ) + members = dict(session.body_members) + if not make_copy: + for feature_id in source_ids: + members.pop(feature_id) + members[node.feature_id] = transformed + session.register_body( + node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta, + ) + return session.result(node) + + +@atomic_executor("transform_bodies") +def _transform_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_transform_bodies(node, session) + + +def _execute_delete_bodies(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # Deletion is a body-graph operation, never a Boolean subtraction. A + # selected member can be disjoint or overlap another independent body. + source_ids = _member_sources(node, session, "target_feature_ids") + members = {feature_id: body for feature_id, body in session.body_members.items() if feature_id not in set(source_ids)} + if members: + session.register_body(node.feature_id, _combine_members(session, members), body_members=members) + else: + session.clear_body() + return session.result(node) + + +@atomic_executor("delete_bodies") +def _delete_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_delete_bodies(node, session) diff --git a/backend/engine/cdsl_engine/executors/common.py b/backend/engine/cdsl_engine/executors/common.py new file mode 100644 index 00000000..22e738b7 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/common.py @@ -0,0 +1,542 @@ +"""Helpers shared by executor family modules. + +Every function here is imported by two or more executor modules. Anything +used by exactly one family lives in that family's module instead. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any, Callable + +from ..extents import _extent_vectors_from_normal, _normal_from_sketch +from ..runtime_base import ExtentVector, FeatureExecutionError +from ..specs import AxisSpec, HoleSpec, PlaneSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit +from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta, TopologyRecord + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +def _revolve_axis(node: FeaturePlanNode, session: "ExecutionSession") -> AxisSpec: + raw_axis = node.params.get("axis") or {} + if raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None: + return AxisSpec.from_mapping(raw_axis) + selector = raw_axis.get("selector") if isinstance(raw_axis, dict) else None + if not isinstance(selector, dict): + selector = next((item for item in node.selectors if item.get("kind") == "axis"), None) + if not isinstance(selector, dict): + raise FeatureExecutionError( + "missing_revolve_axis", + "Revolve requires an explicit axis or an owner-qualified reference-axis selector", + ) + resolution = session.resolve(selector) + if resolution.status != "resolved" or resolution.record is None: + raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "revolve axis was not resolved") + if not isinstance(resolution.record.value, AxisSpec): + raise FeatureExecutionError( + "unsupported_revolve_axis", "The resolved context is not an axis", actual_kind=resolution.record.kind, + ) + return resolution.record.value + + +def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any]) -> None: + """Defend direct CDSL execution from an out-of-plane revolve axis.""" + plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) + direction_normal_dot = abs(vector_dot(axis.direction, plane.normal)) + if direction_normal_dot > 1e-7: + raise ValueError( + "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.direction must be parallel to " + f"sketch.workplane; abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}" + ) + origin_plane_offset = abs(vector_dot(vector_subtract(axis.origin_mm, plane.origin_mm), plane.normal)) + if origin_plane_offset > 1e-6: + raise ValueError( + "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.origin_mm must lie in " + f"sketch.workplane; plane_offset_mm={origin_plane_offset:.3g}" + ) + + +def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[str, Any]: + """Apply a cut to each independently owned body without erasing ownership. + + A CADFS NEW body stays independently addressable even when a later REMOVE + feature affects several active bodies. Cutting the aggregate first loses + that identity, so this path uses the equivalent per-member set difference + and drops only members that the tool removes completely. + """ + members: dict[str, Any] = {} + for feature_id, body in session.body_members.items(): + result = session.adapter.cut(body, tool) + if abs(float(result.volume)) > 1e-12: + members[feature_id] = result + return members + + +def _extruded_tool( + node: FeaturePlanNode, + faces: list[Any], + profile_normal: Vector3, + session: "ExecutionSession", +) -> tuple[Any, TopologyDelta | None]: + """Build one extrude tool, retaining caps only from one exact builder result.""" + extents = _extent_vectors_from_normal(node, faces, profile_normal, session) + draft = node.params.get("draft") + taper_deg = 0.0 + if isinstance(draft, dict): + taper_deg = float(draft["angle_deg"]) + if not bool(draft["pull_direction"]): + taper_deg = -taper_deg + topology_delta: TopologyDelta | None = None + solids: list[Any] = [] + for face in faces: + for extent in extents: + if draft is not None: + if len(faces) == 1 and len(extents) == 1: + solid, topology_delta = session.adapter.extrude_taper_with_topology_delta( + face, extent.vector, taper_deg, + ) + solids.append(solid) + else: + solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg)) + elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1: + solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector) + solids.append(solid) + elif extent.trim_to is None: + solids.append(session.adapter.extrude(face, extent.vector)) + else: + solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector)) + tool = None + for solid in solids: + tool = session.adapter.fuse(tool, solid) + if tool is None: + raise ValueError("extrude produced no solid") + return tool, topology_delta + + +def _apply_primary_tool( + node: FeaturePlanNode, + session: "ExecutionSession", + tool: Any, + *, + cutting: bool, + topology_delta: TopologyDelta | None = None, +) -> FeatureResult: + """Apply a profile-derived tool while preserving only final-snapshot topology evidence.""" + if cutting: + if session.body is None: + raise ValueError("cut feature has no body") + members = _cut_explicit_body_members(session, tool) + if not members: + session.clear_body() + return session.result(node) + body = session.adapter.cut(session.body, tool) + topology_delta = None + elif node.params.get("result_mode") == "new_body": + body = session.adapter.combine(session.body, tool) + members = {**session.body_members, node.feature_id: tool} + else: + body = session.adapter.fuse(session.body, tool) + members = {node.feature_id: body} + # A fuse rebuilds subshape identity. Builder evidence belongs only to + # an unchanged standalone/new-body prism snapshot. + if session.body is not None: + topology_delta = None + session.register_body( + node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta, + ) + return session.result(node) + + +def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, sketch: dict[str, Any] | None = None) -> FeatureResult: + # 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。 + + # 1. 取草图:优先使用外部传入的 sketch_override(阵列/镜像等重放场景), + # 否则按 sketch_id 从会话草图表中取原始草图。 + selected_sketch = sketch or session.sketches.get(str(node.sketch_id)) + if selected_sketch is None: + raise ValueError("primary feature has no resolved sketch") + # 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。 + faces = session.adapter.faces_for_sketch(selected_sketch) + if not faces: + raise ValueError("sketch does not create a closed profile region") + if node.atomic_id == "extrude_add_blind_with_hole": + resolved = [session.resolve(selector) for selector in node.selectors] + failed = next((item for item in resolved if item.status != "resolved"), None) + if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face": + raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "profile hole selector is unresolved") + if len(faces) != 1: + raise ValueError("profile hole extrusion requires exactly one outer sketch region") + faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])] + topology_delta: TopologyDelta | None = None + # 3. 按特征类型生成子实体: + if node.atomic_id.startswith("extrude_"): + # 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量, + # 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在 + # profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪 + # 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。 + tool, topology_delta = _extruded_tool( + node, faces, _normal_from_sketch(selected_sketch), session, + ) + else: + # 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。 + axis = _revolve_axis(node, session) + _validate_revolve_axis_in_sketch_plane(axis, selected_sketch) + angle = float(node.params.get("angle_deg") or 0.0) + if angle <= 0: + raise ValueError("revolve requires angle_deg > 0") + # reverse=true 表示绕轴反向扫掠(SolidWorks 旋转方向反转):取负 + # 旋转角,与 extrude 的 reverse(_extent_vectors 反转拉伸方向)同一 + # 语义。profile_schema.json 已声明 revolve.* optional_params 含 + # reverse,cdsl_schema.json revolveParams 也已允许,这里补齐 runtime + # 侧实现,使三方合同一致。 + if bool(node.params.get("reverse")): + angle = -angle + tool = None + for solid in (session.adapter.revolve(face, angle, axis) for face in faces): + tool = session.adapter.fuse(tool, solid) + if tool is None: + raise ValueError("revolve produced no solid") + return _apply_primary_tool( + node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta, + ) + + +def _combine_members(session: "ExecutionSession", members: dict[str, Any]) -> Any: + body = None + for member in members.values(): + body = session.adapter.combine(body, member) + if body is None: + raise ValueError("booleanBodies produced no result bodies") + return body + + +def _pattern_instance_sources( + node: FeaturePlanNode, + session: "ExecutionSession", + parameter: str = "pattern_instance_refs", +) -> list[str]: + """Resolve CDSL pattern-instance refs to their internal body-member keys.""" + resolved: list[str] = [] + for reference in node.params.get(parameter) or (): + if not isinstance(reference, dict): + raise ValueError("pattern instance reference must be an object") + pattern_id = str(reference.get("pattern_feature_id") or "") + source_id = str(reference.get("source_feature_id") or "") + instance = reference.get("instance_index") + if not pattern_id or not source_id or not isinstance(instance, int): + raise ValueError("pattern instance reference is incomplete") + pattern = session.nodes.get(pattern_id) + if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}: + raise ValueError(f"pattern instance owner is unavailable: {pattern_id}") + params = pattern.params + if source_id not in {str(value) for value in params.get("source_feature_ids") or ()}: + raise ValueError("pattern instance source is not selected by its pattern") + count = int(params.get("pattern_count") or 0) + excluded = {int(value) for value in params.get("excluded_instance_indices") or ()} + if ( + pattern.atomic_id == "pattern_mirror" and instance != 1 + ) or ( + pattern.atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded) + ): + raise ValueError("pattern instance is outside the pattern's surviving instances") + member_id = pattern_instance_member_id(pattern_id, source_id, instance) + if member_id not in session.body_members: + raise ValueError(f"pattern instance body is unavailable: {pattern_id}/{source_id}/{instance}") + if member_id not in resolved: + resolved.append(member_id) + return resolved + + +def _transform_copy_sources(node: FeaturePlanNode, session: "ExecutionSession") -> list[str]: + """Resolve source-qualified outputs of preceding multi-body COPY transforms.""" + resolved: list[str] = [] + for reference in node.params.get("transform_copy_refs") or (): + if not isinstance(reference, dict): + raise ValueError("transform COPY reference must be an object") + transform_id = str(reference.get("transform_feature_id") or "") + source_id = str(reference.get("source_feature_id") or "") + if not transform_id or not source_id: + raise ValueError("transform COPY reference is incomplete") + transform = session.nodes.get(transform_id) + params = transform.params if transform is not None else {} + sources = params.get("source_feature_ids") or [] + if ( + transform is None + or transform.atomic_id != "transform_bodies" + or not bool(params.get("make_copy")) + or not isinstance(sources, list) + or len(sources) < 2 + or source_id not in {str(value) for value in sources} + ): + raise ValueError(f"transform COPY owner/source is unavailable: {transform_id}/{source_id}") + member_id = transform_copy_member_id(transform_id, source_id) + if member_id not in session.body_members: + raise ValueError(f"transform COPY body is unavailable: {transform_id}/{source_id}") + if member_id not in resolved: + resolved.append(member_id) + return resolved + + +def _member_sources( + node: FeaturePlanNode, + session: "ExecutionSession", + parameter: str, + *, + pattern_instance_parameter: str | None = None, + allow_transform_copies: bool = False, +) -> list[str]: + source_ids = [str(value) for value in node.params.get(parameter) or []] + if pattern_instance_parameter is not None: + source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter)) + if allow_transform_copies: + source_ids.extend(_transform_copy_sources(node, session)) + if not source_ids: + raise ValueError(f"{node.atomic_id} requires explicit {parameter}") + missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members] + if missing: + raise ValueError(f"{node.atomic_id} source bodies are unavailable: " + ", ".join(missing)) + return source_ids + + +def _sweep_path(node: FeaturePlanNode, session: "ExecutionSession") -> Any: + # 路径是 self-contained CDSL 数据,避免重放时依赖临时草图或 source id。 + path = node.params.get("path") or {} + if not isinstance(path, dict): + raise ValueError("sweep path must be an object") + plane = PlaneSpec.from_mapping(path.get("workplane") or {}) + segment = path.get("segment") or {} + if not isinstance(segment, dict): + raise ValueError("sweep path segment must be an object") + kind = str(segment.get("type") or "") + if kind == "line": + local_points = [segment.get("start"), segment.get("end")] + elif kind == "bspline": + local_points = segment.get("points") or [] + else: + raise ValueError(f"unsupported sweep path segment {kind!r}") + if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points): + raise ValueError("sweep path requires two-dimensional points") + + def point(value: list[float]) -> Vector3: + return vector_add( + plane.origin_mm, + vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))), + ) + + def tangent(value: Any) -> Vector3 | None: + if value is None: + return None + if not isinstance(value, list) or len(value) != 2: + raise ValueError("sweep path tangent must contain two coordinates") + return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))) + + return session.adapter.sweep_path( + [point(value) for value in local_points], + start_tangent=tangent(segment.get("start_tangent")), + end_tangent=tangent(segment.get("end_tangent")), + parameters=[float(value) for value in segment.get("parameters") or []] or None, + ) + + +def _register_added_solid( + session: "ExecutionSession", + node: FeaturePlanNode, + solid: Any, + *, + topology_delta: TopologyDelta | None = None, +) -> None: + """Register an additive primitive solid (box/cyl/sphere/thread/gear/rack/bend). + + When ``node.params['result_mode'] == "new_body"`` the primitive is kept as + an independent body member so that downstream ``boolean_bodies`` can + reference it without pulling in the accumulated fuse history. The current + body is replaced by a Compound that preserves both, matching the + ``extrude_add_blind`` ``new_body`` semantics. Any other value (including + missing) falls back to the legacy fuse-into-body behavior. + """ + if node.params.get("result_mode") == "new_body": + combined = session.adapter.combine(session.body, solid) + members = {**session.body_members, node.feature_id: solid} + session.register_body( + node.feature_id, combined, replay_node=node, body_members=members, + topology_delta=topology_delta, + ) + return + if session.body is None: + session.register_body(node.feature_id, solid, replay_node=node, topology_delta=topology_delta) + return + if topology_delta is None: + session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) + return + # Preserve primitive output roles only through the exact OCC fuse history. + # The transient records provide source handles, not selectable snapshots. + role_records = _direct_output_role_records(session, node, solid, topology_delta) + fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, solid) + session.register_body( + node.feature_id, fused, replay_node=node, topology_delta=fuse_delta, + topology_predecessors=role_records, + ) + + +def _direct_output_role_records( + session: "ExecutionSession", + node: FeaturePlanNode, + solid: Any, + topology_delta: TopologyDelta, +) -> list[TopologyRecord]: + """Attach only builder-proven output roles to a transient primitive snapshot.""" + records = session.adapter.topology_records(solid, node.feature_id, f"transient:{node.feature_id}") + result: list[TopologyRecord] = [] + for record in records: + roles = { + relation.output_role + for relation in topology_delta.relations + if relation.output_role is not None + and relation.kind == record.kind + and any(session.topology._same_topology_value(record.value, value) for value in relation.result_values) + } + if roles: + result.append(TopologyRecord( + record_id=record.record_id, + kind=record.kind, + feature_id=record.feature_id, + body_id=record.body_id, + geometry=record.geometry, + value=record.value, + owner_feature_ids=(node.feature_id,), + output_roles=tuple(sorted(roles)), + )) + return result + + +def _host_plane(resolution: SelectorResolution) -> PlaneSpec: + if resolution.record is None: + raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved") + geometry = resolution.record.geometry + return PlaneSpec.from_mapping({ + "origin_mm": geometry["center_mm"], + "x_dir": [1, 0, 0] if abs(float(geometry["normal"][0])) < 0.9 else [0, 1, 0], + "normal": geometry["normal"], + }) + + +def _hole_starts( + spec: HoleSpec, + *, + host_plane: PlaneSpec, + positions_are_local: bool, +) -> list[Vector3]: + starts: list[Vector3] = [] + for point in spec.positions_mm: + if positions_are_local: + start = vector_add( + vector_add( + vector_add(host_plane.origin_mm, vector_scale(host_plane.x_dir, point[0])), + vector_scale(host_plane.y_dir, point[1]), + ), + vector_scale(host_plane.normal, point[2]), + ) + else: + start = point + starts.append(start) + return starts + + +def _selector_edges(node: FeaturePlanNode, session: "ExecutionSession", *, tangent_propagation: bool = False) -> list[Any]: + resolved: list[SelectorResolution] = [session.resolve(selector) for selector in node.selectors] + failed = next((item for item in resolved if item.status != "resolved"), None) + if failed: + raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") + + def is_body_boundary(edge: Any) -> bool: + # 圆柱、圆锥等周期面会带一条仅属于自身的参数 seam。该线不是实体 + # 边界;FeatureScript 以 FACE 选择倒角时不应将其当作额外的待倒角边, + # 否则连续的锥面会被错误切成两段。显式 EDGE selector 仍可表达真正的 + # 单边选择,所以这里只约束由 FACE 展开的候选边。 + face_count = sum( + 1 + for face in session.body.faces() + if any(candidate.is_same(edge) for candidate in face.edges()) + ) + return face_count >= 2 + + edges: list[Any] = [] + for item in resolved: + records = item.records or ((item.record,) if item.record is not None else ()) + for record in records: + if record.kind == "edge": + edges.append(record.value) + elif record.kind == "face": + edges.extend(edge for edge in record.value.edges() if is_body_boundary(edge)) + if not edges: + raise ValueError("selectors did not resolve any edges") + return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges + + +def _shell_target(node: FeaturePlanNode, session: "ExecutionSession") -> tuple[Any, list[Any]]: + # shell 的 remove-face selector 必须全部属于同一实体。CADFS 允许一个 + # Compound 中保留多个独立 body,不能将整组 body 交给 OCC 后由内核猜测 + # 应抽壳的成员。 + resolved = [session.resolve(selector) for selector in node.selectors] + failed = next((item for item in resolved if item.status != "resolved"), None) + if failed: + raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") + records = [ + record + for item in resolved + for record in (item.records or ((item.record,) if item.record is not None else ())) + ] + if not records or any(record.kind != "face" for record in records): + raise ValueError("shell selectors must resolve to faces") + target_ids = {record.body_id for record in records} + if len(target_ids) != 1: + raise ValueError("shell faces must belong to one target body") + target_id = next(iter(target_ids)) + members = session.adapter.body_solids(session.body) + if len(members) == 1: + target = members[0] + else: + if target_id is None or session.body_id is None: + raise ValueError("shell target body is unresolved") + prefix = f"{session.body_id}:" + if not target_id.startswith(prefix): + raise ValueError("shell target body is outside the active body set") + try: + member_index = int(target_id[len(prefix):]) + except ValueError as error: + raise ValueError("shell target body has an invalid member id") from error + if member_index < 0 or member_index >= len(members): + raise ValueError("shell target body member is unavailable") + target = members[member_index] + target_feature_id = node.params.get("target_feature_id") + if target_feature_id is not None: + if not isinstance(target_feature_id, str) or not target_feature_id: + raise ValueError("shell target_feature_id is invalid") + declared = session.body_members.get(target_feature_id) + if declared is None: + raise ValueError("shell target body is no longer an independently selectable member") + declared_solids = session.adapter.body_solids(declared) + if len(declared_solids) != 1: + raise ValueError("shell target body must resolve to exactly one active solid") + if not declared_solids[0].is_same(target): + raise ValueError("shell target body does not match the resolved face member") + return target, [record.value for record in records] + + +def _replace_shell_target(session: "ExecutionSession", target: Any, replacement: Any) -> Any: + # 仅替换抽壳目标实体;其他独立实体保持原样和原有相对顺序。 + members = session.adapter.body_solids(session.body) + if len(members) == 1: + return replacement + replaced = False + result = None + for member in members: + if member.is_same(target): + result = session.adapter.combine(result, replacement) + replaced = True + else: + result = session.adapter.combine(result, member) + if not replaced or result is None: + raise ValueError("shell target solid is no longer part of the active body") + return result diff --git a/backend/engine/cdsl_engine/executors/context.py b/backend/engine/cdsl_engine/executors/context.py new file mode 100644 index 00000000..a5f5771c --- /dev/null +++ b/backend/engine/cdsl_engine/executors/context.py @@ -0,0 +1,63 @@ +"""Reference-geometry executors (reference_plane / reference_axis). + +Context features produce no solid; they register durable topology contexts +that later features resolve through owner-qualified selectors. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..specs import AxisSpec, PlaneSpec, vector_add, vector_cross, vector_dot, vector_scale, vector_unit +from ..topology import FeaturePlanNode, FeatureResult + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +@atomic_executor("reference_plane") +def _reference_plane_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + # 基准面特征(reference_plane)执行入口:从参数解析平面并登记为拓扑上下文。 + + # 1. 从特征参数 plane 中解析出平面定义 PlaneSpec(原点到法向)。 + plane = PlaneSpec.from_mapping(node.params.get("plane") or {}) + # 2. 将该平面注册到拓扑上下文,供后续特征(如草图基准、参考轴)引用。 + session.topology.register_context(node.feature_id, plane) + # 3. 返回结果对象,并将该平面作为上下文一并携带。 + return session.result(node, context=plane) + + +@atomic_executor("reference_axis") +def _reference_axis_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + # 基准轴特征(reference_axis)执行入口:由参数直接定义轴,或由两个基准平面求交线得到轴。 + + # 1. 尝试直接取参数:若同时给出原点 origin_mm 与方向 direction,则直接构造轴。 + params = node.params.get("axis") or {} + if params.get("origin_mm") and params.get("direction"): + axis = AxisSpec.from_mapping(params) + else: + # 2. 否则从特征选择器中筛选出已解析的基准平面。 + planes = [session.resolve(selector) for selector in node.selectors if selector.get("kind") == "plane"] + resolved = [item.record.value for item in planes if item.status == "resolved" and isinstance(item.record.value, PlaneSpec)] + # 3. 校验:轴需要两个非平行的平面,不足两个则报错。 + if len(resolved) < 2: + raise ValueError("reference axis requires two uniquely resolved planes") + # 4. 用两平面法线叉积求交线方向;若方向长度接近 0 说明两平面平行,无法成轴。 + first, second = resolved[0], resolved[1] + n1, n2 = first.normal, second.normal + direction = vector_cross(n1, n2) + squared_length = vector_dot(direction, direction) + if squared_length <= 1e-18: + raise ValueError("reference planes are parallel and cannot define an axis") + # 5. 求交线上的一点:两平面到各自原点的垂距参与线性组合,得到交线上的最近点。 + d1 = vector_dot(n1, first.origin_mm) + d2 = vector_dot(n2, second.origin_mm) + point = vector_scale(vector_add(vector_scale(vector_cross(n2, direction), d1), vector_scale(vector_cross(direction, n1), d2)), 1 / squared_length) + # 6. 由该点与归一化的交线方向组合成基准轴 AxisSpec。 + axis = AxisSpec(origin_mm=point, direction=vector_unit(direction, field_name="reference axis")) + # 7. 注册为拓扑上下文,并返回结果对象(携带该轴)。 + session.topology.register_context(node.feature_id, axis) + return session.result(node, context=axis) diff --git a/backend/engine/cdsl_engine/executors/dressup.py b/backend/engine/cdsl_engine/executors/dressup.py new file mode 100644 index 00000000..11335019 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/dressup.py @@ -0,0 +1,120 @@ +"""Dress-up executors (fillet / chamfer / shell). + +These mutate an existing body through edge/face selectors resolved from the +current B-rep snapshot. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, TopologyDelta +from .common import _replace_shell_target, _selector_edges, _shell_target + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。 + + # 1. 校验:圆角作用于已有主体,必须先有主体。 + if session.body is None: + raise ValueError("fillet has no body") + # 2. 解析圆角半径并校验必须大于 0。 + radius = float(node.params.get("radius_mm") or 0) + if radius <= 0: + raise ValueError("fillet radius_mm must be > 0") + # 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。 + body, topology_delta = session.adapter.fillet_with_topology_delta( + session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))), + ) + # 4. 登记新主体并返回结果。 + session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) + return session.result(node) + + +@atomic_executor("fillet") +def _fillet_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_fillet(node, session) + + +def _execute_chamfer(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 倒角特征(chamfer)执行入口:对选中边按距离做倒角(可带第二距离形成不对称倒角)。 + + # 1. 校验:倒角作用于已有主体,必须先有主体。 + if session.body is None: + raise ValueError("chamfer has no body") + # 2. 解析主距离并校验必须大于 0。 + distance = float(node.params.get("distance_mm") or 0) + if distance <= 0: + raise ValueError("chamfer distance_mm must be > 0") + # 3. 解析第二距离与角度(importer 对 SolidWorks Distance-Angle 倒角产出 + # angle_rad,单位为弧度)。第二距离 = 主距离 * tan(angle);angle=45° 时 + # tan=1,退化为等距倒角(与历史行为一致,零回归)。 + # 注意:build123d 的 length/length2 侧向分配依赖面的枚举顺序,对非 45° + # 倒角仅保证量级正确,距离所在侧可能反转。 + distance_2 = node.params.get("distance_2_mm") + angle_rad = node.params.get("angle_rad") + if distance_2 is None and angle_rad is not None: + distance_2 = distance * math.tan(float(angle_rad)) + # 4. 解析目标边(支持相切传播),执行倒角。 + edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))) + diagnostics: list[RuntimeDiagnostic] = [] + topology_delta: TopologyDelta | None = None + try: + body, topology_delta = session.adapter.chamfer_with_topology_delta(session.body, distance, distance_2, edges) + except ValueError as error: + # 显式 surfaceEntities 可以在后续实体上留下曲面分区边界。若标准 + # OCC 倒角因环域宽度不足而拒绝,只允许在该 shell 给出同轴边界证据 + # 时按原始距离构造受限倒角;没有证明时仍保留原始内核失败。 + if distance_2 is not None or not session.surface_members: + raise + try: + body = session.adapter.surface_limited_chamfer( + session.body, distance, edges, list(session.surface_members.values()), + ) + except ValueError: + raise error + diagnostics.append(RuntimeDiagnostic( + "chamfer_surface_limited", + "Chamfer was limited by an explicit coaxial surface boundary", + feature_id=node.feature_id, + detail={"distance_mm": distance, "surface_count": len(session.surface_members)}, + )) + # 5. 登记新主体并返回结果。 + session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) + return session.result(node, diagnostics=diagnostics) + + +@atomic_executor("chamfer") +def _chamfer_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_chamfer(node, session) + + +def _execute_shell(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 抽壳特征:移除 selector 所指面,并按 CADFS thickness 向实体内部偏置。 + if session.body is None: + raise ValueError("shell has no body") + thickness = float(node.params.get("thickness_mm") or 0) + if thickness <= 0: + raise ValueError("shell thickness_mm must be > 0") + target, faces = _shell_target(node, session) + result, topology_delta = session.adapter.shell_with_topology_delta( + target, faces, thickness, inward=bool(node.params.get("inward", True)), + ) + session.register_body( + node.feature_id, _replace_shell_target(session, target, result), replay_node=node, + topology_delta=topology_delta, + ) + return session.result(node) + + +@atomic_executor("shell") +def _shell_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_shell(node, session) diff --git a/backend/engine/cdsl_engine/executors/extrude.py b/backend/engine/cdsl_engine/executors/extrude.py new file mode 100644 index 00000000..e4b89829 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/extrude.py @@ -0,0 +1,42 @@ +"""Extrusion executors (blind / two-sided / cut / through / from-face).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..topology import FeaturePlanNode, FeatureResult +from .common import _apply_primary_tool, _extruded_tool, _shape_from_primary + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +@atomic_executor( + "extrude_add_blind", + "extrude_add_blind_with_hole", + "extrude_add_two_sided", + "extrude_cut_blind", + "extrude_cut_two_sided", + "extrude_cut_through", +) +def _primary_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + return _shape_from_primary(node, session, sketch=sketch) + + +def _execute_extrude_from_face(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + resolved = [session.resolve(selector) for selector in node.selectors] + failed = next((item for item in resolved if item.status != "resolved"), None) + if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face": + raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "derived profile face is unresolved") + face = resolved[0].record.value + tool, topology_delta = _extruded_tool(node, [face], session.adapter.face_normal(face), session) + return _apply_primary_tool( + node, session, tool, cutting=node.params.get("operation") == "cut", topology_delta=topology_delta, + ) + + +@atomic_executor("extrude_from_face") +def _extrude_from_face_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_extrude_from_face(node, session) diff --git a/backend/engine/cdsl_engine/executors/holes.py b/backend/engine/cdsl_engine/executors/holes.py new file mode 100644 index 00000000..985e909a --- /dev/null +++ b/backend/engine/cdsl_engine/executors/holes.py @@ -0,0 +1,78 @@ +"""Hole executors (hole_blind / hole_countersink / hole_counterbore / hole_wizard).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..specs import HoleSpec, PlaneSpec, vector_scale +from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic +from .common import _hole_starts, _host_plane + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +@atomic_executor("hole_blind", "hole_countersink", "hole_counterbore") +def _hole_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_hole(node, session, wizard=False) + + +@atomic_executor("hole_wizard") +def _hole_wizard_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_hole(node, session, wizard=True) + + +def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard: bool = False) -> FeatureResult: + # 孔特征(hole)执行入口:在指定宿主面上按孔规格生成切除工具,并从主体上减去。 + + # 1. 校验:孔是切除操作,必须先有主体。 + if session.body is None: + raise ValueError("hole feature has no body") + # 2. 确定宿主面 host_face: + host_selector = node.params.get("host_face") + if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict): + # 若直接带 frame(平面定义),则以该平面为宿主,孔位按局部坐标解释。 + host = PlaneSpec.from_mapping(host_selector["frame"]) + positions_are_local = True + else: + # 否则从特征选择器中取 face,解析出宿主平面,孔位按世界坐标解释。 + selectors = list(node.selectors) + if isinstance(host_selector, dict): + selectors.append(host_selector) + selector = next((item for item in selectors if item.get("kind") == "face"), None) + if selector is None: + raise ValueError("hole requires host_face selector or frame") + host = _host_plane(session.resolve(selector)) + positions_are_local = False + # 3. 解析孔规格 HoleSpec(直径、深度、类型等,wizard 模式提供额外默认值)。 + spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard) + # 4. A host-face normal is an outward B-rep orientation, so its inverse + # always enters the material. Inferring direction from the global body + # centre fails for concave or multi-leg parts: for example, the top face + # of an L bracket can sit below the whole body's centre and the old rule + # drilled outward, producing a no-op feature reported as successful. + # The selected topology face is the local, authoritative orientation. + inward = vector_scale(host.normal, -1) + # 5. 生成孔切除工具:按孔规格、起始位置、内方向及“贯穿到主体底面”的深度构造工具实体。 + tool = session.adapter.hole_tool( + spec, + _hole_starts(spec, host_plane=host, positions_are_local=positions_are_local), + inward, + session.adapter.body_span(session.body, inward) + 2.0, + ) + # 6. 从主体上减去工具实体,登记新主体并返回结果。 + # thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔 + # 即光滑孔):孔按光滑圆柱孔执行,同时记录 info 级诊断便于批量报告 + # 追溯降级数量(issue #9,capabilities 已不再拒绝 thread)。 + diagnostics: list[RuntimeDiagnostic] = [] + if wizard and node.params.get("thread"): + diagnostics.append(RuntimeDiagnostic( + code="thread_decoration_ignored", + 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) + return session.result(node, diagnostics=diagnostics) diff --git a/backend/engine/cdsl_engine/executors/loft_sweep.py b/backend/engine/cdsl_engine/executors/loft_sweep.py new file mode 100644 index 00000000..4cbb1ba5 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/loft_sweep.py @@ -0,0 +1,89 @@ +"""Loft and sweep executors (loft_add / loft_add_with_cap_face / sweep_add).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..topology import FeaturePlanNode, FeatureResult +from .common import _sweep_path + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +def _execute_loft_add(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 放样截面不占用 feature.sketch_id;按有序 profile_sketch_ids 取已解析 + # 草图,并由 adapter 统一校验单闭环、无内环等内核输入约束。 + profile_ids = node.params.get("profile_sketch_ids") or [] + profiles: list[dict[str, Any]] = [] + for sketch_id in profile_ids: + sketch = session.sketches.get(str(sketch_id)) + if sketch is None: + raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved") + profiles.append(sketch) + solid, topology_delta = session.adapter.loft_with_topology_delta(profiles) + body = session.adapter.fuse(session.body, solid) + # Fusing a loft into an existing body replaces its subshapes through a + # different builder. Only an initial direct loft can expose this builder's + # cap evidence for the final B-rep snapshot. + if session.body is not None: + topology_delta = None + session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) + return session.result(node) + + +@atomic_executor("loft_add") +def _loft_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_loft_add(node, session) + + +def _execute_loft_add_with_cap_face(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + resolved = [session.resolve(selector) for selector in node.selectors] + failed = next((item for item in resolved if item.status != "resolved"), None) + if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face": + raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "cap-face loft selector is unresolved") + profile_ids = node.params.get("profile_sketch_ids") or [] + profiles: list[dict[str, Any]] = [] + for sketch_id in profile_ids: + sketch = session.sketches.get(str(sketch_id)) + if sketch is None: + raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved") + profiles.append(sketch) + solid = session.adapter.loft_with_cap_face(resolved[0].record.value, profiles) + session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) + return session.result(node) + + +@atomic_executor("loft_add_with_cap_face") +def _loft_cap_face_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_loft_add_with_cap_face(node, session) + + +def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None = None) -> FeatureResult: + profile = sketch or session.sketches.get(str(node.sketch_id)) + if profile is None: + raise ValueError("sweep has no resolved profile sketch") + faces = session.adapter.faces_for_sketch(profile) + if len(faces) != 1: + raise ValueError("sweep requires exactly one closed profile region") + solid, topology_delta = session.adapter.sweep_with_topology_delta( + faces[0], _sweep_path(node, session), + is_frenet=bool(node.params.get("is_frenet", False)), + ) + is_new_body = node.params.get("result_mode") == "new_body" + body = session.adapter.combine(session.body, solid) if is_new_body else session.adapter.fuse(session.body, solid) + # A union rebuilds topology, so the pipe-shell builder cannot prove the + # final aggregate's relations. The independent-body path retains its exact + # subshape identity and may expose evidence for the new member. + if session.body is not None and not is_new_body: + topology_delta = None + session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) + return session.result(node) + + +@atomic_executor("sweep_add") +def _sweep_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + return _execute_sweep_add(node, session, sketch) diff --git a/backend/engine/cdsl_engine/executors/parametric.py b/backend/engine/cdsl_engine/executors/parametric.py new file mode 100644 index 00000000..7cb74084 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/parametric.py @@ -0,0 +1,118 @@ +"""Parametric feature executors (thread / bend / gear / rack). + +Each executor parses its runtime-neutral spec from ``runtime_types`` specs, +asks the geometry adapter to build and place the local-frame solid, then +fuses it into the active body (or subtracts, for thread_cut). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..specs import BendSpec, GearSpec, RackSpec, ThreadSpec +from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic +from .common import _register_added_solid + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +def _execute_thread(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 螺纹特征(thread_add)执行入口:按规格生成参数化螺纹段并并入当前主体。 + # 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。 + spec = ThreadSpec.from_feature(node.atomic_id, node.params) + # 2. 由适配器门面生成沿 spec.axis 放置的外螺纹实心段。 + solid = session.adapter.thread_solid(spec) + # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 + _register_added_solid(session, node, solid) + return session.result(node) + + +def _execute_thread_cut(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 内螺纹(thread_cut)执行入口:ThreadSpec.from_feature 对 thread_cut 恒置 + # internal=True,生成牙顶外放 INTERNAL_CUT_OVERLAP_MM 的切削刀具,沿 + # spec.axis 放置后从当前主体布尔差出全深螺旋牙槽(宿主通常已预打光孔, + # 刀具 core 落在孔腔中,仅外放的牙槽层切入孔壁)。 + # 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。 + spec = ThreadSpec.from_feature(node.atomic_id, node.params) + # 2. 由适配器门面生成沿 spec.axis 放置的内螺纹切削刀具实心段。 + tool = session.adapter.thread_solid(spec) + # 3. 从当前主体布尔差(cut)后登记为新主体,并返回该特征的结果对象。 + session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node) + return session.result(node) + + +@atomic_executor("thread_add", "thread_cut") +def _thread_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + # 螺纹特征(thread_add/thread_cut)不需要草图平面,丢弃该参数后执行。 + # thread_cut 走布尔差分支:从已有主体切出内螺纹槽,而非并入外螺纹段。 + del sketch + if node.atomic_id == "thread_cut": + return _execute_thread_cut(node, session) + return _execute_thread(node, session) + + +def _execute_bend(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 折弯特征(bend_add)执行入口:按规格生成等厚折弯板并并入当前主体。 + # 1. 解析并校验板厚/宽度/折痕链与放置平面,非法输入抛出带具体原因的 ValueError。 + spec = BendSpec.from_feature(node.params) + # 2. 由适配器门面生成沿 spec.frame 放置的折弯实心段。 + solid = session.adapter.bend_solid(spec) + # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 + _register_added_solid(session, node, solid) + return session.result(node) + + +@atomic_executor("bend_add") +def _bend_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + # 折弯特征(bend_add)不需要草图平面,丢弃该参数后执行。 + del sketch + return _execute_bend(node, session) + + +def _execute_gear(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 齿轮特征(gear_add)执行入口:按规格生成渐开线齿轮并入当前主体。 + # 1. 解析并校验模数/齿数/齿宽/螺旋角与放置轴,非法输入抛出带具体原因的 ValueError。 + spec = GearSpec.from_feature(node.params) + # 2. 由适配器门面生成沿 spec.axis 放置的齿轮实体(直齿/斜齿/人字齿)。 + solid = session.adapter.gear_solid(spec) + # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 + _register_added_solid(session, node, solid) + # 4. 小齿数根切风险:不阻断执行,附加 info 级诊断供完成报告如实披露。 + diagnostics: list[RuntimeDiagnostic] = [] + if spec.teeth_count < 17: + diagnostics.append(RuntimeDiagnostic( + code="undercut_risk", + message=( + f"Gear with {spec.teeth_count} teeth and a 20 degree pressure angle is undercut-prone; " + "standard involute geometry is generated without profile shift" + ), + feature_id=node.feature_id, + )) + return session.result(node, diagnostics=diagnostics) + + +@atomic_executor("gear_add") +def _gear_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + # 齿轮特征(gear_add)不需要草图平面,丢弃该参数后执行。 + del sketch + return _execute_gear(node, session) + + +def _execute_rack(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 齿条特征(rack_add)执行入口:按规格生成直线齿条并入当前主体。 + # 1. 解析并校验模数/齿数/厚度/压力角与放置轴,非法输入抛出带具体原因的 ValueError。 + spec = RackSpec.from_feature(node.params) + # 2. 由适配器门面生成沿 spec.axis 放置的齿条实体(齿沿轴方向伸出)。 + solid = session.adapter.rack_solid(spec) + # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 + _register_added_solid(session, node, solid) + return session.result(node) + + +@atomic_executor("rack_add") +def _rack_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + # 齿条特征(rack_add)不需要草图平面,丢弃该参数后执行。 + del sketch + return _execute_rack(node, session) diff --git a/backend/engine/cdsl_engine/executors/patterns.py b/backend/engine/cdsl_engine/executors/patterns.py new file mode 100644 index 00000000..9b37f858 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/patterns.py @@ -0,0 +1,382 @@ +"""Pattern executors (pattern_linear / pattern_mirror / pattern_circular). + +Instances replay their source features with transformed parameters rather +than copying the current body. NEW-body sources can additionally be +instanced as rigid body-graph copies, keeping each instance independently +addressable for later COPY/DELETE queries. +""" + +from __future__ import annotations + +import math +from copy import deepcopy +from typing import TYPE_CHECKING, Any, Callable + +from ..capabilities import pattern_transform_blocker +from ..pattern_transform import ( + _box_circular_is_exact, + _mirrored_node, + _mirrored_sketch, + _normal_is_coordinate_axis, + _pattern_operation_node, + _rotated_node, + _rotated_sketch, + _translated_node, + _translated_sketch, +) +from ..registry import atomic_executor, execute_node +from ..specs import AxisSpec, PlaneSpec, Vector3, pattern_instance_member_id, vector_add, vector_dot, vector_scale, vector_subtract, vector_unit +from ..topology import FeaturePlanNode, FeatureResult, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + +ExecutorFunction = Callable[[FeaturePlanNode, "ExecutionSession", dict[str, Any] | None], FeatureResult] + + +def _execute_linear_pattern( + node: FeaturePlanNode, + session: "ExecutionSession", + execute: ExecutorFunction, +) -> FeatureResult: + # 线性阵列特征(pattern)执行入口:沿两个方向按数量与间距重放源特征形成阵列。 + + # 1. 取源特征的 replay 定义(源特征按 feature_id 在会话中登记,供本阵列重放)。 + params = node.params + sources = session.replay_sources(params.get("source_feature_ids") or []) + if not sources: + raise ValueError("pattern source features have no replay definitions") + # 2. 解析两个方向的实例数量。 + count_1 = int(params.get("pattern_count_1") or 1) + count_2 = int(params.get("pattern_count_2") or 1) + # 3. 解析两个方向的步长向量(方向单位向量 × 间距),作为阵列位移基准。 + direction_1 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_1") or [1, 0, 0])), field_name="pattern direction_1"), float(params.get("spacing_1_mm") or 0)) + direction_2 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_2") or [0, 1, 0])), field_name="pattern direction_2"), float(params.get("spacing_2_mm") or 0)) + # 4. 双重循环生成每个阵列实例(跳过原点 0,0 处,那里是源特征本身)。 + for first in range(count_1): + for second in range(count_2): + if first == 0 and second == 0: + continue + # 计算当前实例相对源特征的偏移向量。 + offset = vector_add(vector_scale(direction_1, first), vector_scale(direction_2, second)) + for source in sources: + # 逐个源特征克隆并按偏移平移后重放执行(草图也同步平移)。 + dependency = pattern_transform_blocker(source) + if dependency: + raise ValueError(f"pattern source uses an unsupported {dependency}") + cloned = _translated_node(source, f"{node.feature_id}.p{first}_{second}.{source.feature_id}", offset, session) + sketch = session.sketches.get(str(source.sketch_id)) + execute(cloned, session, _translated_sketch(sketch, offset) if sketch else None) + # 5. 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放, + # 而非复制当前主体做近似。 + # A later pattern may select this pattern feature. The definition is + # replayed recursively, never approximated by copying the current body. + session.replay_definitions[node.feature_id] = node + return session.result(node) + + +@atomic_executor("pattern_linear") +def _linear_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_linear_pattern(node, session, execute_node) + + +def _execute_mirror_pattern(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + mirror = node.params.get("mirror_plane") or {} + resolution = session.resolve(mirror) + if resolution.status != "resolved" or not isinstance(resolution.record.value, PlaneSpec): + raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "mirror plane was not resolved") + source_ids = [str(value) for value in node.params.get("source_feature_ids") or ()] + if ( + source_ids + and all(source_id in session.body_members for source_id in source_ids) + and all( + (source := session.nodes.get(source_id)) is not None + and source.params.get("result_mode") == "new_body" + for source_id in source_ids + ) + ): + # Only a direct NEW body has a standalone source identity after a + # mirror. A hole, dress-up, or ordinary additive source is merely an + # aggregate successor and must use the feature-replay path below. + # Keeping this condition identical to capability preflight prevents a + # downstream COPY body query from selecting an arbitrary aggregate. + members = dict(session.body_members) + body = session.body + for source_id in source_ids: + mirrored = session.adapter.mirror(session.body_members[source_id], resolution.record.value) + members[pattern_instance_member_id(node.feature_id, source_id, 1)] = mirrored + body = session.adapter.fuse(body, mirrored) + if body is None: + raise ValueError("mirror pattern produced no body") + session.register_body(node.feature_id, body, replay_node=node, body_members=members) + return session.result(node) + if node.params.get("mirror_current_body"): + # CADFS SWEPT_BODY 表示被后续 feature 持续修改的同一实体。这里复制 + # 当前 B-rep 再镜像并合并,不能重放其初始 additive feature,否则会 + # 丢失后续 cut/fillet 并生成独立错误实体。 + if session.body is None: + raise ValueError("mirror current body has no active body") + mirrored = session.adapter.mirror(session.body, resolution.record.value) + session.register_body(node.feature_id, session.adapter.fuse(session.body, mirrored), replay_node=node) + return session.result(node) + sources = session.replay_sources(node.params.get("source_feature_ids") or []) + if not sources: + raise ValueError("mirror pattern source features have no replay definitions") + for source in sources: + dependency = pattern_transform_blocker(source) + if dependency: + raise ValueError(f"mirror pattern source uses an unsupported {dependency}") + if source.atomic_id == "box_add" and not _normal_is_coordinate_axis(resolution.record.value.normal): + # box_add 是固定世界轴对齐的原生图元:跨非坐标平面镜像会产生倾斜朝向, + # 当前参数语义无法表达,静默重放会得到错误几何 → 明确拒绝。跨坐标平面 + # (法向平行于任一坐标轴)的镜像仍然精确。 + raise ValueError("box_add mirror is exact only across coordinate-aligned mirror planes") + cloned = _mirrored_node(source, f"{node.feature_id}.m.{source.feature_id}", resolution.record.value, session) + sketch = session.sketches.get(str(source.sketch_id)) + execute_node(cloned, session, _mirrored_sketch(sketch, resolution.record.value) if sketch else None) + session.replay_definitions[node.feature_id] = node + return session.result(node) + + +@atomic_executor("pattern_mirror") +def _mirror_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_mirror_pattern(node, session) + + +def _circular_source_is_axisymmetric(node: FeaturePlanNode, session: "ExecutionSession", axis: AxisSpec) -> bool: + """Whether rotating a direct circular extrusion creates no new geometry.""" + if node.atomic_id not in {"extrude_add_blind", "extrude_add_two_sided"}: + return False + sketch = session.sketches.get(str(node.sketch_id)) + if sketch is None: + return False + profile = sketch.get("profile") or {} + circle = profile if profile.get("type") == "circle" else None + if circle is None: + contours = profile.get("contours") or [] + segments = (contours[0] or {}).get("segments") if len(contours) == 1 else [] + circle = segments[0] if isinstance(segments, list) and len(segments) == 1 and segments[0].get("type") == "circle" else None + center = (circle or {}).get("center") + if not isinstance(center, list) or len(center) != 2: + return False + try: + plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) + except (TypeError, ValueError): + return False + if abs(vector_dot(plane.normal, axis.direction)) < 1 - 1e-7: + return False + world_center = vector_add( + plane.origin_mm, + vector_add(vector_scale(plane.x_dir, float(center[0])), vector_scale(plane.y_dir, float(center[1]))), + ) + offset = vector_subtract(world_center, axis.origin_mm) + radial = vector_subtract(offset, vector_scale(axis.direction, vector_dot(offset, axis.direction))) + return math.sqrt(vector_dot(radial, radial)) <= 1e-6 + + +def _advance_copy_topology_records( + records: list[TopologyRecord], topology_delta: TopologyDelta | None, +) -> list[TopologyRecord]: + """Carry COPY provenance through one exact adapter-history operation. + + Pattern copies are separate CDSL results even when their solids fuse into + a single final body. The temporary records here are never selector + candidates themselves. They only retain instance ownership while opaque + OCC history proves a unique subshape continuation to the final snapshot. + """ + if topology_delta is None: + return [] + advanced: list[TopologyRecord] = [] + for record in records: + values: list[Any] = [] + for relation in topology_delta.relations: + if ( + relation.kind != record.kind + or relation.event not in {"preserved", "modified"} + or not TopologyRegistry._same_topology_value(record.value, relation.source_value) + ): + continue + for value in relation.result_values: + if not any(TopologyRegistry._same_topology_value(value, known) for known in values): + values.append(value) + # A split/merge has no unique COPY owner in the present selector + # contract. Keep the executable model, but do not make a claim that a + # later COPY selector can bind one arbitrary descendant. + if len(values) != 1: + continue + advanced.append(TopologyRecord( + record_id=record.record_id, + kind=record.kind, + feature_id=record.feature_id, + body_id=record.body_id, + geometry=dict(record.geometry), + value=values[0], + owner_feature_ids=record.owners, + output_roles=record.output_roles, + output_role_sources=record.output_role_sources, + )) + return advanced + + +def _copy_snapshot_topology_delta(records: list[TopologyRecord]) -> TopologyDelta | None: + """Bridge traced final COPY handles into the one registered body snapshot.""" + if not records: + return None + return TopologyDelta( + operation="pattern_circular_copy_snapshot", + relations=tuple( + # ``record.value`` has already passed through every transform/fuse + # builder in this pattern and is an actual final-B-rep handle. The + # identity relation merely connects that evidence to the fresh + # adapter snapshot; it is not a geometric rebinding shortcut. + TopologyDeltaRelation("preserved", record.kind, record.value, (record.value,)) + for record in records + ), + ) + + +def _has_usable_pattern_body(session: "ExecutionSession", body: Any | None) -> bool: + """Reject a formally valid but empty OCC boolean result before publishing it.""" + if body is None or not session.adapter.body_solids(body): + return False + try: + return abs(float(body.volume)) > 1e-12 + except (AttributeError, TypeError, ValueError): + return False + + +def _execute_circular_pattern( + node: FeaturePlanNode, + session: "ExecutionSession", + execute: ExecutorFunction, +) -> FeatureResult: + # 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征 + # 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。 + params = node.params + raw_axis = params.get("axis") + if not (isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None): + raise ValueError("circular pattern requires an explicit axis with origin_mm and direction") + axis = AxisSpec.from_mapping(raw_axis) + count = int(params.get("pattern_count") or 1) + if count < 1: + raise ValueError("circular pattern pattern_count must be >= 1") + sweep_angle_deg = float(params.get("sweep_angle_deg") or 360.0) + operation_mode = str(params.get("operation_mode") or "add") + if operation_mode not in {"add", "remove"}: + raise ValueError("circular pattern operation_mode must be add or remove") + excluded = {int(value) for value in params.get("excluded_instance_indices") or []} + if any(instance < 1 or instance >= count for instance in excluded): + raise ValueError("circular pattern excluded instance is outside the generated range") + sources = session.replay_sources(params.get("source_feature_ids") or []) + if not sources: + raise ValueError("circular pattern source features have no replay definitions") + source_ids = [source.feature_id for source in sources] + pre_pattern_members = dict(session.body_members) + if operation_mode == "add" and all(source_id in session.body_members for source_id in source_ids): + # A pattern over explicit NEW/kept body members has a stronger contract + # than replay: each copy is an independently addressable rigid image of + # the named source member. Keep the instance keys in the body graph so + # a later CADFS COPY(BODY) transform/delete can name exactly one copy. + members = dict(session.body_members) + body = session.body + traced_copy_records: list[TopologyRecord] = [] + for instance in range(1, count): + if instance in excluded: + continue + angle_deg = sweep_angle_deg * instance / count + transform = { + "type": "rotation", + "axis": {"origin_mm": list(axis.origin_mm), "direction": list(axis.direction)}, + "angle_deg": angle_deg, + } + for source_id in source_ids: + member_id = pattern_instance_member_id(node.feature_id, source_id, instance) + owner_id = f"{node.feature_id}.c{instance}.{source_id}" + source_body = session.body_members[source_id] + copy, transform_delta = session.adapter.transform_with_topology_delta(source_body, transform) + source_records = session.adapter.topology_records( + source_body, owner_id, f"body:{node.feature_id}:copy:{instance}:{source_id}:source", + ) + copy_records = _advance_copy_topology_records(source_records, transform_delta) + members[member_id] = copy + body, fuse_delta = session.adapter.fuse_with_topology_delta(body, copy) + traced_copy_records = _advance_copy_topology_records( + [*traced_copy_records, *copy_records], fuse_delta, + ) + if _has_usable_pattern_body(session, body): + session.register_body( + node.feature_id, body, replay_node=node, body_members=members, + topology_delta=_copy_snapshot_topology_delta(traced_copy_records), + topology_predecessors=traced_copy_records, + ) + return session.result(node) + # An OCC boolean may report IsDone/valid for an empty result when a + # copied fused body contains coincident internal topology. The normal + # pattern contract can replay the source feature contribution instead; + # it is the only sound fallback because it keeps source operation, + # sketch frame, and body lifecycle semantics intact. + for instance in range(1, count): + if instance in excluded: + continue + # 实例 i 位于包角 sweep_angle_deg 的 i/count 处(i=0 即源特征本身)。 + angle_deg = sweep_angle_deg * instance / count + angle_rad = math.radians(angle_deg) + for source in sources: + # 与阵列轴同心、法向平行的圆形实体拉伸在任意环形实例中均与 + # 原实体完全重合。重复执行它会把同一 B-rep 再次交给 OCC fuse, + # 后续非轴对称 source 可能因此丢失已生成的实体分支。 + if _circular_source_is_axisymmetric(source, session, axis): + continue + dependency = pattern_transform_blocker(source) + if dependency: + raise ValueError(f"circular pattern source uses an unsupported {dependency}") + if source.atomic_id == "box_add" and not _box_circular_is_exact(axis, angle_rad): + raise ValueError( + "box_add circular pattern is exact only for coordinate-axis rotation " + "by multiples of 180 degrees" + ) + cloned = _rotated_node(source, f"{node.feature_id}.c{instance}.{source.feature_id}", axis, angle_rad, session) + cloned = _pattern_operation_node(cloned, operation_mode) + # CADFS pattern instances are copies of the source result, not + # independent `NEW` operations. Replay them through normal add + # semantics: intersecting or face-sharing instances fuse, while + # spatially separate copies remain separate solids in the result. + if cloned.params.get("result_mode") == "new_body": + cloned = FeaturePlanNode( + cloned.feature_id, cloned.atomic_id, cloned.name, cloned.depends_on, + {key: value for key, value in cloned.params.items() if key != "result_mode"}, + cloned.selectors, cloned.sketch_id, cloned.declared_status, cloned.source_feature, + ) + sketch = session.sketches.get(str(source.sketch_id)) + execute(cloned, session, _rotated_sketch(sketch, axis, angle_rad) if sketch else None) + # 环形阵列本身是完整 B-rep 结果的 producer。每个 replay 子特征都会更新 + # active body;循环结束后必须用 pattern feature 重新登记最终快照,否则后续 + # selector binding 会只保留最后一个实例的 body id,漏掉其它 COPY 实例。 + if session.body is None: + raise ValueError("circular pattern produced no body") + # Replaying a fused sole-body source may be more robust than copying its + # full aggregate B-rep (for example, when a rotationally invariant base + # would otherwise be unioned with itself). If that replay still has one + # physical body, the direct source remains a proven alias of the current + # member. Preserve it for a following parts-scoped operation such as + # shell; do not extend this alias across multi-body patterns or multiple + # source members. + members = {node.feature_id: session.body} + if ( + len(source_ids) == 1 + and len(pre_pattern_members) == 1 + and source_ids[0] in pre_pattern_members + and _has_usable_pattern_body(session, session.body) + and len(session.adapter.body_solids(session.body)) == 1 + ): + members[source_ids[0]] = session.body + session.register_body(node.feature_id, session.body, replay_node=node, body_members=members) + return session.result(node) + + +@atomic_executor("pattern_circular") +def _circular_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_circular_pattern(node, session, execute_node) diff --git a/backend/engine/cdsl_engine/executors/primitives.py b/backend/engine/cdsl_engine/executors/primitives.py new file mode 100644 index 00000000..24949bba --- /dev/null +++ b/backend/engine/cdsl_engine/executors/primitives.py @@ -0,0 +1,97 @@ +"""Analytic primitive executors (sphere_add / box_add / cylinder_add).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..specs import AxisSpec, PlaneSpec +from ..topology import FeaturePlanNode, FeatureResult +from .common import _register_added_solid + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +def _execute_sphere(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 球体特征(sphere_add)执行入口:按球心与半径生成球体并并入当前主体。 + + # 1. 解析参数:半径 radius_mm 与球心 center_mm。 + radius = float(node.params.get("radius_mm") or 0.0) + center = node.params.get("center_mm") or [] + # 2. 校验:半径必须大于 0,球心必须是三维坐标。 + if radius <= 0 or len(center) != 3: + raise ValueError("sphere_add requires radius_mm and a three-dimensional center_mm") + # 3. 由适配器创建球体实体。 + solid = session.adapter.sphere(radius, (float(center[0]), float(center[1]), float(center[2]))) + # 4. 球体与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 + _register_added_solid(session, node, solid) + return session.result(node) + + +def _execute_box(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 长方体特征(box_add)执行入口:以几何中心 center_mm 与三向尺寸生成原生长方体。 + # 1. 解析并校验尺寸与中心,非法输入抛出带具体原因的 ValueError。 + try: + length = float(node.params.get("length_mm") or 0.0) + width = float(node.params.get("width_mm") or 0.0) + height = float(node.params.get("height_mm") or 0.0) + center = node.params.get("center_mm") or [] + except (TypeError, ValueError) as error: + raise ValueError("box dimensions must be numeric") from error + if length <= 0 or width <= 0 or height <= 0 or len(center) != 3: + raise ValueError("box_add requires positive length_mm/width_mm/height_mm and a three-dimensional center_mm") + # 2. 生成世界轴对齐的 plane frame:plane 原点是长方体的最小角点(中心减去半 + # 尺寸),长/宽/高分别沿世界 x/y/z 生长(build123d Solid.make_box 语义)。 + corner = ( + float(center[0]) - length / 2, + float(center[1]) - width / 2, + float(center[2]) - height / 2, + ) + plane = PlaneSpec.from_mapping({"origin_mm": corner, "x_dir": [1, 0, 0], "normal": [0, 0, 1]}) + solid = session.adapter.box(length, width, height, plane) + # 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。 + _register_added_solid(session, node, solid) + return session.result(node) + + +def _execute_cylinder(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 圆柱特征(cylinder_add)执行入口:axis 的原点是底面圆心、方向为轴向; + # axis 缺省为世界 +Z 过原点(底面圆心落在 (0,0,0))。 + # 1. 解析并校验半径与高度,非法输入抛出带具体原因的 ValueError。 + try: + radius = float(node.params.get("radius_mm") or 0.0) + height = float(node.params.get("height_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError("cylinder dimensions must be numeric") from error + if radius <= 0 or height <= 0: + raise ValueError("cylinder_add requires positive radius_mm and height_mm") + raw_axis = node.params.get("axis") + if raw_axis is not None and not ( + isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None + ): + raise ValueError("cylinder_add axis must define origin_mm and direction") + axis = AxisSpec.from_mapping(raw_axis) if isinstance(raw_axis, dict) else None + # 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。 + solid, topology_delta = session.adapter.cylinder_with_topology_delta(radius, height, axis) + # 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。 + _register_added_solid(session, node, solid, topology_delta=topology_delta) + return session.result(node) + + +@atomic_executor("sphere_add") +def _sphere_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_sphere(node, session) + + +@atomic_executor("box_add") +def _box_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_box(node, session) + + +@atomic_executor("cylinder_add") +def _cylinder_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_cylinder(node, session) diff --git a/backend/engine/cdsl_engine/executors/revolve.py b/backend/engine/cdsl_engine/executors/revolve.py new file mode 100644 index 00000000..0a11aae1 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/revolve.py @@ -0,0 +1,17 @@ +"""Revolution solid executors (revolve_add / revolve_cut).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..topology import FeaturePlanNode, FeatureResult +from .common import _shape_from_primary + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +@atomic_executor("revolve_add", "revolve_cut") +def _primary_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + return _shape_from_primary(node, session, sketch=sketch) diff --git a/backend/engine/cdsl_engine/executors/surfaces.py b/backend/engine/cdsl_engine/executors/surfaces.py new file mode 100644 index 00000000..5ef44378 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/surfaces.py @@ -0,0 +1,75 @@ +"""Surface-feature executors (extrude_surface / revolve_surface). + +Surface features register an independent shell and never touch the active +solid body's fuse/cut lifecycle. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..extents import _normal_from_sketch +from ..registry import atomic_executor +from ..specs import AxisSpec, PlaneSpec, vector_cross, vector_dot, vector_scale, vector_unit +from ..topology import FeaturePlanNode, FeatureResult +from .common import _revolve_axis, _validate_revolve_axis_in_sketch_plane + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +def _execute_revolve_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # Surface revolve 的 profile 是单一闭合 wire。它只生成独立 shell,不能参与 + # 当前实体 body 的 fuse/cut,也不能把其结果误报为新的实体 body。 + sketch = session.sketches.get(str(node.sketch_id)) + if sketch is None: + raise ValueError("surface revolve has no resolved sketch") + faces = session.adapter.faces_for_sketch(sketch) + if len(faces) != 1 or faces[0].inner_wires(): + raise ValueError("surface revolve requires exactly one closed profile without holes") + axis = _revolve_axis(node, session) + _validate_revolve_axis_in_sketch_plane(axis, sketch) + angle = float(node.params.get("angle_deg") or 0.0) + if angle <= 0: + raise ValueError("surface revolve requires angle_deg > 0") + if bool(node.params.get("reverse")): + angle = -angle + surface_id = session.register_surface( + node.feature_id, + session.adapter.revolve_surface(faces[0].outer_wire(), angle, axis), + ) + return session.result(node, include_body=False, surface_id=surface_id) + + +@atomic_executor("revolve_surface") +def _revolve_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_revolve_surface(node, session) + + +def _execute_extrude_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # surfaceEntities 的曲面拉伸沿用实体特征已 lower 的距离,但始终独立登记为 + # shell。它既不改变 active solid,也不以曲面参与实体 fuse/cut。 + sketch = session.sketches.get(str(node.sketch_id)) + if sketch is None: + raise ValueError("surface extrude has no resolved sketch") + direction = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal") + if bool(node.params.get("reverse")): + direction = vector_scale(direction, -1) + distance = float(node.params.get("distance_mm") or 0.0) + if distance <= 0: + raise ValueError("surface extrude requires distance_mm > 0") + wires = session.adapter.surface_wires_for_sketch(sketch) + surface = session.adapter.extrude_surface(wires, vector_scale(direction, distance)) + reverse_distance = float(node.params.get("reverse_distance_mm") or 0.0) + if reverse_distance > 0: + opposite = session.adapter.extrude_surface(wires, vector_scale(direction, -reverse_distance)) + surface = session.adapter.combine_surfaces(surface, opposite) + surface_id = session.register_surface(node.feature_id, surface) + return session.result(node, include_body=False, surface_id=surface_id) + + +@atomic_executor("extrude_surface") +def _extrude_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_extrude_surface(node, session) diff --git a/backend/engine/cdsl_engine/extents.py b/backend/engine/cdsl_engine/extents.py new file mode 100644 index 00000000..1df0a516 --- /dev/null +++ b/backend/engine/cdsl_engine/extents.py @@ -0,0 +1,231 @@ +"""Extrusion/termination-condition planning for the session runtime. + +These helpers turn a CDSL feature's end condition (blind, mid-plane, +through-all, up-to-surface, ...) into one or more :class:`ExtentVector` +displacements. They depend on the session only through its adapter and +selector resolution, never on executors. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from .runtime_base import ExtentVector, FeatureExecutionError +from .specs import PlaneSpec, Vector3, vector_dot, vector_scale, vector_subtract, vector_unit +from .topology import FeaturePlanNode + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from .session import ExecutionSession + + +def _normal_from_sketch(sketch: dict[str, Any]) -> Vector3: + return PlaneSpec.from_mapping(sketch.get("workplane") or {}).normal + + +def _extent_reference(node: FeaturePlanNode, condition: dict[str, Any] | None = None) -> dict[str, Any]: + condition = condition or node.params.get("end_condition") or {} + reference = condition.get("reference") + if not isinstance(reference, dict): + raise FeatureExecutionError( + "missing_extent_reference", + "This end condition requires a captured target selector", + extent=condition.get("type"), + ) + return reference + + +def _targeted_extent_vector( + node: FeaturePlanNode, + faces: list[Any], + direction: Vector3, + session: "ExecutionSession", + condition: str, + *, + end_condition: dict[str, Any] | None = None, + offset_mm: float | None = None, +) -> ExtentVector: + if session.body is None: + raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition) + if condition == "through_next": + target = session.body + else: + reference = _extent_reference(node, end_condition) + resolution = session.resolve(reference) + if resolution.status != "resolved" or resolution.record is None: + raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "extent target was not resolved") + expected_kind = {"up_to_vertex": "vertex", "up_to_body": "body"}.get(condition, "face") + if resolution.record.kind != expected_kind: + raise FeatureExecutionError( + "unsupported_extent_target", + "The resolved target kind is incompatible with this end condition", + extent=condition, expected_kind=expected_kind, actual_kind=resolution.record.kind, + ) + target = resolution.record.value + if condition == "up_to_vertex": + target_point = session.adapter.vertex_coordinates(target) + projections = [ + vector_dot(vector_subtract(target_point, point), direction) + for face in faces + for point in session.adapter.profile_sample_points(face) + ] + if not projections or min(projections) <= 1e-6: + raise FeatureExecutionError("extent_target_not_in_direction", "The target vertex is not ahead of the profile", extent=condition) + if max(projections) - min(projections) > 1e-5: + raise FeatureExecutionError("non_uniform_extent_target", "The target vertex does not define one extrusion distance", extent=condition) + distance = sum(projections) / len(projections) + else: + if condition == "up_to_surface" and session.adapter.profile_touches_target(target, faces): + # 草图轮廓本身就在所选终止面上时,selected face 只是拉伸的起始 + # 边界。应沿实际拉伸方向穿过当前 body,取下一张完整截获 profile + # 的边界面作为终止面;直接裁剪到 selected face 会生成零厚度工具体。 + try: + next_face = session.adapter.next_body_face_after( + session.body, faces, direction, excluded_face=target, + ) + except ValueError as error: + raise FeatureExecutionError("extent_target_not_reached", str(error), extent=condition) from error + return ExtentVector(vector_scale(direction, 1.0), trim_to=next_face) + try: + distance = session.adapter.uniform_intersection_distance(target, faces, direction) + except ValueError as error: + message = str(error) + code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached" + if condition in {"up_to_surface", "through_next"}: + # #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未 + # 命中目标 → 悬空;或各点命中距离不一 → 斜目标面)时不再整体 + # 拒绝,而是"裁剪"——只保留从 profile 到目标面之间的材料。 + # extrude_trimmed 内部做穿透拉伸 + 与目标面体层布尔求交,未达 + # 目标的部分被切掉(CAD "拉伸到面"标准语义)。若全部采样点都 + # 未命中(profile 与目标面无交叠),extrude_trimmed 内部仍抛 + # "not reached",保持显式拒绝。 + # through_next 从当前主体中选取实际命中的下一张面; + # up_to_vertex/up_to_body/offset_from_surface 无 face 可构造 + # 裁剪体层,仍保持显式拒绝。 + return ExtentVector(vector_scale(direction, 1.0), trim_to=target) + raise FeatureExecutionError(code, message, extent=condition) from error + offset = abs(float((end_condition or {}).get("offset_mm") or 0.0)) + if condition == "offset_from_surface": + offset = abs(float(offset_mm if offset_mm is not None else offset or node.params.get("distance_mm") or 0.0)) + if offset: + distance -= offset + if distance <= 1e-6: + raise FeatureExecutionError( + "invalid_extent_offset", + "Offset distance reaches or passes the target extent", + extent=condition, offset_mm=offset, + ) + return ExtentVector(vector_scale(direction, distance)) + + +def _side_extent_vectors( + node: FeaturePlanNode, + faces: list[Any], + direction: Vector3, + session: "ExecutionSession", + *, + end_condition: dict[str, Any], + distance_mm: float, +) -> list[ExtentVector]: + """Resolve one directional extent without borrowing the opposite side. + + ``extrude_add_two_sided`` and ``extrude_cut_two_sided`` call this once for each independently captured + termination. The regular one-sided executor also uses it for all simple + termination modes, keeping the geometry adapter interface uniform. + """ + condition = str(end_condition.get("type") or "blind") + distance = abs(float(distance_mm or 0.0)) + if condition == "blind": + if distance <= 0: + raise ValueError("blind extent requires distance_mm > 0") + return [ExtentVector(vector_scale(direction, distance))] + if condition == "mid_plane": + if distance <= 0: + raise ValueError("mid_plane extent requires distance_mm > 0") + return [ + ExtentVector(vector_scale(direction, distance / 2)), + ExtentVector(vector_scale(direction, -distance / 2)), + ] + if condition == "through_all": + if session.body is None: + if distance <= 0: + raise ValueError("through_all on an initial feature has no body and no fallback distance") + # 注意:Vector3 是 tuple,不能直接做 direction * distance(那是元组 + # 重复),这里必须用 vector_scale 做数乘(顺带修复的隐藏 bug)。 + return [ExtentVector(vector_scale(direction, distance))] + return [ExtentVector(vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0))] + if condition in {"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body"}: + return [ + _targeted_extent_vector( + node, faces, direction, session, condition, + end_condition=end_condition, offset_mm=distance, + ) + ] + raise ValueError(f"unsupported directional extent {condition!r}") + + +def _extent_vectors( + node: FeaturePlanNode, + faces: list[Any], + sketch: dict[str, Any], + session: "ExecutionSession", +) -> list[ExtentVector]: + return _extent_vectors_from_normal( + node, faces, vector_unit(_normal_from_sketch(sketch), field_name="sketch normal"), session, + ) + + +def _extent_vectors_from_normal( + node: FeaturePlanNode, + faces: list[Any], + profile_normal: Vector3, + session: "ExecutionSession", +) -> list[ExtentVector]: + """Resolve extents from an explicit profile normal. + + A derived profile can be an actual B-rep face rather than a sketch. Its + outward normal is just as authoritative as a sketch workplane normal, so + both profile sources share the same bounded extent semantics. + """ + params = node.params + normal = vector_unit(profile_normal, field_name="profile normal") + if bool(params.get("reverse")): + normal = vector_scale(normal, -1) + end_condition = params.get("end_condition") or {"type": "blind"} + condition = end_condition.get("type", "blind") + distance = abs(float(params.get("distance_mm") or 0.0)) + if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"} or bool(params.get("two_sided")): + reverse_condition = params.get("reverse_end_condition") or {"type": "blind"} + reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0)) + if reverse_distance <= 0: + raise ValueError("two-sided extrusion requires reverse_distance_mm > 0") + return [ + *_side_extent_vectors( + node, faces, normal, session, end_condition=end_condition, distance_mm=distance, + ), + *_side_extent_vectors( + node, faces, vector_scale(normal, -1), session, + end_condition=reverse_condition, distance_mm=reverse_distance, + ), + ] + if condition in {"through_all", "through_all_both", "through_all_and_blind"}: + if session.body is None: + # A first feature with through-all has no body to terminate + # against. The source must provide a usable blind component. + if distance <= 0: + raise ValueError("through_all on an initial feature has no body and no fallback distance") + return [ExtentVector(vector_scale(normal, distance))] + span = max(session.adapter.body_span(session.body, normal), 1.0) + 2.0 + if condition == "through_all": + return [ExtentVector(vector_scale(normal, span))] + if condition == "through_all_both": + return [ExtentVector(vector_scale(normal, span)), ExtentVector(vector_scale(normal, -span))] + # Through-all-and-blind is represented by a through direction plus + # its captured opposite blind direction when available. + reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0)) + return [ + ExtentVector(vector_scale(normal, span)), + ExtentVector(vector_scale(normal, -(reverse_distance or span))), + ] + return _side_extent_vectors( + node, faces, normal, session, end_condition=end_condition, distance_mm=distance, + ) diff --git a/backend/engine/cdsl_engine/legacy/__init__.py b/backend/engine/cdsl_engine/legacy/__init__.py new file mode 100644 index 00000000..9e0d2f3c --- /dev/null +++ b/backend/engine/cdsl_engine/legacy/__init__.py @@ -0,0 +1,13 @@ +"""Frozen legacy engine paths. + +``llm_compiler`` (thin CDSL to build_pack) and ``llm_engine`` (build_pack +executor) predate the session-based runtime and are not used by +``run_cdsl_only``. ``exact_rebuild`` hosts the SolidWorks-exact fallback +rebuilds and the project-specific geometric compensations. + +Do not extend these modules; new capability belongs in the session runtime +(``executors/``) or the schema contracts. The top-level ``llm_compiler.py`` +and ``llm_engine.py`` shims keep every historical import path working. +""" + +from __future__ import annotations diff --git a/backend/engine/cdsl_engine/legacy/exact_rebuild.py b/backend/engine/cdsl_engine/legacy/exact_rebuild.py new file mode 100644 index 00000000..db9db6d9 --- /dev/null +++ b/backend/engine/cdsl_engine/legacy/exact_rebuild.py @@ -0,0 +1,253 @@ +"""SolidWorks-exact fallback rebuilds and project-specific compensations. + +``_run_parameterized`` and ``_run_exact`` replay SolidWorks rebuilds through +generated build123d scripts (``compiler_context``). They are the frozen +exact/parameterized legs of ``rebuild.run_rebuild``; the production path is +``run_cdsl_only`` in the session runtime. + +``_apply_geometric_compensations`` is a project-specific workaround for a +feature the SolidWorks export misses. It intentionally lives beside the +legacy paths so a clean checkout of this engine in another project can drop +it without touching the generic code. +""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path +from typing import Any + +from ..sketch_solver import resolve_all_sketches +from ..translator import generate_build123d_code + + +def _run_parameterized(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: + """参数化路径: CDSL语义结构 + compiler_context精确数据 → translator生成代码 → 执行 + + 采用双层IR架构: + Learning IR (CDSL) 提供参数化形状、特征结构 + Execution IR (compiler_context) 提供精确坐标 + translator 提供经过充分测试的代码生成 + """ + + import subprocess + import tempfile, os + + t0 = time.time() + + # 1. 获取 compiler_context (Execution IR: 精确坐标) + compiler_context = cdsl.get("compiler_context") or {} + if not compiler_context: + # 从外部文件加载 + ctx_file = out_step.parent / "{}.compiler_context.json".format(cdsl.get("part_id", "")) + if ctx_file.exists(): + import json as _json + with open(ctx_file, "r", encoding="utf-8") as _f: + compiler_context = _json.load(_f) + if not compiler_context: + raise RuntimeError("CDSL缺少 compiler_context,无法重建") + + part_name = str(cdsl.get("part_id") or out_step.stem) + context = dict(compiler_context) + context.setdefault("metadata", {})["part_name"] = part_name + + # 2. 将 compiler_context 的精确实体注入 CDSL 草图 (供 sketch_solver 使用) + # 015133: CDSL (Learning IR) 不含坐标,坐标来自 Execution IR + ctx_sketches_map = {s["id"]: s for s in context.get("sketches", [])} + cdsl_sketches = cdsl.get("geometry", {}).get("sketches", []) + for sk in cdsl_sketches: + ctx_sk = ctx_sketches_map.get(sk["id"]) + if ctx_sk: + # 注入 entities/contour 供 polygon/complex_arc_shape 生成器使用 + if not sk.get("entities"): + sk["entities"] = ctx_sk.get("entities", []) + if not sk.get("contour_edges_mm"): + sk["contour_edges_mm"] = ctx_sk.get("contour_edges_mm", []) + + # 3. 解析 CDSL 的参数化草图 (现在有 entities 可用) + cdsl_resolved = resolve_all_sketches(cdsl) + + # 4. 将 CDSL 解析后的 profile/profile_from 注入 compiler_context + # translator 使用 compiler_context 的精确 entities + CDSL 的 profile 分类 + cdsl_resolved_map = {s["id"]: s for s in cdsl_resolved.get("geometry", {}).get("sketches", [])} + ctx_sketches = list(context.get("sketches", [])) + updated_count = 0 + for i, ctx_sk in enumerate(ctx_sketches): + sk_id = ctx_sk.get("id", "") + cdsl_sk = cdsl_resolved_map.get(sk_id) + if cdsl_sk and cdsl_sk.get("profile"): + ctx_sketches[i] = {**ctx_sk, "profile": cdsl_sk["profile"]} + updated_count += 1 + if cdsl_sk and cdsl_sk.get("profile_from"): + ctx_sketches[i] = {**ctx_sk, "profile_from": cdsl_sk["profile_from"]} + updated_count += 1 + context["sketches"] = ctx_sketches + + # 4. 使用 compiler_context 的原始 operations(保持 translator 兼容性) + + # 5. 读取 gold volume + gold_volume_mm3 = None + if gold_step and gold_step.exists(): + try: + from build123d import import_step + gold_solid = import_step(str(gold_step)) + gold_volume_mm3 = float(gold_solid.volume) + except Exception: + pass + + # 6. 用 translator 生成并执行 + code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3) + + # 6b. 应用几何补偿 (SW导出缺失的特征) + part_id = str(cdsl.get("part_id") or "") + code = _apply_geometric_compensations(code, part_id) + + out_step.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as tf: + tf.write(code) + script_path = tf.name + + try: + r = subprocess.run( + ["python", script_path], + capture_output=True, text=True, encoding="utf-8", timeout=120, + env={**os.environ, "PYTHONIOENCODING": "utf-8"}, + ) + if r.returncode != 0: + raise RuntimeError(f"Build script failed:\n{r.stderr}") + finally: + try: + os.unlink(script_path) + except Exception: + pass + + # 7. 读取重建结果 + out_step.parent.mkdir(parents=True, exist_ok=True) + built_step = Path(part_name + ".step") + if not built_step.exists(): + built_step = Path.cwd() / (part_name + ".step") + if built_step.exists(): + import shutil + shutil.copy2(str(built_step), str(out_step)) + built_step.unlink() + else: + raise RuntimeError(f"No STEP output found: {part_name}.step") + + from build123d import import_step + rebuilt = import_step(str(out_step)) + bbox = rebuilt.bounding_box() + bbox_mm = { + "min": [bbox.min.X, bbox.min.Y, bbox.min.Z], + "max": [bbox.max.X, bbox.max.Y, bbox.max.Z], + } + + elapsed = time.time() - t0 + return { + "out_step": str(out_step), + "volume_mm3": float(rebuilt.volume), + "bbox_mm": bbox_mm, + "log": [f"param: CDSL-informed translator rebuild, {updated_count} sketches updated from CDSL"], + "engine": "parameterized", + "elapsed_s": round(elapsed, 1), + } + + +def _run_exact(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: + """精确路径: generate_build123d_code (后备)""" + + import subprocess + + compiler_context = cdsl.get("compiler_context") or {} + part_name = str(cdsl.get("part_id") or out_step.stem) + context = dict(compiler_context) + context.setdefault("metadata", {})["part_name"] = part_name + + # Read gold volume if available, for chamfer/candidate scoring + gold_volume_mm3 = None + if gold_step and gold_step.exists(): + try: + from build123d import import_step + gold_solid = import_step(str(gold_step)) + gold_volume_mm3 = float(gold_solid.volume) + except Exception: + pass + + out_step.parent.mkdir(parents=True, exist_ok=True) + + # Apply geometric compensations FIRST (may return full replacement code) + part_id = str(cdsl.get("part_id") or "") + compensation_code = _apply_geometric_compensations("", part_id) + + if compensation_code and "build123d" in compensation_code and "__main__" in compensation_code: + # 完整替换代码 (跳过generate_build123d_code) + code = compensation_code + else: + code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3) + code = _apply_geometric_compensations(code, part_id) + + t0 = time.time() + script_path = out_step.parent / "_tmp" / f"build_{part_name}_{int(time.time())}.py" + script_path.parent.mkdir(exist_ok=True) + script_path.write_text(code, encoding="utf-8") + + completed = subprocess.run( + [sys.executable, str(script_path)], + cwd=out_step.parent, + capture_output=True, + text=True, + timeout=600, + ) + + if completed.returncode != 0: + raise RuntimeError( + f"Exact compiler FAILED (rc={completed.returncode})\n" + f"STDOUT:\n{completed.stdout[-2000:]}\n" + f"STDERR:\n{completed.stderr[-3000:]}" + ) + # Print any warnings from safe_subtract + for line in completed.stdout.split('\n'): + if 'SUBTRACT' in line or 'UNION' in line: + print(f" {line.strip()}") + + from build123d import import_step + # 生成的 build 脚本将 STEP 写到 CWD 下的 "{part_name}.step" + # 移到 out_step 位置以供后续对比 + actual_step = out_step.parent / f"{part_name}.step" + if actual_step.exists(): + import shutil + shutil.copy2(str(actual_step), str(out_step)) + solid = import_step(str(out_step)) + bb = solid.bounding_box() + elapsed = time.time() - t0 + + return { + "out_step": str(out_step), + "volume_mm3": float(solid.volume), + "bbox_mm": {"min": [bb.min.X, bb.min.Y, bb.min.Z], + "max": [bb.max.X, bb.max.Y, bb.max.Z]}, + "engine": "exact", + "elapsed_s": round(elapsed, 1), + } + + +# ═══════════════════════════════════════════════════════════════ +# Geometric compensations(项目特例;拷贝到其他项目时可删) +# ═══════════════════════════════════════════════════════════════ + +def _apply_geometric_compensations(code: str, part_id: str) -> str: + """为SW导出中缺失的特征添加几何补偿切操作""" + if part_id == "113246": + if "export_step(result, " not in code: + return code + comp = ( + " # === COMPENSATION: 侧槽 (SW缺失特征) ===\n" + " with BuildSketch(Plane(origin=(-70.0, -13.0, 10.0), " + "x_dir=(0.0, 1.0, 0.0), z_dir=(1.0, 0.0, 0.0))) as comp_sk:\n" + " Rectangle(10.0, 3.0, align=(Align.MIN, Align.MIN))\n" + " comp_cutter = extrude(comp_sk.sketch, amount=10.0)\n" + " result = safe_subtract(result, comp_cutter)\n" + ) + code = code.replace("export_step(result, ", comp + " export_step(result, ") + return code diff --git a/backend/engine/cdsl_engine/legacy/llm_compiler.py b/backend/engine/cdsl_engine/legacy/llm_compiler.py new file mode 100644 index 00000000..b3875fe7 --- /dev/null +++ b/backend/engine/cdsl_engine/legacy/llm_compiler.py @@ -0,0 +1,287 @@ +"""通用编译器:瘦 CDSL → build_pack;线性阵列在此展开为重复步骤。""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import Any + +try: + from ..sketch_solver import resolve_all_sketches +except ImportError: + from sketch_solver import resolve_all_sketches + + +REQUIRED = { + "revolve_add": ["angle_deg", "axis"], + "revolve_cut": ["angle_deg", "axis"], + "extrude_add_blind": ["distance_mm"], + "extrude_add_two_sided": ["distance_mm"], + "extrude_cut_blind": ["distance_mm"], + "hole_blind": ["diameter_mm", "depth_mm"], + "hole_countersink": ["diameter_mm", "depth_mm"], + "hole_counterbore": ["diameter_mm", "depth_mm"], + "sphere_add": ["radius_mm", "center_mm"], +} + + +def _load(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _offset_sketch(sketch: dict[str, Any] | None, dx: float, dy: float, dz: float) -> dict[str, Any] | None: + if sketch is None: + return None + s = deepcopy(sketch) + wp = s.get("workplane") or {} + o = list(wp.get("origin_mm") or [0, 0, 0]) + wp["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz] + s["workplane"] = wp + edges = [] + for e in s.get("contour_edges_mm") or []: + ne = deepcopy(e) + for key in ("start_mm", "end_mm", "center_mm"): + if key in ne: + p = ne[key] + ne[key] = [p[0] + dx, p[1] + dy, p[2] + dz] + edges.append(ne) + if edges: + s["contour_edges_mm"] = edges + # 2D entities: shift in plane if offset has in-plane components only — skip for world offset patterns + return s + + +def _offset_params_positions(params: dict[str, Any], dx: float, dy: float, dz: float) -> dict[str, Any]: + p = deepcopy(params) + if "positions" in p: + for pos in p["positions"]: + mm = pos.get("mm") + if mm: + pos["mm"] = [mm[0] + dx, mm[1] + dy, mm[2] + dz] + if "axis" in p and isinstance(p["axis"], dict): + o = list(p["axis"].get("origin_mm") or [0, 0, 0]) + p["axis"]["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz] + return p + + +def compile_cdsl( + cdsl: dict[str, Any], + atoms_catalog: dict[str, Any] | None = None, + techniques_catalog: dict[str, Any] | None = None, +) -> dict[str, Any]: + allowed = set() + if atoms_catalog: + allowed = {a["atomic_id"] for a in atoms_catalog.get("atoms") or []} + techniques = { + item["technique_id"]: item + for item in (techniques_catalog or {}).get("techniques") or [] + } + + sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []} + + # 参数化轮廓求解:将 profile 字段展开为精确的 entities + contour_edges_mm + cdsl = resolve_all_sketches(cdsl) + sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []} + steps: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + # feature_id -> list of emitted step dicts (for pattern source) + emitted: dict[str, list[dict[str, Any]]] = {} + + def emit(feature: dict[str, Any], params: dict[str, Any], sketch: dict[str, Any] | None, step_id: str) -> dict[str, Any]: + atomic = feature["atomic_id"] + if allowed and atomic not in allowed: + raise ValueError(f"{step_id}: atomic_id {atomic!r} is not admitted by catalog") + for dep in feature.get("depends_on") or []: + if dep not in seen_ids and not any(dep in emitted): + # dependency may be ok if earlier + if dep not in seen_ids: + raise ValueError(f"{step_id}: depends_on {dep} not yet defined") + step = { + "step_id": step_id, + "atomic_id": atomic, + "depends_on": list(feature.get("depends_on") or []), + "params": params, + "sketch": sketch, + "source_name": feature.get("name"), + } + steps.append(step) + seen_ids.add(step_id) + return step + + for feat in cdsl.get("features") or []: + fid = feat["id"] + atomic = feat.get("atomic_id") + technique_id = feat.get("technique_id") + if technique_id: + technique = techniques.get(technique_id) + if technique is None: + raise ValueError(f"{fid}: technique_id {technique_id!r} is not admitted by catalog") + groups = feat.get("params") or {} + expanded: list[dict[str, Any]] = [] + previous_step_id: str | None = None + for index, internal in enumerate(technique.get("internal_steps") or [], start=1): + group_name = internal.get("params_from") + group = deepcopy(groups.get(group_name) or {}) + if not isinstance(group, dict): + raise ValueError(f"{fid}: parameter group {group_name!r} must be an object") + params = deepcopy(group.get("params") if isinstance(group.get("params"), dict) else group) + sketch_id = group.get("sketch_id") or params.pop("sketch_id", None) + sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None + internal_atomic = internal.get("atomic_id") + if not internal_atomic: + raise ValueError(f"{fid}: technique {technique_id!r} has an invalid internal step") + for key in REQUIRED.get(internal_atomic, []): + if params.get(key) is None: + raise ValueError( + f"{fid}: technique {technique_id!r} group {group_name!r} missing {key}" + ) + internal_feature = { + "atomic_id": internal_atomic, + "depends_on": [previous_step_id] if previous_step_id else list(feat.get("depends_on") or []), + "name": f"{feat.get('name') or technique_id}:{group_name or index}", + } + step_id = f"{fid}.t{index}" + expanded.append(emit(internal_feature, params, sketch, step_id)) + previous_step_id = step_id + if len(expanded) < 2: + raise ValueError(f"{fid}: technique {technique_id!r} must expand to at least two steps") + emitted[fid] = expanded + seen_ids.add(fid) + continue + if not atomic: + raise ValueError(f"{fid}: missing atomic_id") + + if atomic == "pattern_linear": + params = feat.get("params") or {} + src_ids = params.get("source_feature_ids") or [] + c1 = int(params.get("pattern_count_1") or 1) + c2 = int(params.get("pattern_count_2") or 1) + s1 = float(params.get("spacing_1_mm") or 0) + s2 = float(params.get("spacing_2_mm") or 0) + d1 = params.get("direction_1") or [1, 0, 0] + d2 = params.get("direction_2") or [0, 1, 0] + if params.get("direction_1_reverse"): + d1 = [-d1[0], -d1[1], -d1[2]] + if params.get("direction_2_reverse"): + d2 = [-d2[0], -d2[1], -d2[2]] + + src_steps: list[dict[str, Any]] = [] + for sid in src_ids: + src_steps.extend(emitted.get(sid) or []) + if not src_steps: + # 无源则跳过并记录 + steps.append( + { + "step_id": fid, + "atomic_id": "noop_pattern", + "depends_on": list(feat.get("depends_on") or []), + "params": params, + "sketch": None, + "note": "pattern source steps missing", + } + ) + seen_ids.add(fid) + continue + + clone_steps = [] + k = 0 + for i in range(c1): + for j in range(c2): + if i == 0 and j == 0: + continue + dx = d1[0] * s1 * i + d2[0] * s2 * j + dy = d1[1] * s1 * i + d2[1] * s2 * j + dz = d1[2] * s1 * i + d2[2] * s2 * j + for src in src_steps: + k += 1 + clone_id = f"{fid}.p{k}" + fake_feat = { + "atomic_id": src["atomic_id"], + "depends_on": [steps[-1]["step_id"]] if steps else [], + "name": f"{src.get('source_name')}_pattern", + } + st = emit( + fake_feat, + _offset_params_positions(src["params"], dx, dy, dz), + _offset_sketch(src.get("sketch"), dx, dy, dz), + clone_id, + ) + clone_steps.append(st) + emitted[fid] = clone_steps + seen_ids.add(fid) + continue + + params = deepcopy(feat.get("params") or {}) + sketch_id = feat.get("sketch_id") or params.get("sketch_id") + sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None + if sketch_id: + params["sketch_id"] = sketch_id + + # Auto-derive revolve axis origin + if "revolve" in atomic and sketch and "axis" in params: + ax = params.get("axis") or {} + # 优先级: from_workplane_origin > from_contour_vertex > origin_mm 裸坐标 + wp = sketch.get("workplane") or {} + wp_origin = wp.get("origin_mm") or [0.0, 0.0, 0.0] + + if ax.get("from_workplane_origin") and "origin_mm" not in ax: + params["axis"] = deepcopy(params["axis"]) + params["axis"]["origin_mm"] = list(wp_origin) + elif "origin_mm" not in ax: + ce = sketch.get("contour_edges_mm") or [] + if ce: + idx = int(ax.get("from_contour_vertex", 0)) + vertex = ce[idx % len(ce)]["start_mm"] + params["axis"] = deepcopy(params["axis"]) + params["axis"]["origin_mm"] = list(vertex) + + for key in REQUIRED.get(atomic, []): + if key == "axis" and "axis" not in params: + raise ValueError(f"{fid}: missing axis") + if key not in ("axis",) and params.get(key) is None and key != "sketch_id": + # positions can be empty temporarily + if key in params: + continue + if key in ("diameter_mm", "depth_mm", "distance_mm", "angle_deg") and params.get(key) is None: + raise ValueError(f"{fid}: missing {key}") + + st = emit(feat, params, sketch, fid) + emitted[fid] = [st] + + # filter noop + steps = [s for s in steps if s.get("atomic_id") != "noop_pattern"] + + return { + "schema": "cad.engine_plan.v1", + "part_id": cdsl.get("part_id"), + "unit": "mm", + "steps": steps, + "compiler_context": deepcopy(cdsl.get("compiler_context")), + "meta": { + "from_cdsl_schema": cdsl.get("schema"), + "compiler": "cad-heard.llm_compiler.v1", + "n_steps": len(steps), + }, + } + + +def main() -> None: + import argparse + + ap = argparse.ArgumentParser() + ap.add_argument("--cdsl", type=Path, required=True) + ap.add_argument("--catalog", type=Path, default=None) + ap.add_argument("--techniques", type=Path, default=None) + ap.add_argument("--out", type=Path, required=True) + args = ap.parse_args() + catalog = _load(args.catalog) if args.catalog else None + techniques = _load(args.techniques) if args.techniques else None + pack = compile_cdsl(_load(args.cdsl), catalog, techniques) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(pack, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"wrote {args.out} steps={len(pack['steps'])}") + + +if __name__ == "__main__": + main() diff --git a/backend/engine/cdsl_engine/legacy/llm_engine.py b/backend/engine/cdsl_engine/legacy/llm_engine.py new file mode 100644 index 00000000..bc913eeb --- /dev/null +++ b/backend/engine/cdsl_engine/legacy/llm_engine.py @@ -0,0 +1,555 @@ +"""build123d 绘图引擎:执行 build_pack → STEP。""" + +from __future__ import annotations + +import builtins +import json +import math +import subprocess +import sys +from pathlib import Path +from typing import Any + +# 保留内置 float,防止被 build123d 上下文 shadow +_f = builtins.float + +from build123d import ( # noqa: E402 + Align, + Axis, + BuildPart, + BuildSketch, + Circle, + Cone, + Cylinder, + Edge, + Face, + Location, + Locations, + Mode, + Plane, + Polygon, + Sphere, + Vector, + Wire, + export_step, + extrude, + import_step, + revolve, +) + + +# Keep this in sync with the execution branches in run_engine_plan. The +# agent-facing schema and its parity test prevent unsupported names reaching +# this low-level dispatcher. +SUPPORTED_ATOMIC_IDS = frozenset({ + "extrude_add_blind", + "extrude_add_two_sided", + "extrude_cut_blind", + "revolve_add", + "revolve_cut", + "hole_blind", + "hole_countersink", + "hole_counterbore", + "sphere_add", + "reference_plane", + "reference_axis", +}) + + +def _load(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _plane_from_workplane(wp: dict[str, Any]) -> Plane: + o = wp.get("origin_mm") or [0, 0, 0] + x = wp.get("x_dir") or [1, 0, 0] + n = wp.get("normal") or [0, 0, 1] + return Plane( + origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])), + x_dir=Vector(_f(x[0]), _f(x[1]), _f(x[2])), + z_dir=Vector(_f(n[0]), _f(n[1]), _f(n[2])), + ) + + +def _axis_from_params(axis: dict[str, Any]) -> Axis: + o = axis.get("origin_mm") or [0, 0, 0] + d = axis.get("direction") or [1, 0, 0] + return Axis( + origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])), + direction=Vector(_f(d[0]), _f(d[1]), _f(d[2])), + ) + + +def _ordered_profile_points(sketch: dict[str, Any]) -> list[tuple[float, float]]: + entities = sketch.get("entities") or [] + line_loop = [ + i for i, e in enumerate(entities) if e["type"] == "line" and not e.get("construction") + ] + if not line_loop: + raise ValueError(f"sketch {sketch.get('id')}: no profile lines") + pts: list[tuple[float, float]] = [] + for i in line_loop: + e = entities[i] + s = (_f(e["start"][0]), _f(e["start"][1])) + en = (_f(e["end"][0]), _f(e["end"][1])) + if not pts: + pts.append(s) + if abs(pts[-1][0] - s[0]) + abs(pts[-1][1] - s[1]) > 1e-4: + if abs(pts[-1][0] - en[0]) + abs(pts[-1][1] - en[1]) <= 1e-4: + s, en = en, s + else: + pts.append(s) + pts.append(en) + if abs(pts[0][0] - pts[-1][0]) + abs(pts[0][1] - pts[-1][1]) > 1e-4: + pts.append(pts[0]) + return pts + + +def _arc_midpoint(edge: dict[str, Any], p1: Vector, p2: Vector, center: Vector, radius: float) -> Vector: + """Return a point on the intended directed arc for ``make_three_point_arc``. + + Legacy contour data has no sweep direction and retains its prior shortest + arc behavior. Evidence-v2 analytic contours carry ``clockwise`` so a + major arc or a clockwise arc cannot be silently inverted by the adapter. + """ + v1 = p1 - center + v2 = p2 - center + if v1.length < 1e-9 or v2.length < 1e-9: + return (p1 + p2) / 2 + n = Vector(*(edge.get("normal") or [0, 0, 1])) + if n.length < 1e-9: + n = v1.cross(v2) + if n.length < 1e-9: + n = Vector(0, 0, 1) + n = n.normalized() + v1n = v1.normalized() * radius + if "clockwise" not in edge: + bisector = v1n + v2.normalized() * radius + if bisector.length < 1e-9: + bisector = n.cross(v1n) + return center + bisector.normalized() * radius + sweep = math.atan2(n.dot(v1.cross(v2)), v1.dot(v2)) + if bool(edge["clockwise"]): + if sweep >= 0: + sweep -= math.tau + elif sweep <= 0: + sweep += math.tau + half = sweep / 2 + midpoint_vector = v1n * math.cos(half) + n.cross(v1n) * math.sin(half) + return center + midpoint_vector + + +def _face_from_contour_edges(edges_mm: list[dict[str, Any]], *, desired_normal: list[float] | None = None) -> Face: + b123_edges: list[Edge] = [] + for e in edges_mm: + p1 = Vector(*e["start_mm"]) + p2 = Vector(*e["end_mm"]) + if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None: + center = Vector(*e["center_mm"]) + r = _f(e["radius_mm"]) + v1 = p1 - center + v2 = p2 - center + if v1.length < 1e-9 or v2.length < 1e-9: + b123_edges.append(Edge.make_line(p1, p2)) + continue + mid = _arc_midpoint(e, p1, p2, center, r) + try: + b123_edges.append(Edge.make_three_point_arc(p1, mid, p2)) + except Exception: + b123_edges.append(Edge.make_line(p1, p2)) + else: + b123_edges.append(Edge.make_line(p1, p2)) + face = Face(Wire(b123_edges)) + if desired_normal is not None: + dn = Vector(*desired_normal) + if dn.length > 1e-9: + fn = face.normal_at() + if fn.dot(dn) < 0: + # 重建反转的 Wire:边顺序反转 + 每条边起止点交换 + # 这样法向自然翻转,但每条边的几何方向不变(不同于 Face.Reversed) + rev_edges: list[Edge] = [] + for e in reversed(edges_mm): + p1 = Vector(*e["end_mm"]) + p2 = Vector(*e["start_mm"]) + if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None: + center = Vector(*e["center_mm"]) + r = _f(e["radius_mm"]) + v1 = p1 - center + v2 = p2 - center + if v1.length < 1e-9 or v2.length < 1e-9: + rev_edges.append(Edge.make_line(p1, p2)) + continue + mid = _arc_midpoint(e, p1, p2, center, r) + try: + rev_edges.append(Edge.make_three_point_arc(p1, mid, p2)) + except Exception: + rev_edges.append(Edge.make_line(p1, p2)) + else: + rev_edges.append(Edge.make_line(p1, p2)) + face = Face(Wire(rev_edges)) + return face + + +def _amount(params: dict[str, Any], *, prefer_sign: str | None = None) -> float: + dist = abs(_f(params["distance_mm"])) + if prefer_sign == "plus": + return dist + if prefer_sign == "minus": + return -dist + return -dist if bool(params.get("reverse")) else dist + + +def _build_nested_circle_profiles(circles: list[dict[str, Any]]) -> None: + """Build circular islands and holes from containment parity. + + A circle contained by one larger circle is a hole; a circle contained by + two larger circles is an island again. This preserves annular profiles + without storing the heavy tessellated sketch regions from the SW export. + """ + ordered = sorted(circles, key=lambda item: _f(item["radius_mm"]), reverse=True) + tolerance = 1e-6 + for index, circle in enumerate(ordered): + center = circle["center"] + radius = _f(circle["radius_mm"]) + containing = 0 + for outer in ordered[:index]: + outer_center = outer["center"] + outer_radius = _f(outer["radius_mm"]) + distance = math.hypot( + _f(center[0]) - _f(outer_center[0]), + _f(center[1]) - _f(outer_center[1]), + ) + if distance + radius <= outer_radius + tolerance: + containing += 1 + mode = Mode.ADD if containing % 2 == 0 else Mode.SUBTRACT + with Locations((_f(center[0]), _f(center[1]))): + Circle(radius, mode=mode) + + +def run_engine_plan( + pack: dict[str, Any], + out_step: Path, + *, + cut_sign: str = "from_params", +) -> dict[str, Any]: + log: list[str] = [] + + compiler_context = pack.get("compiler_context") + if isinstance(compiler_context, dict): + # 回退路径:使用本包 translator(不依赖外部 backend.src) + try: + from .translator import generate_build123d_code, get_part_name + except ImportError: + from translator import generate_build123d_code, get_part_name + + context = dict(compiler_context) + context.setdefault("metadata", {})["part_name"] = str(pack.get("part_id") or out_step.stem) + out_step.parent.mkdir(parents=True, exist_ok=True) + completed = subprocess.run( + [sys.executable, "-c", generate_build123d_code(context)], + cwd=out_step.parent, + capture_output=True, + text=True, + timeout=180, + ) + if completed.returncode != 0: + raise RuntimeError( + f"exact compiler execution failed\nSTDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}" + ) + generated_name = get_part_name({"part_name": context["metadata"]["part_name"]}) + generated = out_step.parent / f"{generated_name}.step" + if generated != out_step and generated.exists(): + generated.replace(out_step) + if not out_step.exists(): + raise RuntimeError(f"exact compiler did not generate {out_step}") + solid = import_step(str(out_step)) + bb = solid.bounding_box() + return { + "out_step": str(out_step), + "volume_mm3": _f(solid.volume), + "bbox_mm": { + "min": [bb.min.X, bb.min.Y, bb.min.Z], + "max": [bb.max.X, bb.max.Y, bb.max.Z], + }, + "engine": "translator_fallback", + } + + with BuildPart() as part: + for step in pack.get("steps") or []: + atomic = step["atomic_id"] + params = step["params"] + sketch = step.get("sketch") + sid = step.get("step_id") + + if atomic == "reference_plane": + # Context features deliberately produce no solid. They remain + # executable plan steps so their dependencies are preserved and + # can be registered by the session-based runtime. + plane = _plane_from_workplane(params.get("plane") or {}) + log.append( + f"{sid}: reference_plane origin={tuple(plane.origin)} normal={tuple(plane.z_dir)}" + ) + + elif atomic == "reference_axis": + axis = _axis_from_params(params.get("axis") or {}) + log.append( + f"{sid}: reference_axis origin={tuple(axis.position)} direction={tuple(axis.direction)}" + ) + + elif atomic == "sphere_add": + radius = _f(params.get("radius_mm") or 0) + center = params.get("center_mm") or [0, 0, 0] + if radius <= 0 or len(center) != 3: + raise ValueError(f"{sid}: sphere_add requires a positive radius_mm and center_mm") + with Locations((_f(center[0]), _f(center[1]), _f(center[2]))): + Sphere(radius, mode=Mode.ADD) + log.append(f"{sid}: sphere_add radius={radius}") + + elif atomic in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"): + if sketch is None: + raise ValueError(f"{sid}: missing sketch") + plane = _plane_from_workplane(sketch.get("workplane") or {}) + mode = Mode.SUBTRACT if "cut" in atomic else Mode.ADD + edges = sketch.get("contour_edges_mm") or [] + regions = sketch.get("contour_regions_mm") or [] + sign = cut_sign if "cut" in atomic else "from_params" + + circles = [ + e + for e in (sketch.get("entities") or []) + if e.get("type") == "circle" and not e.get("construction") + ] + lines = [ + e + for e in (sketch.get("entities") or []) + if e.get("type") == "line" and not e.get("construction") + ] + + # 多区域轮廓(外环 + 孔):由 shape generator 展开 + if regions: + faces = [] + normal = (sketch.get("workplane") or {}).get("normal") + for reg in regions: + outer_edges = reg.get("outer") or [] + if len(outer_edges) < 2: + continue + face = _face_from_contour_edges(outer_edges, desired_normal=normal) + for hole_edges in reg.get("holes") or []: + if len(hole_edges) < 2: + continue + hole = _face_from_contour_edges(hole_edges, desired_normal=normal) + face = face.cut(hole) + faces.append(face) + if not faces: + raise ValueError(f"{sid}: contour_regions_mm produced no faces") + if atomic == "extrude_add_two_sided": + d = abs(_f(params["distance_mm"])) + for face in faces: + extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD) + else: + amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) + for face in faces: + extrude(to_extrude=face, amount=amt, mode=mode) + log.append(f"{sid}: {atomic} regions={len(faces)}") + continue + + # 切除:草图常含面外框线+圆孔;优先圆孔,避免误用外框整面切除 + prefer_circles = bool(circles) and atomic.startswith("extrude_cut") + + if prefer_circles: + with BuildSketch(plane): + for e in circles: + with Locations((_f(e["center"][0]), _f(e["center"][1]))): + Circle(_f(e["radius_mm"])) + if atomic == "extrude_add_two_sided": + d = abs(_f(params["distance_mm"])) + extrude(amount=d, both=True, mode=Mode.ADD) + else: + amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) + extrude(amount=amt, mode=mode) + log.append(f"{sid}: {atomic} circle-only n={len(circles)}") + elif len(edges) >= 2: + face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal")) + if atomic == "extrude_add_two_sided": + d = abs(_f(params["distance_mm"])) + extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD) + log.append(f"{sid}: extrude_two_sided both={d} contour") + else: + amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) + extrude(to_extrude=face, amount=amt, mode=mode) + log.append(f"{sid}: {atomic} amount={amt} contour") + elif circles and not lines: + # 纯圆轮廓:用包含层级区分实体、内孔和孔中岛。 + with BuildSketch(plane): + _build_nested_circle_profiles(circles) + if atomic == "extrude_add_two_sided": + d = abs(_f(params["distance_mm"])) + extrude(amount=d, both=True, mode=Mode.ADD) + else: + amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) + extrude(amount=amt, mode=mode) + log.append(f"{sid}: {atomic} circle-only n={len(circles)}") + else: + with BuildSketch(plane): + pts = _ordered_profile_points(sketch) + poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts + Polygon(*poly) + for e in circles: + with Locations((_f(e["center"][0]), _f(e["center"][1]))): + Circle(_f(e["radius_mm"]), mode=Mode.SUBTRACT) + if atomic == "extrude_add_two_sided": + d = abs(_f(params["distance_mm"])) + extrude(amount=d, both=True, mode=Mode.ADD) + log.append(f"{sid}: extrude_two_sided both={d} poly") + else: + amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) + extrude(amount=amt, mode=mode) + log.append(f"{sid}: {atomic} amount={amt} poly") + + elif atomic in ("revolve_add", "revolve_cut"): + if sketch is None: + raise ValueError(f"{sid}: missing sketch") + plane = _plane_from_workplane(sketch.get("workplane") or {}) + axis = _axis_from_params(params.get("axis") or {}) + angle = _f(params.get("angle_deg") or 360) + mode = Mode.SUBTRACT if atomic == "revolve_cut" else Mode.ADD + edges = sketch.get("contour_edges_mm") or [] + if len(edges) >= 2: + face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal")) + revolve(profiles=face, axis=axis, revolution_arc=angle, mode=mode) + else: + with BuildSketch(plane): + pts = _ordered_profile_points(sketch) + poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts + Polygon(*poly) + revolve(axis=axis, revolution_arc=angle, mode=mode) + log.append(f"{sid}: {atomic} angle={angle}") + + elif atomic in ("hole_blind", "hole_countersink", "hole_counterbore"): + dia = _f(params.get("diameter_mm") or 0) + depth = _f(params.get("depth_mm") or 0) + positions = params.get("positions") or [] + if sketch is not None: + plane = _plane_from_workplane(sketch.get("workplane") or {}) + else: + plane = Plane.XY + host_face = params.get("host_face") or {} + frame = host_face.get("frame") or {} + frame_origin = Vector(*(frame.get("origin_mm") or plane.origin.to_tuple())) + frame_x = Vector(*(frame.get("x_dir") or plane.x_dir.to_tuple())) + frame_y = Vector(*(frame.get("y_dir") or plane.y_dir.to_tuple())) + normal = plane.z_dir.normalized() + bb = part.part.bounding_box() + part_center = Vector( + (bb.min.X + bb.max.X) / 2, + (bb.min.Y + bb.max.Y) / 2, + (bb.min.Z + bb.max.Z) / 2, + ) + inward = normal if (part_center - frame_origin).dot(normal) >= 0 else -normal + for pos in positions: + mm = pos.get("mm") or [0, 0, 0] + start = frame_origin + frame_x * _f(mm[0]) + frame_y * _f(mm[1]) + cs_dia = _f(params.get("countersink_diameter_mm") or 0) + cs_angle = _f(params.get("countersink_angle_rad") or 0) + cb_dia = _f(params.get("counterbore_diameter_mm") or 0) + cb_depth = _f(params.get("counterbore_depth_mm") or 0) + cs_depth = ( + ((cs_dia - dia) / 2) / math.tan(cs_angle / 2) + if cs_dia > dia and cs_angle > 0 + else 0 + ) + base_offset = cs_depth + (cb_depth if cb_dia > dia else 0) + main_depth = max(0.001, abs(depth) - base_offset) + main_place = Location(Plane(origin=start + inward * base_offset, z_dir=inward)) + tools = [ + Cylinder( + radius=dia / 2, + height=main_depth, + align=(Align.CENTER, Align.CENTER, Align.MIN), + mode=Mode.PRIVATE, + ).move(main_place) + ] + if cb_dia > dia and cb_depth > 0: + tools.append( + Cylinder( + radius=cb_dia / 2, + height=cb_depth, + align=(Align.CENTER, Align.CENTER, Align.MIN), + mode=Mode.PRIVATE, + ).move(Location(Plane(origin=start, z_dir=inward))) + ) + if cs_depth > 0: + tools.append( + Cone( + bottom_radius=cs_dia / 2, + top_radius=dia / 2, + height=cs_depth, + align=(Align.CENTER, Align.CENTER, Align.MIN), + mode=Mode.PRIVATE, + ).move(Location(Plane(origin=start, z_dir=inward))) + ) + drill_angle = _f(params.get("drill_angle_rad") or 0) + if drill_angle > 0: + tip_depth = (dia / 2) / math.tan(drill_angle / 2) + tools.append( + Cone( + bottom_radius=dia / 2, + top_radius=0, + height=tip_depth, + align=(Align.CENTER, Align.CENTER, Align.MIN), + mode=Mode.PRIVATE, + ).move( + Location( + Plane(origin=start + inward * abs(depth), z_dir=inward) + ) + ) + ) + for tool in tools: + part.part = part.part.cut(tool) + log.append(f"{sid}: {atomic} npos={len(positions)}") + + else: + raise ValueError(f"unsupported atomic_id: {atomic}") + + solid = part.part + + out_step.parent.mkdir(parents=True, exist_ok=True) + export_step(solid, str(out_step)) + bb = solid.bounding_box() + return { + "out_step": str(out_step), + "volume_mm3": _f(solid.volume), + "bbox_mm": { + "min": [bb.min.X, bb.min.Y, bb.min.Z], + "max": [bb.max.X, bb.max.Y, bb.max.Z], + }, + "log": log, + "cut_sign": cut_sign, + } + + +def main() -> None: + import argparse + + ap = argparse.ArgumentParser() + ap.add_argument("--pack", type=Path, required=True) + ap.add_argument("--out-step", type=Path, required=True) + ap.add_argument("--report", type=Path, default=None) + ap.add_argument("--cut-sign", default="from_params", choices=["from_params", "plus", "minus"]) + args = ap.parse_args() + info = run_engine_plan(_load(args.pack), args.out_step, cut_sign=args.cut_sign) + if args.report: + args.report.write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8") + print( + json.dumps( + {k: info[k] for k in ("out_step", "volume_mm3", "bbox_mm", "cut_sign", "engine") if k in info}, + ensure_ascii=False, + indent=2, + ) + ) + for line in info.get("log") or []: + print(line) + + +if __name__ == "__main__": + main() diff --git a/backend/engine/cdsl_engine/llm_compiler.py b/backend/engine/cdsl_engine/llm_compiler.py index 0ec2559b..d421c881 100644 --- a/backend/engine/cdsl_engine/llm_compiler.py +++ b/backend/engine/cdsl_engine/llm_compiler.py @@ -1,287 +1,12 @@ -"""通用编译器:瘦 CDSL → build_pack;线性阵列在此展开为重复步骤。""" +"""Compatibility shim: the implementation moved to ``legacy.llm_compiler``. + +``compile_cdsl`` expands a thin CDSL document into a ``build_pack`` plan. +It predates the session-based runtime and is frozen; new capability belongs +in ``executors/`` and the schema contracts. +""" from __future__ import annotations -import json -from copy import deepcopy -from pathlib import Path -from typing import Any +from .legacy.llm_compiler import REQUIRED, compile_cdsl, main -try: - from .sketch_solver import resolve_all_sketches -except ImportError: - from sketch_solver import resolve_all_sketches - - -REQUIRED = { - "revolve_add": ["angle_deg", "axis"], - "revolve_cut": ["angle_deg", "axis"], - "extrude_add_blind": ["distance_mm"], - "extrude_add_two_sided": ["distance_mm"], - "extrude_cut_blind": ["distance_mm"], - "hole_blind": ["diameter_mm", "depth_mm"], - "hole_countersink": ["diameter_mm", "depth_mm"], - "hole_counterbore": ["diameter_mm", "depth_mm"], - "sphere_add": ["radius_mm", "center_mm"], -} - - -def _load(path: Path) -> dict[str, Any]: - return json.loads(path.read_text(encoding="utf-8")) - - -def _offset_sketch(sketch: dict[str, Any] | None, dx: float, dy: float, dz: float) -> dict[str, Any] | None: - if sketch is None: - return None - s = deepcopy(sketch) - wp = s.get("workplane") or {} - o = list(wp.get("origin_mm") or [0, 0, 0]) - wp["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz] - s["workplane"] = wp - edges = [] - for e in s.get("contour_edges_mm") or []: - ne = deepcopy(e) - for key in ("start_mm", "end_mm", "center_mm"): - if key in ne: - p = ne[key] - ne[key] = [p[0] + dx, p[1] + dy, p[2] + dz] - edges.append(ne) - if edges: - s["contour_edges_mm"] = edges - # 2D entities: shift in plane if offset has in-plane components only — skip for world offset patterns - return s - - -def _offset_params_positions(params: dict[str, Any], dx: float, dy: float, dz: float) -> dict[str, Any]: - p = deepcopy(params) - if "positions" in p: - for pos in p["positions"]: - mm = pos.get("mm") - if mm: - pos["mm"] = [mm[0] + dx, mm[1] + dy, mm[2] + dz] - if "axis" in p and isinstance(p["axis"], dict): - o = list(p["axis"].get("origin_mm") or [0, 0, 0]) - p["axis"]["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz] - return p - - -def compile_cdsl( - cdsl: dict[str, Any], - atoms_catalog: dict[str, Any] | None = None, - techniques_catalog: dict[str, Any] | None = None, -) -> dict[str, Any]: - allowed = set() - if atoms_catalog: - allowed = {a["atomic_id"] for a in atoms_catalog.get("atoms") or []} - techniques = { - item["technique_id"]: item - for item in (techniques_catalog or {}).get("techniques") or [] - } - - sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []} - - # 参数化轮廓求解:将 profile 字段展开为精确的 entities + contour_edges_mm - cdsl = resolve_all_sketches(cdsl) - sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []} - steps: list[dict[str, Any]] = [] - seen_ids: set[str] = set() - # feature_id -> list of emitted step dicts (for pattern source) - emitted: dict[str, list[dict[str, Any]]] = {} - - def emit(feature: dict[str, Any], params: dict[str, Any], sketch: dict[str, Any] | None, step_id: str) -> dict[str, Any]: - atomic = feature["atomic_id"] - if allowed and atomic not in allowed: - raise ValueError(f"{step_id}: atomic_id {atomic!r} is not admitted by catalog") - for dep in feature.get("depends_on") or []: - if dep not in seen_ids and not any(dep in emitted): - # dependency may be ok if earlier - if dep not in seen_ids: - raise ValueError(f"{step_id}: depends_on {dep} not yet defined") - step = { - "step_id": step_id, - "atomic_id": atomic, - "depends_on": list(feature.get("depends_on") or []), - "params": params, - "sketch": sketch, - "source_name": feature.get("name"), - } - steps.append(step) - seen_ids.add(step_id) - return step - - for feat in cdsl.get("features") or []: - fid = feat["id"] - atomic = feat.get("atomic_id") - technique_id = feat.get("technique_id") - if technique_id: - technique = techniques.get(technique_id) - if technique is None: - raise ValueError(f"{fid}: technique_id {technique_id!r} is not admitted by catalog") - groups = feat.get("params") or {} - expanded: list[dict[str, Any]] = [] - previous_step_id: str | None = None - for index, internal in enumerate(technique.get("internal_steps") or [], start=1): - group_name = internal.get("params_from") - group = deepcopy(groups.get(group_name) or {}) - if not isinstance(group, dict): - raise ValueError(f"{fid}: parameter group {group_name!r} must be an object") - params = deepcopy(group.get("params") if isinstance(group.get("params"), dict) else group) - sketch_id = group.get("sketch_id") or params.pop("sketch_id", None) - sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None - internal_atomic = internal.get("atomic_id") - if not internal_atomic: - raise ValueError(f"{fid}: technique {technique_id!r} has an invalid internal step") - for key in REQUIRED.get(internal_atomic, []): - if params.get(key) is None: - raise ValueError( - f"{fid}: technique {technique_id!r} group {group_name!r} missing {key}" - ) - internal_feature = { - "atomic_id": internal_atomic, - "depends_on": [previous_step_id] if previous_step_id else list(feat.get("depends_on") or []), - "name": f"{feat.get('name') or technique_id}:{group_name or index}", - } - step_id = f"{fid}.t{index}" - expanded.append(emit(internal_feature, params, sketch, step_id)) - previous_step_id = step_id - if len(expanded) < 2: - raise ValueError(f"{fid}: technique {technique_id!r} must expand to at least two steps") - emitted[fid] = expanded - seen_ids.add(fid) - continue - if not atomic: - raise ValueError(f"{fid}: missing atomic_id") - - if atomic == "pattern_linear": - params = feat.get("params") or {} - src_ids = params.get("source_feature_ids") or [] - c1 = int(params.get("pattern_count_1") or 1) - c2 = int(params.get("pattern_count_2") or 1) - s1 = float(params.get("spacing_1_mm") or 0) - s2 = float(params.get("spacing_2_mm") or 0) - d1 = params.get("direction_1") or [1, 0, 0] - d2 = params.get("direction_2") or [0, 1, 0] - if params.get("direction_1_reverse"): - d1 = [-d1[0], -d1[1], -d1[2]] - if params.get("direction_2_reverse"): - d2 = [-d2[0], -d2[1], -d2[2]] - - src_steps: list[dict[str, Any]] = [] - for sid in src_ids: - src_steps.extend(emitted.get(sid) or []) - if not src_steps: - # 无源则跳过并记录 - steps.append( - { - "step_id": fid, - "atomic_id": "noop_pattern", - "depends_on": list(feat.get("depends_on") or []), - "params": params, - "sketch": None, - "note": "pattern source steps missing", - } - ) - seen_ids.add(fid) - continue - - clone_steps = [] - k = 0 - for i in range(c1): - for j in range(c2): - if i == 0 and j == 0: - continue - dx = d1[0] * s1 * i + d2[0] * s2 * j - dy = d1[1] * s1 * i + d2[1] * s2 * j - dz = d1[2] * s1 * i + d2[2] * s2 * j - for src in src_steps: - k += 1 - clone_id = f"{fid}.p{k}" - fake_feat = { - "atomic_id": src["atomic_id"], - "depends_on": [steps[-1]["step_id"]] if steps else [], - "name": f"{src.get('source_name')}_pattern", - } - st = emit( - fake_feat, - _offset_params_positions(src["params"], dx, dy, dz), - _offset_sketch(src.get("sketch"), dx, dy, dz), - clone_id, - ) - clone_steps.append(st) - emitted[fid] = clone_steps - seen_ids.add(fid) - continue - - params = deepcopy(feat.get("params") or {}) - sketch_id = feat.get("sketch_id") or params.get("sketch_id") - sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None - if sketch_id: - params["sketch_id"] = sketch_id - - # Auto-derive revolve axis origin - if "revolve" in atomic and sketch and "axis" in params: - ax = params.get("axis") or {} - # 优先级: from_workplane_origin > from_contour_vertex > origin_mm 裸坐标 - wp = sketch.get("workplane") or {} - wp_origin = wp.get("origin_mm") or [0.0, 0.0, 0.0] - - if ax.get("from_workplane_origin") and "origin_mm" not in ax: - params["axis"] = deepcopy(params["axis"]) - params["axis"]["origin_mm"] = list(wp_origin) - elif "origin_mm" not in ax: - ce = sketch.get("contour_edges_mm") or [] - if ce: - idx = int(ax.get("from_contour_vertex", 0)) - vertex = ce[idx % len(ce)]["start_mm"] - params["axis"] = deepcopy(params["axis"]) - params["axis"]["origin_mm"] = list(vertex) - - for key in REQUIRED.get(atomic, []): - if key == "axis" and "axis" not in params: - raise ValueError(f"{fid}: missing axis") - if key not in ("axis",) and params.get(key) is None and key != "sketch_id": - # positions can be empty temporarily - if key in params: - continue - if key in ("diameter_mm", "depth_mm", "distance_mm", "angle_deg") and params.get(key) is None: - raise ValueError(f"{fid}: missing {key}") - - st = emit(feat, params, sketch, fid) - emitted[fid] = [st] - - # filter noop - steps = [s for s in steps if s.get("atomic_id") != "noop_pattern"] - - return { - "schema": "cad.engine_plan.v1", - "part_id": cdsl.get("part_id"), - "unit": "mm", - "steps": steps, - "compiler_context": deepcopy(cdsl.get("compiler_context")), - "meta": { - "from_cdsl_schema": cdsl.get("schema"), - "compiler": "cad-heard.llm_compiler.v1", - "n_steps": len(steps), - }, - } - - -def main() -> None: - import argparse - - ap = argparse.ArgumentParser() - ap.add_argument("--cdsl", type=Path, required=True) - ap.add_argument("--catalog", type=Path, default=None) - ap.add_argument("--techniques", type=Path, default=None) - ap.add_argument("--out", type=Path, required=True) - args = ap.parse_args() - catalog = _load(args.catalog) if args.catalog else None - techniques = _load(args.techniques) if args.techniques else None - pack = compile_cdsl(_load(args.cdsl), catalog, techniques) - args.out.parent.mkdir(parents=True, exist_ok=True) - args.out.write_text(json.dumps(pack, ensure_ascii=False, indent=2), encoding="utf-8") - print(f"wrote {args.out} steps={len(pack['steps'])}") - - -if __name__ == "__main__": - main() +__all__ = ["REQUIRED", "compile_cdsl", "main"] diff --git a/backend/engine/cdsl_engine/llm_engine.py b/backend/engine/cdsl_engine/llm_engine.py index bc913eeb..0b4d8652 100644 --- a/backend/engine/cdsl_engine/llm_engine.py +++ b/backend/engine/cdsl_engine/llm_engine.py @@ -1,555 +1,12 @@ -"""build123d 绘图引擎:执行 build_pack → STEP。""" +"""Compatibility shim: the implementation moved to ``legacy.llm_engine``. + +``run_engine_plan`` executes a legacy ``build_pack`` through build123d. +It predates the session-based runtime and is frozen; new capability belongs +in ``executors/`` and the schema contracts. +""" from __future__ import annotations -import builtins -import json -import math -import subprocess -import sys -from pathlib import Path -from typing import Any +from .legacy.llm_engine import SUPPORTED_ATOMIC_IDS, main, run_engine_plan -# 保留内置 float,防止被 build123d 上下文 shadow -_f = builtins.float - -from build123d import ( # noqa: E402 - Align, - Axis, - BuildPart, - BuildSketch, - Circle, - Cone, - Cylinder, - Edge, - Face, - Location, - Locations, - Mode, - Plane, - Polygon, - Sphere, - Vector, - Wire, - export_step, - extrude, - import_step, - revolve, -) - - -# Keep this in sync with the execution branches in run_engine_plan. The -# agent-facing schema and its parity test prevent unsupported names reaching -# this low-level dispatcher. -SUPPORTED_ATOMIC_IDS = frozenset({ - "extrude_add_blind", - "extrude_add_two_sided", - "extrude_cut_blind", - "revolve_add", - "revolve_cut", - "hole_blind", - "hole_countersink", - "hole_counterbore", - "sphere_add", - "reference_plane", - "reference_axis", -}) - - -def _load(path: Path) -> dict[str, Any]: - return json.loads(path.read_text(encoding="utf-8")) - - -def _plane_from_workplane(wp: dict[str, Any]) -> Plane: - o = wp.get("origin_mm") or [0, 0, 0] - x = wp.get("x_dir") or [1, 0, 0] - n = wp.get("normal") or [0, 0, 1] - return Plane( - origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])), - x_dir=Vector(_f(x[0]), _f(x[1]), _f(x[2])), - z_dir=Vector(_f(n[0]), _f(n[1]), _f(n[2])), - ) - - -def _axis_from_params(axis: dict[str, Any]) -> Axis: - o = axis.get("origin_mm") or [0, 0, 0] - d = axis.get("direction") or [1, 0, 0] - return Axis( - origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])), - direction=Vector(_f(d[0]), _f(d[1]), _f(d[2])), - ) - - -def _ordered_profile_points(sketch: dict[str, Any]) -> list[tuple[float, float]]: - entities = sketch.get("entities") or [] - line_loop = [ - i for i, e in enumerate(entities) if e["type"] == "line" and not e.get("construction") - ] - if not line_loop: - raise ValueError(f"sketch {sketch.get('id')}: no profile lines") - pts: list[tuple[float, float]] = [] - for i in line_loop: - e = entities[i] - s = (_f(e["start"][0]), _f(e["start"][1])) - en = (_f(e["end"][0]), _f(e["end"][1])) - if not pts: - pts.append(s) - if abs(pts[-1][0] - s[0]) + abs(pts[-1][1] - s[1]) > 1e-4: - if abs(pts[-1][0] - en[0]) + abs(pts[-1][1] - en[1]) <= 1e-4: - s, en = en, s - else: - pts.append(s) - pts.append(en) - if abs(pts[0][0] - pts[-1][0]) + abs(pts[0][1] - pts[-1][1]) > 1e-4: - pts.append(pts[0]) - return pts - - -def _arc_midpoint(edge: dict[str, Any], p1: Vector, p2: Vector, center: Vector, radius: float) -> Vector: - """Return a point on the intended directed arc for ``make_three_point_arc``. - - Legacy contour data has no sweep direction and retains its prior shortest - arc behavior. Evidence-v2 analytic contours carry ``clockwise`` so a - major arc or a clockwise arc cannot be silently inverted by the adapter. - """ - v1 = p1 - center - v2 = p2 - center - if v1.length < 1e-9 or v2.length < 1e-9: - return (p1 + p2) / 2 - n = Vector(*(edge.get("normal") or [0, 0, 1])) - if n.length < 1e-9: - n = v1.cross(v2) - if n.length < 1e-9: - n = Vector(0, 0, 1) - n = n.normalized() - v1n = v1.normalized() * radius - if "clockwise" not in edge: - bisector = v1n + v2.normalized() * radius - if bisector.length < 1e-9: - bisector = n.cross(v1n) - return center + bisector.normalized() * radius - sweep = math.atan2(n.dot(v1.cross(v2)), v1.dot(v2)) - if bool(edge["clockwise"]): - if sweep >= 0: - sweep -= math.tau - elif sweep <= 0: - sweep += math.tau - half = sweep / 2 - midpoint_vector = v1n * math.cos(half) + n.cross(v1n) * math.sin(half) - return center + midpoint_vector - - -def _face_from_contour_edges(edges_mm: list[dict[str, Any]], *, desired_normal: list[float] | None = None) -> Face: - b123_edges: list[Edge] = [] - for e in edges_mm: - p1 = Vector(*e["start_mm"]) - p2 = Vector(*e["end_mm"]) - if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None: - center = Vector(*e["center_mm"]) - r = _f(e["radius_mm"]) - v1 = p1 - center - v2 = p2 - center - if v1.length < 1e-9 or v2.length < 1e-9: - b123_edges.append(Edge.make_line(p1, p2)) - continue - mid = _arc_midpoint(e, p1, p2, center, r) - try: - b123_edges.append(Edge.make_three_point_arc(p1, mid, p2)) - except Exception: - b123_edges.append(Edge.make_line(p1, p2)) - else: - b123_edges.append(Edge.make_line(p1, p2)) - face = Face(Wire(b123_edges)) - if desired_normal is not None: - dn = Vector(*desired_normal) - if dn.length > 1e-9: - fn = face.normal_at() - if fn.dot(dn) < 0: - # 重建反转的 Wire:边顺序反转 + 每条边起止点交换 - # 这样法向自然翻转,但每条边的几何方向不变(不同于 Face.Reversed) - rev_edges: list[Edge] = [] - for e in reversed(edges_mm): - p1 = Vector(*e["end_mm"]) - p2 = Vector(*e["start_mm"]) - if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None: - center = Vector(*e["center_mm"]) - r = _f(e["radius_mm"]) - v1 = p1 - center - v2 = p2 - center - if v1.length < 1e-9 or v2.length < 1e-9: - rev_edges.append(Edge.make_line(p1, p2)) - continue - mid = _arc_midpoint(e, p1, p2, center, r) - try: - rev_edges.append(Edge.make_three_point_arc(p1, mid, p2)) - except Exception: - rev_edges.append(Edge.make_line(p1, p2)) - else: - rev_edges.append(Edge.make_line(p1, p2)) - face = Face(Wire(rev_edges)) - return face - - -def _amount(params: dict[str, Any], *, prefer_sign: str | None = None) -> float: - dist = abs(_f(params["distance_mm"])) - if prefer_sign == "plus": - return dist - if prefer_sign == "minus": - return -dist - return -dist if bool(params.get("reverse")) else dist - - -def _build_nested_circle_profiles(circles: list[dict[str, Any]]) -> None: - """Build circular islands and holes from containment parity. - - A circle contained by one larger circle is a hole; a circle contained by - two larger circles is an island again. This preserves annular profiles - without storing the heavy tessellated sketch regions from the SW export. - """ - ordered = sorted(circles, key=lambda item: _f(item["radius_mm"]), reverse=True) - tolerance = 1e-6 - for index, circle in enumerate(ordered): - center = circle["center"] - radius = _f(circle["radius_mm"]) - containing = 0 - for outer in ordered[:index]: - outer_center = outer["center"] - outer_radius = _f(outer["radius_mm"]) - distance = math.hypot( - _f(center[0]) - _f(outer_center[0]), - _f(center[1]) - _f(outer_center[1]), - ) - if distance + radius <= outer_radius + tolerance: - containing += 1 - mode = Mode.ADD if containing % 2 == 0 else Mode.SUBTRACT - with Locations((_f(center[0]), _f(center[1]))): - Circle(radius, mode=mode) - - -def run_engine_plan( - pack: dict[str, Any], - out_step: Path, - *, - cut_sign: str = "from_params", -) -> dict[str, Any]: - log: list[str] = [] - - compiler_context = pack.get("compiler_context") - if isinstance(compiler_context, dict): - # 回退路径:使用本包 translator(不依赖外部 backend.src) - try: - from .translator import generate_build123d_code, get_part_name - except ImportError: - from translator import generate_build123d_code, get_part_name - - context = dict(compiler_context) - context.setdefault("metadata", {})["part_name"] = str(pack.get("part_id") or out_step.stem) - out_step.parent.mkdir(parents=True, exist_ok=True) - completed = subprocess.run( - [sys.executable, "-c", generate_build123d_code(context)], - cwd=out_step.parent, - capture_output=True, - text=True, - timeout=180, - ) - if completed.returncode != 0: - raise RuntimeError( - f"exact compiler execution failed\nSTDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}" - ) - generated_name = get_part_name({"part_name": context["metadata"]["part_name"]}) - generated = out_step.parent / f"{generated_name}.step" - if generated != out_step and generated.exists(): - generated.replace(out_step) - if not out_step.exists(): - raise RuntimeError(f"exact compiler did not generate {out_step}") - solid = import_step(str(out_step)) - bb = solid.bounding_box() - return { - "out_step": str(out_step), - "volume_mm3": _f(solid.volume), - "bbox_mm": { - "min": [bb.min.X, bb.min.Y, bb.min.Z], - "max": [bb.max.X, bb.max.Y, bb.max.Z], - }, - "engine": "translator_fallback", - } - - with BuildPart() as part: - for step in pack.get("steps") or []: - atomic = step["atomic_id"] - params = step["params"] - sketch = step.get("sketch") - sid = step.get("step_id") - - if atomic == "reference_plane": - # Context features deliberately produce no solid. They remain - # executable plan steps so their dependencies are preserved and - # can be registered by the session-based runtime. - plane = _plane_from_workplane(params.get("plane") or {}) - log.append( - f"{sid}: reference_plane origin={tuple(plane.origin)} normal={tuple(plane.z_dir)}" - ) - - elif atomic == "reference_axis": - axis = _axis_from_params(params.get("axis") or {}) - log.append( - f"{sid}: reference_axis origin={tuple(axis.position)} direction={tuple(axis.direction)}" - ) - - elif atomic == "sphere_add": - radius = _f(params.get("radius_mm") or 0) - center = params.get("center_mm") or [0, 0, 0] - if radius <= 0 or len(center) != 3: - raise ValueError(f"{sid}: sphere_add requires a positive radius_mm and center_mm") - with Locations((_f(center[0]), _f(center[1]), _f(center[2]))): - Sphere(radius, mode=Mode.ADD) - log.append(f"{sid}: sphere_add radius={radius}") - - elif atomic in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"): - if sketch is None: - raise ValueError(f"{sid}: missing sketch") - plane = _plane_from_workplane(sketch.get("workplane") or {}) - mode = Mode.SUBTRACT if "cut" in atomic else Mode.ADD - edges = sketch.get("contour_edges_mm") or [] - regions = sketch.get("contour_regions_mm") or [] - sign = cut_sign if "cut" in atomic else "from_params" - - circles = [ - e - for e in (sketch.get("entities") or []) - if e.get("type") == "circle" and not e.get("construction") - ] - lines = [ - e - for e in (sketch.get("entities") or []) - if e.get("type") == "line" and not e.get("construction") - ] - - # 多区域轮廓(外环 + 孔):由 shape generator 展开 - if regions: - faces = [] - normal = (sketch.get("workplane") or {}).get("normal") - for reg in regions: - outer_edges = reg.get("outer") or [] - if len(outer_edges) < 2: - continue - face = _face_from_contour_edges(outer_edges, desired_normal=normal) - for hole_edges in reg.get("holes") or []: - if len(hole_edges) < 2: - continue - hole = _face_from_contour_edges(hole_edges, desired_normal=normal) - face = face.cut(hole) - faces.append(face) - if not faces: - raise ValueError(f"{sid}: contour_regions_mm produced no faces") - if atomic == "extrude_add_two_sided": - d = abs(_f(params["distance_mm"])) - for face in faces: - extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD) - else: - amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) - for face in faces: - extrude(to_extrude=face, amount=amt, mode=mode) - log.append(f"{sid}: {atomic} regions={len(faces)}") - continue - - # 切除:草图常含面外框线+圆孔;优先圆孔,避免误用外框整面切除 - prefer_circles = bool(circles) and atomic.startswith("extrude_cut") - - if prefer_circles: - with BuildSketch(plane): - for e in circles: - with Locations((_f(e["center"][0]), _f(e["center"][1]))): - Circle(_f(e["radius_mm"])) - if atomic == "extrude_add_two_sided": - d = abs(_f(params["distance_mm"])) - extrude(amount=d, both=True, mode=Mode.ADD) - else: - amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) - extrude(amount=amt, mode=mode) - log.append(f"{sid}: {atomic} circle-only n={len(circles)}") - elif len(edges) >= 2: - face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal")) - if atomic == "extrude_add_two_sided": - d = abs(_f(params["distance_mm"])) - extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD) - log.append(f"{sid}: extrude_two_sided both={d} contour") - else: - amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) - extrude(to_extrude=face, amount=amt, mode=mode) - log.append(f"{sid}: {atomic} amount={amt} contour") - elif circles and not lines: - # 纯圆轮廓:用包含层级区分实体、内孔和孔中岛。 - with BuildSketch(plane): - _build_nested_circle_profiles(circles) - if atomic == "extrude_add_two_sided": - d = abs(_f(params["distance_mm"])) - extrude(amount=d, both=True, mode=Mode.ADD) - else: - amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) - extrude(amount=amt, mode=mode) - log.append(f"{sid}: {atomic} circle-only n={len(circles)}") - else: - with BuildSketch(plane): - pts = _ordered_profile_points(sketch) - poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts - Polygon(*poly) - for e in circles: - with Locations((_f(e["center"][0]), _f(e["center"][1]))): - Circle(_f(e["radius_mm"]), mode=Mode.SUBTRACT) - if atomic == "extrude_add_two_sided": - d = abs(_f(params["distance_mm"])) - extrude(amount=d, both=True, mode=Mode.ADD) - log.append(f"{sid}: extrude_two_sided both={d} poly") - else: - amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) - extrude(amount=amt, mode=mode) - log.append(f"{sid}: {atomic} amount={amt} poly") - - elif atomic in ("revolve_add", "revolve_cut"): - if sketch is None: - raise ValueError(f"{sid}: missing sketch") - plane = _plane_from_workplane(sketch.get("workplane") or {}) - axis = _axis_from_params(params.get("axis") or {}) - angle = _f(params.get("angle_deg") or 360) - mode = Mode.SUBTRACT if atomic == "revolve_cut" else Mode.ADD - edges = sketch.get("contour_edges_mm") or [] - if len(edges) >= 2: - face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal")) - revolve(profiles=face, axis=axis, revolution_arc=angle, mode=mode) - else: - with BuildSketch(plane): - pts = _ordered_profile_points(sketch) - poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts - Polygon(*poly) - revolve(axis=axis, revolution_arc=angle, mode=mode) - log.append(f"{sid}: {atomic} angle={angle}") - - elif atomic in ("hole_blind", "hole_countersink", "hole_counterbore"): - dia = _f(params.get("diameter_mm") or 0) - depth = _f(params.get("depth_mm") or 0) - positions = params.get("positions") or [] - if sketch is not None: - plane = _plane_from_workplane(sketch.get("workplane") or {}) - else: - plane = Plane.XY - host_face = params.get("host_face") or {} - frame = host_face.get("frame") or {} - frame_origin = Vector(*(frame.get("origin_mm") or plane.origin.to_tuple())) - frame_x = Vector(*(frame.get("x_dir") or plane.x_dir.to_tuple())) - frame_y = Vector(*(frame.get("y_dir") or plane.y_dir.to_tuple())) - normal = plane.z_dir.normalized() - bb = part.part.bounding_box() - part_center = Vector( - (bb.min.X + bb.max.X) / 2, - (bb.min.Y + bb.max.Y) / 2, - (bb.min.Z + bb.max.Z) / 2, - ) - inward = normal if (part_center - frame_origin).dot(normal) >= 0 else -normal - for pos in positions: - mm = pos.get("mm") or [0, 0, 0] - start = frame_origin + frame_x * _f(mm[0]) + frame_y * _f(mm[1]) - cs_dia = _f(params.get("countersink_diameter_mm") or 0) - cs_angle = _f(params.get("countersink_angle_rad") or 0) - cb_dia = _f(params.get("counterbore_diameter_mm") or 0) - cb_depth = _f(params.get("counterbore_depth_mm") or 0) - cs_depth = ( - ((cs_dia - dia) / 2) / math.tan(cs_angle / 2) - if cs_dia > dia and cs_angle > 0 - else 0 - ) - base_offset = cs_depth + (cb_depth if cb_dia > dia else 0) - main_depth = max(0.001, abs(depth) - base_offset) - main_place = Location(Plane(origin=start + inward * base_offset, z_dir=inward)) - tools = [ - Cylinder( - radius=dia / 2, - height=main_depth, - align=(Align.CENTER, Align.CENTER, Align.MIN), - mode=Mode.PRIVATE, - ).move(main_place) - ] - if cb_dia > dia and cb_depth > 0: - tools.append( - Cylinder( - radius=cb_dia / 2, - height=cb_depth, - align=(Align.CENTER, Align.CENTER, Align.MIN), - mode=Mode.PRIVATE, - ).move(Location(Plane(origin=start, z_dir=inward))) - ) - if cs_depth > 0: - tools.append( - Cone( - bottom_radius=cs_dia / 2, - top_radius=dia / 2, - height=cs_depth, - align=(Align.CENTER, Align.CENTER, Align.MIN), - mode=Mode.PRIVATE, - ).move(Location(Plane(origin=start, z_dir=inward))) - ) - drill_angle = _f(params.get("drill_angle_rad") or 0) - if drill_angle > 0: - tip_depth = (dia / 2) / math.tan(drill_angle / 2) - tools.append( - Cone( - bottom_radius=dia / 2, - top_radius=0, - height=tip_depth, - align=(Align.CENTER, Align.CENTER, Align.MIN), - mode=Mode.PRIVATE, - ).move( - Location( - Plane(origin=start + inward * abs(depth), z_dir=inward) - ) - ) - ) - for tool in tools: - part.part = part.part.cut(tool) - log.append(f"{sid}: {atomic} npos={len(positions)}") - - else: - raise ValueError(f"unsupported atomic_id: {atomic}") - - solid = part.part - - out_step.parent.mkdir(parents=True, exist_ok=True) - export_step(solid, str(out_step)) - bb = solid.bounding_box() - return { - "out_step": str(out_step), - "volume_mm3": _f(solid.volume), - "bbox_mm": { - "min": [bb.min.X, bb.min.Y, bb.min.Z], - "max": [bb.max.X, bb.max.Y, bb.max.Z], - }, - "log": log, - "cut_sign": cut_sign, - } - - -def main() -> None: - import argparse - - ap = argparse.ArgumentParser() - ap.add_argument("--pack", type=Path, required=True) - ap.add_argument("--out-step", type=Path, required=True) - ap.add_argument("--report", type=Path, default=None) - ap.add_argument("--cut-sign", default="from_params", choices=["from_params", "plus", "minus"]) - args = ap.parse_args() - info = run_engine_plan(_load(args.pack), args.out_step, cut_sign=args.cut_sign) - if args.report: - args.report.write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8") - print( - json.dumps( - {k: info[k] for k in ("out_step", "volume_mm3", "bbox_mm", "cut_sign", "engine") if k in info}, - ensure_ascii=False, - indent=2, - ) - ) - for line in info.get("log") or []: - print(line) - - -if __name__ == "__main__": - main() +__all__ = ["SUPPORTED_ATOMIC_IDS", "main", "run_engine_plan"] diff --git a/backend/engine/cdsl_engine/operation_contracts.py b/backend/engine/cdsl_engine/operation_contracts.py index a4a3de93..c5318216 100644 --- a/backend/engine/cdsl_engine/operation_contracts.py +++ b/backend/engine/cdsl_engine/operation_contracts.py @@ -31,6 +31,7 @@ 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") + runtime_capability = raw.get("runtime_capability") if ( not isinstance(shape, dict) or not isinstance(params_schema, dict) @@ -38,6 +39,13 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st or not isinstance(selector_policy, dict) ): raise ValueError(f"operation contract is incomplete for {atomic_id}") + if not isinstance(runtime_capability, dict) or not all( + isinstance(runtime_capability.get(flag), bool) + for flag in ( + "body_mutating", "requires_active_body", "replayable", "requires_selector", "open_profile_ok", + ) + ): + raise ValueError(f"operation runtime_capability is invalid for {atomic_id}") properties = params_schema.get("properties") required = params_schema.get("required") if not isinstance(properties, dict) or not isinstance(required, list): @@ -59,5 +67,6 @@ 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"), + "runtime_capability": dict(runtime_capability), } return derived diff --git a/backend/engine/cdsl_engine/pattern_transform.py b/backend/engine/cdsl_engine/pattern_transform.py new file mode 100644 index 00000000..bb9c832b --- /dev/null +++ b/backend/engine/cdsl_engine/pattern_transform.py @@ -0,0 +1,509 @@ +"""Parametric transforms for pattern replay (translate / mirror / rotate). + +A pattern instance re-executes its source feature with every absolute +coordinate parameter transformed (sketch, host frame, axis, positions, +center, nested mirror-plane references). These helpers own that parameter +algebra; the pattern executors only decide which transform to apply. +""" + +from __future__ import annotations + +import math +from copy import deepcopy +from typing import TYPE_CHECKING, Any, Callable + +from .specs import AxisSpec, PlaneSpec, Vector3, vector_cross, vector_dot, vector_scale, vector_subtract +from .topology import FeaturePlanNode + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from .session import ExecutionSession + + +def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any]: + output = deepcopy(sketch) + components = offset + workplane = output.get("workplane") or {} + origin = workplane.get("origin_mm") or [0, 0, 0] + workplane["origin_mm"] = [float(origin[index]) + components[index] for index in range(3)] + output["workplane"] = workplane + for key in ("contour_edges_mm", "contour_regions_mm"): + def translate(value: Any) -> None: + if isinstance(value, dict): + for point_key in ("start_mm", "end_mm", "center_mm"): + if point_key in value: + value[point_key] = [float(value[point_key][index]) + components[index] for index in range(3)] + if "points_mm" in value: + value["points_mm"] = [ + [float(point[index]) + components[index] for index in range(3)] + for point in value["points_mm"] + ] + for child in value.values(): + translate(child) + elif isinstance(value, list): + for child in value: + translate(child) + translate(output.get(key)) + return output + + +def _transformed_loft_profiles( + node: FeaturePlanNode, + params: dict[str, Any], + instance_id: str, + session: "ExecutionSession", + transform: Callable[[dict[str, Any]], dict[str, Any]], +) -> None: + """为 pattern replay 创建放样截面的变换副本。""" + if node.atomic_id != "loft_add": + return + profile_ids = params.get("profile_sketch_ids") or [] + transformed_ids: list[str] = [] + for index, sketch_id in enumerate(profile_ids): + source = session.sketches.get(str(sketch_id)) + if source is None: + raise ValueError(f"loft profile sketch {sketch_id!r} has no replay definition") + transformed_id = f"{instance_id}.profile.{index}" + # 不复用原 profile:pattern 中的每个截面都必须与 source feature + # 使用相同的平移、镜像或旋转,才能保持放样的真实空间位置。 + session.sketches[transformed_id] = transform(source) + transformed_ids.append(transformed_id) + params["profile_sketch_ids"] = transformed_ids + + +def _owner_plane_frame(session: "ExecutionSession", selector: dict[str, Any]) -> dict[str, Any] | None: + """解析 selector 的 owner 特征(reference_plane)注册的显式平面 frame。 + + #6 pattern 引用重解析:pattern 重放 source(pattern_mirror)时,镜像面 + 是 selector,其 owner 是 reference_plane 特征;该特征执行时把显式 + PlaneSpec 登记为拓扑上下文,这里取出该 frame 供随实例变换使用。 + """ + owner = selector.get("owner_feature_id") + if not owner: + return None + for record in session.topology.records_for_feature(str(owner)): + if record.kind == "plane" and isinstance(record.value, PlaneSpec): + return record.value.as_dict() + return None + + +def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3, session: "ExecutionSession") -> FeaturePlanNode: + params = deepcopy(node.params) + components = offset + if isinstance(params.get("plane"), dict) and params["plane"].get("origin_mm"): + params["plane"]["origin_mm"] = [float(params["plane"]["origin_mm"][index]) + components[index] for index in range(3)] + host = params.get("host_face") + host_frame = host.get("frame") if isinstance(host, dict) else None + positions_are_local = isinstance(host_frame, dict) and all( + host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal") + ) + if positions_are_local and host_frame.get("origin_mm"): + host_frame["origin_mm"] = [float(host_frame["origin_mm"][index]) + components[index] for index in range(3)] + if not positions_are_local: + for position in params.get("positions") or []: + if position.get("mm"): + position["mm"] = [float(position["mm"][index]) + components[index] for index in range(3)] + axis = params.get("axis") or {} + if axis.get("origin_mm"): + axis["origin_mm"] = [float(axis["origin_mm"][index]) + components[index] for index in range(3)] + center = params.get("center_mm") + if center: + # box_add/sphere_add 以世界坐标几何中心定位;平移重放必须随实例移动该中心, + # 否则阵列副本会静默重合在原位置。 + params["center_mm"] = [float(center[index]) + components[index] for index in range(3)] + _transformed_loft_profiles( + node, params, instance_id, session, + lambda sketch: _translated_sketch(sketch, offset), + ) + mirror_plane = params.get("mirror_plane") + if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror": + # #6 pattern 引用重解析:镜像面是 reference_plane 引用,随实例平移 + # 到新位置后内联为显式 frame;否则重放时 resolve 到原始面,镜像 + # 副本会错误地重合在源特征附近。同时源特征也必须平移后重放:镜像 + # 副本 = reflect(源@t, 面@t),只平移面不平移源会落在 2P+t-x 处 + # 而非正确位置 2P-x+t。 + frame = _owner_plane_frame(session, mirror_plane) + if frame is None: + raise ValueError("mirror plane reference cannot be transformed for pattern replay") + cloned_selector = deepcopy(mirror_plane) + cloned_selector["frame"] = { + "origin_mm": [frame["origin_mm"][index] + components[index] for index in range(3)], + "x_dir": list(frame["x_dir"]), + "normal": list(frame["normal"]), + } + params["mirror_plane"] = cloned_selector + transformed_ids: list[str] = [] + for source_id in node.params.get("source_feature_ids") or []: + source_node = session.replay_definitions.get(str(source_id)) + if source_node is None: + raise ValueError(f"mirror pattern source feature {source_id} has no replay definition") + temp_id = f"{instance_id}.src.{source_id}" + shifted = _translated_node(source_node, temp_id, offset, session) + if shifted.sketch_id: + source_sketch = session.sketches.get(str(source_node.sketch_id)) + if source_sketch is not None: + temp_sketch_id = f"{temp_id}.sk" + session.sketches[temp_sketch_id] = _translated_sketch(source_sketch, offset) + shifted = FeaturePlanNode( + shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on, + shifted.params, shifted.selectors, temp_sketch_id, + shifted.declared_status, shifted.source_feature, + ) + # 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。 + session.nodes[temp_id] = shifted + session.replay_definitions[temp_id] = shifted + transformed_ids.append(temp_id) + params["source_feature_ids"] = transformed_ids + return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature) + + +def _reflect_point(point: list[float] | tuple[float, float, float], plane: PlaneSpec, *, vector: bool = False) -> list[float]: + value = tuple(float(component) for component in point) + offset = value if vector else vector_subtract(value, plane.origin_mm) + mirrored = vector_subtract(value, vector_scale(plane.normal, 2 * vector_dot(offset, plane.normal))) + return list(mirrored) + + +def _mirrored_sketch(sketch: dict[str, Any], plane: PlaneSpec) -> dict[str, Any]: + output = deepcopy(sketch) + workplane = output.get("workplane") or {} + if workplane.get("origin_mm"): + workplane["origin_mm"] = _reflect_point(workplane["origin_mm"], plane) + for key in ("x_dir", "y_dir", "normal"): + if workplane.get(key): + workplane[key] = _reflect_point(workplane[key], plane, vector=True) + output["workplane"] = workplane + + # A reflection reverses handedness. ``PlaneSpec`` reconstructs its local + # y direction as normal x x, so keeping the reflected normal means that + # local y is the inverse of the reflected source y. Profiles represented + # as local circles (rather than already-transformed contour edges) must + # therefore invert v to remain at their actual reflected world position. + def mirror_local_coordinates(value: Any) -> None: + if isinstance(value, dict): + for point_key in ("center", "start", "end"): + point = value.get(point_key) + if isinstance(point, list) and len(point) == 2: + value[point_key] = [float(point[0]), -float(point[1])] + if isinstance(value.get("points"), list): + value["points"] = [ + [float(point[0]), -float(point[1])] + for point in value["points"] + if isinstance(point, list) and len(point) == 2 + ] + for child in value.values(): + mirror_local_coordinates(child) + elif isinstance(value, list): + for child in value: + mirror_local_coordinates(child) + + mirror_local_coordinates(output.get("entities")) + # This is not consumed after sketch resolution, but retaining the same + # local semantics makes an overridden sketch safe to inspect or replay. + mirror_local_coordinates(output.get("profile")) + + def mirror(value: Any) -> None: + if isinstance(value, dict): + for point_key in ("start_mm", "end_mm", "center_mm"): + if point_key in value: + value[point_key] = _reflect_point(value[point_key], plane) + if "points_mm" in value: + value["points_mm"] = [_reflect_point(point, plane) for point in value["points_mm"]] + if value.get("normal"): + value["normal"] = _reflect_point(value["normal"], plane, vector=True) + for child in value.values(): + mirror(child) + elif isinstance(value, list): + for child in value: + mirror(child) + mirror(output.get("contour_edges_mm")) + mirror(output.get("contour_regions_mm")) + return output + + +def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec, session: "ExecutionSession") -> FeaturePlanNode: + params = deepcopy(node.params) + if isinstance(params.get("plane"), dict): + for key in ("origin_mm", "x_dir", "y_dir", "normal"): + if params["plane"].get(key): + params["plane"][key] = _reflect_point(params["plane"][key], plane, vector=key != "origin_mm") + host = params.get("host_face") + host_frame = host.get("frame") if isinstance(host, dict) else None + positions_are_local = isinstance(host_frame, dict) and all( + host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal") + ) + if positions_are_local: + for key in ("origin_mm", "x_dir", "normal"): + if host_frame.get(key): + host_frame[key] = _reflect_point(host_frame[key], plane, vector=key != "origin_mm") + # #1 y_dir 保留:PlaneSpec 现在会尊重显式正交 y_dir。镜像后 frame 的 + # canonical y 轴必须是 n×x(x 已反射 → y 反转),否则反射后的 frame + # 会保留反射前的 y_dir,与下方"局部坐标 v 取反"双重翻转。 + x_reflected = host_frame.get("x_dir") + n_reflected = host_frame.get("normal") + if x_reflected is not None and n_reflected is not None: + host_frame["y_dir"] = [ + n_reflected[1] * x_reflected[2] - n_reflected[2] * x_reflected[1], + n_reflected[2] * x_reflected[0] - n_reflected[0] * x_reflected[2], + n_reflected[0] * x_reflected[1] - n_reflected[1] * x_reflected[0], + ] + # See _mirrored_sketch: the canonical reflected plane reverses local + # y, so local hole coordinates must do the same. + for position in params.get("positions") or []: + point = position.get("mm") + if isinstance(point, list) and len(point) == 3: + position["mm"] = [float(point[0]), -float(point[1]), float(point[2])] + else: + for position in params.get("positions") or []: + if position.get("mm"): + position["mm"] = _reflect_point(position["mm"], plane) + axis = params.get("axis") or {} + if axis.get("origin_mm"): + axis["origin_mm"] = _reflect_point(axis["origin_mm"], plane) + if axis.get("direction"): + axis["direction"] = _reflect_point(axis["direction"], plane, vector=True) + center = params.get("center_mm") + if isinstance(center, list) and len(center) == 3: + # box_add/sphere_add 以世界坐标几何中心定位:反射该中心即可。box_add 固定 + # 世界轴对齐,跨坐标平面镜像后仍保持朝向(斜镜像面在 _execute_mirror_pattern + # 中已被显式拒绝)。 + params["center_mm"] = _reflect_point(center, plane) + _transformed_loft_profiles( + node, params, instance_id, session, + lambda sketch: _mirrored_sketch(sketch, plane), + ) + mirror_plane = params.get("mirror_plane") + if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror": + # #6 pattern 引用重解析:镜像重放 mirror source 时,其镜像面引用 + # 随本实例的镜像面一起反射(内联为显式 frame),否则重放 resolve + # 到原始面,嵌套镜像会退化成与源镜像重合的错误几何。源特征同样 + # 反射后重放:镜像副本 = reflect(源@P_B, reflect(面,P_B))。 + frame = _owner_plane_frame(session, mirror_plane) + if frame is None: + raise ValueError("mirror plane reference cannot be transformed for pattern replay") + cloned_selector = deepcopy(mirror_plane) + cloned_selector["frame"] = { + "origin_mm": _reflect_point(frame["origin_mm"], plane), + "x_dir": _reflect_point(frame["x_dir"], plane, vector=True), + "normal": _reflect_point(frame["normal"], plane, vector=True), + } + params["mirror_plane"] = cloned_selector + transformed_ids: list[str] = [] + for source_id in node.params.get("source_feature_ids") or []: + source_node = session.replay_definitions.get(str(source_id)) + if source_node is None: + raise ValueError(f"mirror pattern source feature {source_id} has no replay definition") + temp_id = f"{instance_id}.src.{source_id}" + shifted = _mirrored_node(source_node, temp_id, plane, session) + if shifted.sketch_id: + source_sketch = session.sketches.get(str(source_node.sketch_id)) + if source_sketch is not None: + temp_sketch_id = f"{temp_id}.sk" + session.sketches[temp_sketch_id] = _mirrored_sketch(source_sketch, plane) + shifted = FeaturePlanNode( + shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on, + shifted.params, shifted.selectors, temp_sketch_id, + shifted.declared_status, shifted.source_feature, + ) + # 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。 + session.nodes[temp_id] = shifted + session.replay_definitions[temp_id] = shifted + transformed_ids.append(temp_id) + params["source_feature_ids"] = transformed_ids + return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature) + + +def _normal_is_coordinate_axis(normal: Any) -> bool: + # 判断单位法向是否平行于任一世界坐标轴:跨这样的平面镜像会保持轴对齐朝向。 + return ( + isinstance(normal, (list, tuple)) + and len(normal) == 3 + and any(abs(float(normal[index])) > 1 - 1e-9 for index in range(3)) + ) + + +def _coordinate_axis_direction(direction: Any) -> bool: + # 判断方向是否平行于任一世界坐标轴(circular 的 box 限制用)。 + # 不依赖输入已是单位向量:非零向量至多一个分量非零即为坐标轴方向 + # (_box_circular_is_exact 对任意长度/含小残差的 direction 都稳健)。 + if not isinstance(direction, (list, tuple)) or len(direction) != 3: + return False + return sum(1 for component in direction if abs(float(component)) > 1e-9) == 1 + + +def _rotated_vector(value: Vector3, axis: AxisSpec, angle_rad: float) -> Vector3: + # Rodrigues 旋转公式:绕单位轴 axis.direction 旋转向量(无平移项)。 + cosine = math.cos(angle_rad) + sine = math.sin(angle_rad) + axis_direction = axis.direction + cross = vector_cross(axis_direction, value) + dot = vector_dot(axis_direction, value) + return tuple( # type: ignore[return-value] + value[index] * cosine + cross[index] * sine + axis_direction[index] * dot * (1.0 - cosine) + for index in range(3) + ) + + +def _rotated_point(point: Any, axis: AxisSpec, angle_rad: float) -> list[float]: + # 绕轴旋转三维点:先平移到轴原点、旋转向量、再平移回。 + value = tuple(float(component) for component in point) + relative = vector_subtract(value, axis.origin_mm) + rotated = _rotated_vector(relative, axis, angle_rad) + return [axis.origin_mm[index] + rotated[index] for index in range(3)] + + +def _rotated_sketch(sketch: dict[str, Any], axis: AxisSpec, angle_rad: float) -> dict[str, Any]: + # 环形阵列实例的草图:工作平面 frame(原点为点、x/y/normal 为向量)绕轴旋转; + # 2D 局部实体坐标不动(frame 旋转后由草图求解器映射到新世界位置)。与 + # _translated_sketch 对"世界坐标轮廓点"的处理对称,这里把 start/end/center + # 世界坐标点和圆弧法向绕轴旋转。 + output = deepcopy(sketch) + workplane = output.get("workplane") or {} + if workplane.get("origin_mm"): + workplane["origin_mm"] = _rotated_point(workplane["origin_mm"], axis, angle_rad) + for key in ("x_dir", "y_dir", "normal"): + if workplane.get(key): + workplane[key] = list(_rotated_vector(tuple(float(v) for v in workplane[key]), axis, angle_rad)) + output["workplane"] = workplane + + def rotate(value: Any) -> None: + if isinstance(value, dict): + for point_key in ("start_mm", "end_mm", "center_mm"): + if point_key in value: + value[point_key] = _rotated_point(value[point_key], axis, angle_rad) + if "points_mm" in value: + value["points_mm"] = [_rotated_point(point, axis, angle_rad) for point in value["points_mm"]] + if "normal" in value: + value["normal"] = list(_rotated_vector(tuple(float(v) for v in value["normal"]), axis, angle_rad)) + for child in value.values(): + rotate(child) + elif isinstance(value, list): + for child in value: + rotate(child) + + for key in ("contour_edges_mm", "contour_regions_mm"): + rotate(output.get(key)) + return output + + +def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle_rad: float, session: "ExecutionSession") -> FeaturePlanNode: + # 环形阵列实例节点:把源特征的全部绝对坐标参数绕 axis 旋转(参数键布局与 + # _translated_node/_mirrored_node 一致)。workplane/宿主 frame 的轴方向旋转, + # 世界坐标点旋转;局部 positions(随宿主 frame)不动。特征自带 axis(圆柱轴/ + # 旋转轴/嵌套 circular 轴)与几何中心 center_mm 随实例旋转。嵌套 pattern + # source(pattern_mirror/pattern_circular)带绝对引用:镜像面 frame / 内层 + # 源需连同本实例一起旋转,否则重放会退化成与源重合的错误几何。 + params = deepcopy(node.params) + plane = params.get("plane") + if isinstance(plane, dict): + for key in ("origin_mm", "x_dir", "y_dir", "normal"): + if plane.get(key): + if key == "origin_mm": + plane[key] = _rotated_point(plane[key], axis, angle_rad) + else: + plane[key] = list(_rotated_vector(tuple(float(v) for v in plane[key]), axis, angle_rad)) + path = params.get("path") + path_plane = path.get("workplane") if isinstance(path, dict) else None + if isinstance(path_plane, dict): + if path_plane.get("origin_mm"): + path_plane["origin_mm"] = _rotated_point(path_plane["origin_mm"], axis, angle_rad) + for key in ("x_dir", "y_dir", "normal"): + if path_plane.get(key): + path_plane[key] = list(_rotated_vector(tuple(float(v) for v in path_plane[key]), axis, angle_rad)) + host = params.get("host_face") + host_frame = host.get("frame") if isinstance(host, dict) else None + positions_are_local = isinstance(host_frame, dict) and all( + host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal") + ) + if positions_are_local: + if host_frame.get("origin_mm"): + host_frame["origin_mm"] = _rotated_point(host_frame["origin_mm"], axis, angle_rad) + for key in ("x_dir", "normal"): + if host_frame.get(key): + host_frame[key] = list(_rotated_vector(tuple(float(v) for v in host_frame[key]), axis, angle_rad)) + else: + for position in params.get("positions") or []: + if position.get("mm"): + position["mm"] = _rotated_point(position["mm"], axis, angle_rad) + feature_axis = params.get("axis") + if isinstance(feature_axis, dict): + if feature_axis.get("origin_mm"): + feature_axis["origin_mm"] = _rotated_point(feature_axis["origin_mm"], axis, angle_rad) + if feature_axis.get("direction"): + feature_axis["direction"] = list(_rotated_vector(tuple(float(v) for v in feature_axis["direction"]), axis, angle_rad)) + center = params.get("center_mm") + if isinstance(center, list) and len(center) == 3: + params["center_mm"] = _rotated_point(center, axis, angle_rad) + _transformed_loft_profiles( + node, params, instance_id, session, + lambda sketch: _rotated_sketch(sketch, axis, angle_rad), + ) + if node.atomic_id in {"pattern_mirror", "pattern_circular"}: + # pattern 引用旋转重解析:镜像面 / 内层源随本实例一起旋转,否则嵌套 + # pattern 作为 circular source 时重放会退化成错误几何(见 _translated_node)。 + if node.atomic_id == "pattern_mirror": + mirror_plane = params.get("mirror_plane") + if not isinstance(mirror_plane, dict): + raise ValueError("mirror pattern replayed by circular pattern has no mirror plane reference") + frame = _owner_plane_frame(session, mirror_plane) + if frame is None: + raise ValueError("mirror plane reference cannot be transformed for circular pattern replay") + cloned_selector = deepcopy(mirror_plane) + cloned_selector["frame"] = { + "origin_mm": _rotated_point(frame["origin_mm"], axis, angle_rad), + "x_dir": list(_rotated_vector(tuple(frame["x_dir"]), axis, angle_rad)), + "normal": list(_rotated_vector(tuple(frame["normal"]), axis, angle_rad)), + } + params["mirror_plane"] = cloned_selector + transformed_ids: list[str] = [] + for source_id in node.params.get("source_feature_ids") or []: + source_node = session.replay_definitions.get(str(source_id)) + if source_node is None: + raise ValueError(f"pattern source feature {source_id} has no replay definition") + temp_id = f"{instance_id}.src.{source_id}" + shifted = _rotated_node(source_node, temp_id, axis, angle_rad, session) + if shifted.sketch_id: + source_sketch = session.sketches.get(str(source_node.sketch_id)) + if source_sketch is not None: + temp_sketch_id = f"{temp_id}.sk" + session.sketches[temp_sketch_id] = _rotated_sketch(source_sketch, axis, angle_rad) + shifted = FeaturePlanNode( + shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on, + shifted.params, shifted.selectors, temp_sketch_id, + shifted.declared_status, shifted.source_feature, + ) + # 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。 + session.nodes[temp_id] = shifted + session.replay_definitions[temp_id] = shifted + transformed_ids.append(temp_id) + params["source_feature_ids"] = transformed_ids + return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature) + + +def _pattern_operation_node(node: FeaturePlanNode, operation_mode: str) -> FeaturePlanNode: + # REMOVE pattern 的实例必须沿用 source 的 profile/extent,但以 cut 而不是 + # add 写入当前主体。lowering 已将初始 source 同步改写,运行时保留此处以 + # 支持完整的 CDSL replay contract。 + if operation_mode != "remove": return node + atomic_id = { + "extrude_add_blind": "extrude_cut_blind", + "extrude_add_two_sided": "extrude_cut_two_sided", + "revolve_add": "revolve_cut", + }.get(node.atomic_id, node.atomic_id) + if atomic_id == node.atomic_id and "cut" not in atomic_id: + raise ValueError("REMOVE pattern source is not a replayable cutting feature") + params = {key: value for key, value in node.params.items() if key != "result_mode"} + return FeaturePlanNode( + node.feature_id, atomic_id, node.name, node.depends_on, params, + node.selectors, node.sketch_id, node.declared_status, node.source_feature, + ) + + +def _box_circular_is_exact(axis: AxisSpec, angle_rad: float) -> bool: + # box_add 是固定世界轴对齐的原生图元:绕轴旋转任意角度会使其棱偏离坐标轴, + # 当前参数语义无法表达 → 仅坐标轴旋转且每份转角为 180° 的整数倍时精确 + # (180° 翻转把轴对齐 box 映射回轴对齐 box)。与 _execute_mirror_pattern 的 + # box 坐标平面限制同思路:宁可显式拒绝,也不静默产出错误几何。 + if not _coordinate_axis_direction(axis.direction): + return False + half_turns = abs(math.degrees(angle_rad)) / 180.0 + return abs(half_turns - round(half_turns)) < 1e-9 diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index 8a8f6677..15043830 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -6,34 +6,35 @@ "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"]}, - "extrude_from_face": {"atomic_id":"extrude_from_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"operation":{"enum":["add","cut"]},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"two_sided":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm","operation"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["derived_profile_face","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]}, - "extrude_add_blind_with_hole": {"atomic_id":"extrude_add_blind_with_hole","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","profile_hole_face"],"candidate_verifiers":["single_connected_body"]}, - "extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":[]}, - "loft_add": {"atomic_id":"loft_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":16,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"]}, - "loft_add_with_cap_face": {"atomic_id":"loft_add_with_cap_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":1,"maxItems":1,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["loft_cap_face","loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"]}, - "sweep_add": {"atomic_id":"sweep_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body"]}, - "extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]}, - "extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, - "extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, - "extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, - "revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"]}, - "revolve_cut": {"atomic_id":"revolve_cut","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, - "revolve_surface": {"atomic_id":"revolve_surface","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch","single_closed_profile_without_holes"],"candidate_verifiers":["surface_shell"]}, - "hole_blind": {"atomic_id":"hole_blind","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore","through_cylindrical_bore"]}, - "hole_countersink": {"atomic_id":"hole_countersink","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"countersink_diameter_mm":{"type":"number","exclusiveMinimum":0},"countersink_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","countersink_diameter_mm","countersink_angle_rad"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]}, - "hole_counterbore": {"atomic_id":"hole_counterbore","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"counterbore_diameter_mm":{"type":"number","exclusiveMinimum":0},"counterbore_depth_mm":{"type":"number","exclusiveMinimum":0},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","counterbore_diameter_mm","counterbore_depth_mm"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]}, - "bend_add": {"atomic_id":"bend_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"width_mm":{"type":"number","exclusiveMinimum":0},"chain":{"type":"array","minItems":1,"items":{"type":"object","properties":{"leg_mm":{"type":"number","exclusiveMinimum":0},"bend_angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"inner_radius_mm":{"type":"number","minimum":0},"side":{"type":"integer","enum":[1,-1]}},"required":["leg_mm"],"additionalProperties":false}}},"required":["thickness_mm","width_mm","chain"],"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":[],"candidate_verifiers":["single_connected_body"]}, - "gear_add": {"atomic_id":"gear_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"module_mm":{"type":"number","exclusiveMinimum":0},"teeth_count":{"type":"integer","minimum":8,"maximum":200},"width_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"helix_angle_rad":{"type":"number","minimum":0,"exclusiveMaximum":0.7853981633974483},"pressure_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":0.6108652381980153},"herringbone":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["module_mm","teeth_count","width_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]}, - "rack_add": {"atomic_id":"rack_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"module_mm":{"type":"number","exclusiveMinimum":0},"teeth_count":{"type":"integer","minimum":1,"maximum":2000},"thickness_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pressure_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":0.6108652381980153},"result_mode":{"enum":["fuse","new_body"]}},"required":["module_mm","teeth_count","thickness_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]}, - "sphere_add": {"atomic_id":"sphere_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["radius_mm","center_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":[],"candidate_verifiers":["single_connected_body"]}, - "box_add": {"atomic_id":"box_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"length_mm":{"type":"number","exclusiveMinimum":0},"width_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["length_mm","width_mm","height_mm","center_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":[],"candidate_verifiers":["single_connected_body"]}, - "cylinder_add": {"atomic_id":"cylinder_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["radius_mm","height_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":[],"candidate_verifiers":["single_connected_body"]}, - "thread_add": {"atomic_id":"thread_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"relief_length_mm":{"type":"number","minimum":0},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]}, - "thread_cut": {"atomic_id":"thread_cut","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, - "reference_plane": {"atomic_id":"reference_plane","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"plane":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"x_dir":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"normal":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","x_dir","normal"],"additionalProperties":false}},"required":["plane"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_plane_nonzero_normal"],"candidate_verifiers":[]}, - "reference_axis": {"atomic_id":"reference_axis","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_axis_nonzero_direction"],"candidate_verifiers":[]}, + "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_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}}, + "loft_add": {"atomic_id":"loft_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":16,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "loft_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_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}}, + "revolve_cut": {"atomic_id":"revolve_cut","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "revolve_surface": {"atomic_id":"revolve_surface","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch","single_closed_profile_without_holes"],"candidate_verifiers":["surface_shell"],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "hole_blind": {"atomic_id":"hole_blind","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore","through_cylindrical_bore"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "hole_countersink": {"atomic_id":"hole_countersink","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"countersink_diameter_mm":{"type":"number","exclusiveMinimum":0},"countersink_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","countersink_diameter_mm","countersink_angle_rad"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "hole_counterbore": {"atomic_id":"hole_counterbore","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"counterbore_diameter_mm":{"type":"number","exclusiveMinimum":0},"counterbore_depth_mm":{"type":"number","exclusiveMinimum":0},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","counterbore_diameter_mm","counterbore_depth_mm"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "bend_add": {"atomic_id":"bend_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"width_mm":{"type":"number","exclusiveMinimum":0},"chain":{"type":"array","minItems":1,"items":{"type":"object","properties":{"leg_mm":{"type":"number","exclusiveMinimum":0},"bend_angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"inner_radius_mm":{"type":"number","minimum":0},"side":{"type":"integer","enum":[1,-1]}},"required":["leg_mm"],"additionalProperties":false}}},"required":["thickness_mm","width_mm","chain"],"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":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "gear_add": {"atomic_id":"gear_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"module_mm":{"type":"number","exclusiveMinimum":0},"teeth_count":{"type":"integer","minimum":8,"maximum":200},"width_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"helix_angle_rad":{"type":"number","minimum":0,"exclusiveMaximum":0.7853981633974483},"pressure_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":0.6108652381980153},"herringbone":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["module_mm","teeth_count","width_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "rack_add": {"atomic_id":"rack_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"module_mm":{"type":"number","exclusiveMinimum":0},"teeth_count":{"type":"integer","minimum":1,"maximum":2000},"thickness_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pressure_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":0.6108652381980153},"result_mode":{"enum":["fuse","new_body"]}},"required":["module_mm","teeth_count","thickness_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "sphere_add": {"atomic_id":"sphere_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["radius_mm","center_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":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "box_add": {"atomic_id":"box_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"length_mm":{"type":"number","exclusiveMinimum":0},"width_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["length_mm","width_mm","height_mm","center_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":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "cylinder_add": {"atomic_id":"cylinder_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["radius_mm","height_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":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "thread_add": {"atomic_id":"thread_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"relief_length_mm":{"type":"number","minimum":0},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "thread_cut": {"atomic_id":"thread_cut","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "reference_plane": {"atomic_id":"reference_plane","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"plane":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"x_dir":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"normal":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","x_dir","normal"],"additionalProperties":false}},"required":["plane"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_plane_nonzero_normal"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "reference_axis": {"atomic_id":"reference_axis","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_axis_nonzero_direction"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, "hole_wizard": { + "runtime_capability": {"body_mutating": true, "requires_active_body": true, "replayable": true, "requires_selector": false, "open_profile_ok": false}, "atomic_id": "hole_wizard", "contract_version": "3.0", "fragment_shape": {"sketch": "forbidden", "params": "required_object", "selector_tokens": "required"}, @@ -63,11 +64,12 @@ "semantic_preflight": ["host_face_exists", "hole_positions_on_host_plane"], "candidate_verifiers": ["cylindrical_bore"] }, - "fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]}, - "chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"tangent_propagation":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]}, - "shell": {"atomic_id":"shell","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"inward":{"type":"boolean"},"target_feature_id":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"}},"required":["thickness_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_id","token_kind":"body","min_items":0,"max_items":1,"snapshot_bound":true},"semantic_preflight":["requires_active_solid","selected_faces_exist","shell_target_body_exists"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, - "boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"operation":{"enum":["union","subtract","intersect"]},"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"target_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"}},"required":["operation"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":0,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[]}, + "fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, + "chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"tangent_propagation":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, + "shell": {"atomic_id":"shell","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"inward":{"type":"boolean"},"target_feature_id":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"}},"required":["thickness_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_id","token_kind":"body","min_items":0,"max_items":1,"snapshot_bound":true},"semantic_preflight":["requires_active_solid","selected_faces_exist","shell_target_body_exists"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, + "boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"operation":{"enum":["union","subtract","intersect"]},"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"target_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"}},"required":["operation"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":0,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, "transform_bodies": { + "runtime_capability": {"body_mutating": false, "requires_active_body": false, "replayable": false, "requires_selector": false, "open_profile_ok": false}, "atomic_id": "transform_bodies", "contract_version": "3.3", "fragment_shape": {"sketch": "forbidden", "params": "required_object", "selector_tokens": "forbidden"}, @@ -107,10 +109,10 @@ "semantic_preflight": ["source_bodies_exist", "pattern_instance_bodies_exist", "transform_copy_bodies_exist", "body_transform"], "candidate_verifiers": [] }, - "delete_bodies": {"atomic_id":"delete_bodies","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true}},"required":["target_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[]}, - "pattern_linear": {"atomic_id":"pattern_linear","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"direction_1":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_1_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_1":{"type":"integer","minimum":1,"maximum":128},"direction_2":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_2_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_2":{"type":"integer","minimum":1,"maximum":128}},"required":["source_feature_ids","direction_1","spacing_1_mm","pattern_count_1"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]}, - "pattern_mirror": {"atomic_id":"pattern_mirror","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"mirror_current_body":{"type":"boolean"}},"required":["source_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":"params.mirror_plane","token_kind":"plane","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.mirror_plane"],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist","mirror_plane_exists"],"candidate_verifiers":[]}, - "pattern_circular": {"atomic_id":"pattern_circular","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pattern_count":{"type":"integer","minimum":1,"maximum":128},"sweep_angle_deg":{"type":"number","minimum":-360,"maximum":360},"operation_mode":{"enum":["add","remove"]},"excluded_instance_indices":{"type":"array","items":{"type":"integer","minimum":1,"maximum":127},"uniqueItems":true}},"required":["source_feature_ids","axis","pattern_count"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]} + "delete_bodies": {"atomic_id":"delete_bodies","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true}},"required":["target_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "pattern_linear": {"atomic_id":"pattern_linear","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"direction_1":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_1_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_1":{"type":"integer","minimum":1,"maximum":128},"direction_2":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_2_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_2":{"type":"integer","minimum":1,"maximum":128}},"required":["source_feature_ids","direction_1","spacing_1_mm","pattern_count_1"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "pattern_mirror": {"atomic_id":"pattern_mirror","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"mirror_current_body":{"type":"boolean"}},"required":["source_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":"params.mirror_plane","token_kind":"plane","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.mirror_plane"],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist","mirror_plane_exists"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "pattern_circular": {"atomic_id":"pattern_circular","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pattern_count":{"type":"integer","minimum":1,"maximum":128},"sweep_angle_deg":{"type":"number","minimum":-360,"maximum":360},"operation_mode":{"enum":["add","remove"]},"excluded_instance_indices":{"type":"array","items":{"type":"integer","minimum":1,"maximum":127},"uniqueItems":true}},"required":["source_feature_ids","axis","pattern_count"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}} }, "profiles": { "circle": { diff --git a/backend/engine/cdsl_engine/rebuild.py b/backend/engine/cdsl_engine/rebuild.py index 5d3815a1..784b152f 100644 --- a/backend/engine/cdsl_engine/rebuild.py +++ b/backend/engine/cdsl_engine/rebuild.py @@ -1,40 +1,42 @@ -""" -CDSL → STEP 重建管道 -==================== +""" +CDSL → STEP 重建管道 +==================== 优先: CDSL → capability planner → session runtime → STEP (engine=cdsl_only) -回退: CDSL + compiler_context → translator -""" - -from __future__ import annotations - -import json -import os -import sys -import time -from pathlib import Path -from typing import Any - -try: +回退: CDSL + compiler_context → translator +""" + +from __future__ import annotations + +import json +import os +import sys +import time +from pathlib import Path +from typing import Any + +try: from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches - from .llm_compiler import compile_cdsl - from .llm_engine import run_engine_plan - from .translator import generate_build123d_code, normalize_to_ir -except ImportError: # 允许直接 python rebuild.py + from .llm_compiler import compile_cdsl + from .llm_engine import run_engine_plan + from .translator import generate_build123d_code, normalize_to_ir + from .legacy.exact_rebuild import _apply_geometric_compensations, _run_exact, _run_parameterized +except ImportError: # 允许直接 python rebuild.py from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches - from llm_compiler import compile_cdsl - from llm_engine import run_engine_plan - from translator import generate_build123d_code, normalize_to_ir - - + from llm_compiler import compile_cdsl + from llm_engine import run_engine_plan + from translator import generate_build123d_code, normalize_to_ir + from legacy.exact_rebuild import _apply_geometric_compensations, _run_exact, _run_parameterized + + def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None, force_exact: bool = False) -> dict[str, Any]: - """主重建入口。 - - 优先:纯 CDSL 参数化路径(sketch_solver → llm_compiler → llm_engine),不依赖 compiler_context。 - 回退:CDSL + compiler_context 的 translator 路径。 - """ + """主重建入口。 + + 优先:纯 CDSL 参数化路径(sketch_solver → llm_compiler → llm_engine),不依赖 compiler_context。 + 回退:CDSL + compiler_context 的 translator 路径。 + """ # Compatibility entry point only: new CDSL-only runtime calls do not use # macro profiles and therefore never invoke this adapter. cdsl = lower_legacy_profiles(cdsl) @@ -43,34 +45,34 @@ def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = No # executable by the CDSL-only runtime. ``all([])`` deliberately keeps # that path available rather than forcing an unavailable legacy fallback. all_drawable = all(_sketch_is_cdsl_drawable(s) for s in sketches) - + cdsl_only_error: Exception | None = None if all_drawable and not force_exact: try: return run_cdsl_only(cdsl, out_step, gold_step=gold_step) except Exception as e: cdsl_only_error = e - - # 加载 compiler_context(后备路径) - ctx = None - if ctx_file and ctx_file.exists(): - with open(ctx_file, "r", encoding="utf-8") as f: - ctx = json.load(f) - if ctx is None: - part_id = cdsl.get("part_id", "") - sw_json = out_step.parent / f"{part_id}.solidworks_rebuild_extract.json" - if sw_json.exists(): - with open(sw_json, "r", encoding="utf-8") as f: - sw_data = json.load(f) - ir = normalize_to_ir(sw_data) - ctx = { - "version": ir.get("version", "ir-0.1"), - "metadata": ir.get("metadata", {}), - "sketches": ir.get("sketches", []), - "operations": ir.get("operations", []), - "references": ir.get("references", []), - "validation_hints": ir.get("validation_hints", {}), - } + + # 加载 compiler_context(后备路径) + ctx = None + if ctx_file and ctx_file.exists(): + with open(ctx_file, "r", encoding="utf-8") as f: + ctx = json.load(f) + if ctx is None: + part_id = cdsl.get("part_id", "") + sw_json = out_step.parent / f"{part_id}.solidworks_rebuild_extract.json" + if sw_json.exists(): + with open(sw_json, "r", encoding="utf-8") as f: + sw_data = json.load(f) + ir = normalize_to_ir(sw_data) + ctx = { + "version": ir.get("version", "ir-0.1"), + "metadata": ir.get("metadata", {}), + "sketches": ir.get("sketches", []), + "operations": ir.get("operations", []), + "references": ir.get("references", []), + "validation_hints": ir.get("validation_hints", {}), + } if ctx is None and not (cdsl.get("compiler_context")): if cdsl_only_error is not None: raise RuntimeError(f"CDSL-only rebuild failed: {cdsl_only_error}") from cdsl_only_error @@ -78,40 +80,40 @@ def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = No "CDSL-only rebuild is unavailable: every sketch must use a supported " "self-contained profile." ) - - if ctx is not None: - cdsl["compiler_context"] = ctx - - has_profiled = any( - s.get("profile") or s.get("profile_from") or s.get("entities") or s.get("contour_edges_mm") - for s in sketches - ) - if has_profiled and not force_exact: - try: - return _run_parameterized(cdsl, out_step, gold_step=gold_step) - except Exception as e: - import traceback - traceback.print_exc() - print(f" [WARN] parameterized path failed: {e}, falling back to exact") - - return _run_exact(cdsl, out_step, gold_step) - - -def _sketch_is_cdsl_drawable(sketch: dict[str, Any]) -> bool: - """草图是否可仅凭 CDSL profile 展开(不靠 compiler_context 注坐标)。""" - if sketch.get("profile_from"): - return True - profile = sketch.get("profile") - if not profile: - return bool(sketch.get("entities") or sketch.get("contour_edges_mm") or sketch.get("contour_regions_mm")) - ptype = profile.get("type") - if ptype in ("complex_arc_shape", "unknown_shape"): - return False - if ptype == "polygon": - return bool(profile.get("vertices")) - return ptype in SHAPE_GENERATORS - - + + if ctx is not None: + cdsl["compiler_context"] = ctx + + has_profiled = any( + s.get("profile") or s.get("profile_from") or s.get("entities") or s.get("contour_edges_mm") + for s in sketches + ) + if has_profiled and not force_exact: + try: + return _run_parameterized(cdsl, out_step, gold_step=gold_step) + except Exception as e: + import traceback + traceback.print_exc() + print(f" [WARN] parameterized path failed: {e}, falling back to exact") + + return _run_exact(cdsl, out_step, gold_step) + + +def _sketch_is_cdsl_drawable(sketch: dict[str, Any]) -> bool: + """草图是否可仅凭 CDSL profile 展开(不靠 compiler_context 注坐标)。""" + if sketch.get("profile_from"): + return True + profile = sketch.get("profile") + if not profile: + return bool(sketch.get("entities") or sketch.get("contour_edges_mm") or sketch.get("contour_regions_mm")) + ptype = profile.get("type") + if ptype in ("complex_arc_shape", "unknown_shape"): + return False + if ptype == "polygon": + return bool(profile.get("vertices")) + return ptype in SHAPE_GENERATORS + + def run_cdsl_only(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: """Pure semantic CDSL path with no compiler_context fallback.""" t0 = time.time() @@ -124,449 +126,218 @@ def run_cdsl_only(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = result["engine"] = "cdsl_only" result["elapsed_s"] = round(time.time() - t0, 1) result.setdefault("log", []).append("cdsl_only: capability planner + session runtime (no compiler_context)") - if gold_step and gold_step.exists(): - result["gold_step"] = str(gold_step) - return result - - + if gold_step and gold_step.exists(): + result["gold_step"] = str(gold_step) + return result + + def compile_cdsl_to_pack(cdsl: dict[str, Any]) -> dict[str, Any]: - pack = compile_cdsl({k: v for k, v in cdsl.items() if k != "compiler_context"}) - pack.pop("compiler_context", None) - return pack - - -def run_engine(pack: dict[str, Any], out_step: Path) -> dict[str, Any]: - return run_engine_plan(pack, out_step) - - -def _run_parameterized(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: - """参数化路径: CDSL语义结构 + compiler_context精确数据 → translator生成代码 → 执行 - - 采用双层IR架构: - Learning IR (CDSL) 提供参数化形状、特征结构 - Execution IR (compiler_context) 提供精确坐标 - translator 提供经过充分测试的代码生成 - """ - - import subprocess - import tempfile, os - - t0 = time.time() - - # 1. 获取 compiler_context (Execution IR: 精确坐标) - compiler_context = cdsl.get("compiler_context") or {} - if not compiler_context: - # 从外部文件加载 - ctx_file = out_step.parent / "{}.compiler_context.json".format(cdsl.get("part_id", "")) - if ctx_file.exists(): - import json as _json - with open(ctx_file, "r", encoding="utf-8") as _f: - compiler_context = _json.load(_f) - if not compiler_context: - raise RuntimeError("CDSL缺少 compiler_context,无法重建") - - part_name = str(cdsl.get("part_id") or out_step.stem) - context = dict(compiler_context) - context.setdefault("metadata", {})["part_name"] = part_name - - # 2. 将 compiler_context 的精确实体注入 CDSL 草图 (供 sketch_solver 使用) - # 015133: CDSL (Learning IR) 不含坐标,坐标来自 Execution IR - ctx_sketches_map = {s["id"]: s for s in context.get("sketches", [])} - cdsl_sketches = cdsl.get("geometry", {}).get("sketches", []) - for sk in cdsl_sketches: - ctx_sk = ctx_sketches_map.get(sk["id"]) - if ctx_sk: - # 注入 entities/contour 供 polygon/complex_arc_shape 生成器使用 - if not sk.get("entities"): - sk["entities"] = ctx_sk.get("entities", []) - if not sk.get("contour_edges_mm"): - sk["contour_edges_mm"] = ctx_sk.get("contour_edges_mm", []) - - # 3. 解析 CDSL 的参数化草图 (现在有 entities 可用) - cdsl_resolved = resolve_all_sketches(cdsl) - - # 4. 将 CDSL 解析后的 profile/profile_from 注入 compiler_context - # translator 使用 compiler_context 的精确 entities + CDSL 的 profile 分类 - cdsl_resolved_map = {s["id"]: s for s in cdsl_resolved.get("geometry", {}).get("sketches", [])} - ctx_sketches = list(context.get("sketches", [])) - updated_count = 0 - for i, ctx_sk in enumerate(ctx_sketches): - sk_id = ctx_sk.get("id", "") - cdsl_sk = cdsl_resolved_map.get(sk_id) - if cdsl_sk and cdsl_sk.get("profile"): - ctx_sketches[i] = {**ctx_sk, "profile": cdsl_sk["profile"]} - updated_count += 1 - if cdsl_sk and cdsl_sk.get("profile_from"): - ctx_sketches[i] = {**ctx_sk, "profile_from": cdsl_sk["profile_from"]} - updated_count += 1 - context["sketches"] = ctx_sketches - - # 4. 使用 compiler_context 的原始 operations(保持 translator 兼容性) - - # 5. 读取 gold volume - gold_volume_mm3 = None - if gold_step and gold_step.exists(): - try: - from build123d import import_step - gold_solid = import_step(str(gold_step)) - gold_volume_mm3 = float(gold_solid.volume) - except Exception: - pass - - # 6. 用 translator 生成并执行 - code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3) - - # 6b. 应用几何补偿 (SW导出缺失的特征) - part_id = str(cdsl.get("part_id") or "") - code = _apply_geometric_compensations(code, part_id) - - out_step.parent.mkdir(parents=True, exist_ok=True) - - with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as tf: - tf.write(code) - script_path = tf.name - - try: - r = subprocess.run( - ["python", script_path], - capture_output=True, text=True, encoding="utf-8", timeout=120, - env={**os.environ, "PYTHONIOENCODING": "utf-8"}, - ) - if r.returncode != 0: - raise RuntimeError(f"Build script failed:\n{r.stderr}") - finally: - try: - os.unlink(script_path) - except Exception: - pass - - # 7. 读取重建结果 - out_step.parent.mkdir(parents=True, exist_ok=True) - built_step = Path(part_name + ".step") - if not built_step.exists(): - built_step = Path.cwd() / (part_name + ".step") - if built_step.exists(): - import shutil - shutil.copy2(str(built_step), str(out_step)) - built_step.unlink() - else: - raise RuntimeError(f"No STEP output found: {part_name}.step") - - from build123d import import_step - rebuilt = import_step(str(out_step)) - bbox = rebuilt.bounding_box() - bbox_mm = { - "min": [bbox.min.X, bbox.min.Y, bbox.min.Z], - "max": [bbox.max.X, bbox.max.Y, bbox.max.Z], - } - - elapsed = time.time() - t0 - return { - "out_step": str(out_step), - "volume_mm3": float(rebuilt.volume), - "bbox_mm": bbox_mm, - "log": [f"param: CDSL-informed translator rebuild, {updated_count} sketches updated from CDSL"], - "engine": "parameterized", - "elapsed_s": round(elapsed, 1), - } - - -def _run_exact(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: - """精确路径: generate_build123d_code (后备)""" - - import subprocess - - compiler_context = cdsl.get("compiler_context") or {} - part_name = str(cdsl.get("part_id") or out_step.stem) - context = dict(compiler_context) - context.setdefault("metadata", {})["part_name"] = part_name - - # Read gold volume if available, for chamfer/candidate scoring - gold_volume_mm3 = None - if gold_step and gold_step.exists(): - try: - from build123d import import_step - gold_solid = import_step(str(gold_step)) - gold_volume_mm3 = float(gold_solid.volume) - except Exception: - pass - - out_step.parent.mkdir(parents=True, exist_ok=True) - - # Apply geometric compensations FIRST (may return full replacement code) - part_id = str(cdsl.get("part_id") or "") - compensation_code = _apply_geometric_compensations("", part_id) - - if compensation_code and "build123d" in compensation_code and "__main__" in compensation_code: - # 完整替换代码 (跳过generate_build123d_code) - code = compensation_code - else: - code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3) - code = _apply_geometric_compensations(code, part_id) - - t0 = time.time() - script_path = out_step.parent / "_tmp" / f"build_{part_name}_{int(time.time())}.py" - script_path.parent.mkdir(exist_ok=True) - script_path.write_text(code, encoding="utf-8") - - completed = subprocess.run( - [sys.executable, str(script_path)], - cwd=out_step.parent, - capture_output=True, - text=True, - timeout=600, - ) - - if completed.returncode != 0: - raise RuntimeError( - f"Exact compiler FAILED (rc={completed.returncode})\n" - f"STDOUT:\n{completed.stdout[-2000:]}\n" - f"STDERR:\n{completed.stderr[-3000:]}" - ) - # Print any warnings from safe_subtract - for line in completed.stdout.split('\n'): - if 'SUBTRACT' in line or 'UNION' in line: - print(f" {line.strip()}") - - from build123d import import_step - # 生成的 build 脚本将 STEP 写到 CWD 下的 "{part_name}.step" - # 移到 out_step 位置以供后续对比 - actual_step = out_step.parent / f"{part_name}.step" - if actual_step.exists(): - import shutil - shutil.copy2(str(actual_step), str(out_step)) - solid = import_step(str(out_step)) - bb = solid.bounding_box() - elapsed = time.time() - t0 - - return { - "out_step": str(out_step), - "volume_mm3": float(solid.volume), - "bbox_mm": {"min": [bb.min.X, bb.min.Y, bb.min.Z], - "max": [bb.max.X, bb.max.Y, bb.max.Z]}, - "engine": "exact", - "elapsed_s": round(elapsed, 1), - } - - -def compare_with_gold(gold_step: Path, rebuilt_step: Path) -> dict[str, Any]: - from build123d import import_step - import math, random, time - gold = import_step(str(gold_step)) - rebuilt = import_step(str(rebuilt_step)) - gv = float(gold.volume) - rv = float(rebuilt.volume) - rel_err = abs(rv - gv) / gv * 100 if gv > 0 else 0 - gb = gold.bounding_box() - rb = rebuilt.bounding_box() - bbox_delta = max( - abs(gb.min.X - rb.min.X), abs(gb.min.Y - rb.min.Y), - abs(gb.min.Z - rb.min.Z), abs(gb.max.X - rb.max.X), - abs(gb.max.Y - rb.max.Y), abs(gb.max.Z - rb.max.Z), - ) - shape_deltas = _surface_deviation(gold, rebuilt, n_points=500) - shape_p99 = shape_deltas.get("shape_p99_delta_mm", 999) - shape_median = shape_deltas.get("shape_median_delta_mm", 999) - over_pct = shape_deltas.get("shape_over_0.5mm_pct", 100) - - # 形状一致性分级(形状为主,体积/包围盒仅作参考) - if shape_p99 <= 1.0: - shape_grade = "A" # 完美形状匹配 - elif shape_p99 <= 6.0: - shape_grade = "B" # 优质形状匹配(6mm容忍build123d对SW有机Loft/放样的偏差) - elif shape_p99 <= 8.0: - shape_grade = "C" # 可接受 - else: - shape_grade = "F" # 形状偏差过大 - - # 形状通过: P99≤6mm(99%采样点偏差≤6mm),体积误差≤10%,包围盒≤2mm - shape_pass = shape_p99 <= 6.0 - vol_sane = rel_err <= 10.0 - bbox_sane = bbox_delta <= 2.0 - passed = shape_pass and vol_sane and bbox_sane - - report = { - "gold_volume_mm3": gv, - "rebuilt_volume_mm3": rv, - "volume_rel_err_pct": round(rel_err, 4), - "gold_bbox_mm": {"min": [gb.min.X, gb.min.Y, gb.min.Z], - "max": [gb.max.X, gb.max.Y, gb.max.Z]}, - "rebuilt_bbox_mm": {"min": [rb.min.X, rb.min.Y, rb.min.Z], - "max": [rb.max.X, rb.max.Y, rb.max.Z]}, - "bbox_max_delta_mm": round(bbox_delta, 4), - **shape_deltas, - "shape_grade": shape_grade, - "passed": passed, - } - if not passed: - reasons = [] - if not shape_pass: - reasons.append(f"shape_p99={shape_p99:.1f}mm > 6mm") - if not vol_sane: - reasons.append(f"vol_err={rel_err:.1f}% > 10%") - if not bbox_sane: - reasons.append(f"bbox_delta={bbox_delta:.1f}mm > 2.0mm") - report["fail_reasons"] = " | ".join(reasons) - return report - - -def _surface_deviation(gold, rebuilt, n_points: int = 500) -> dict[str, Any]: - """用BRepExtrema计算gold和rebuilt表面顶点间的精确距离偏差""" - from OCP.BRepExtrema import BRepExtrema_DistShapeShape - from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex - from OCP.gp import gp_Pnt - import random, math - - random.seed(42) - - def sample_points(solid, max_n): - pts = [] - for v in solid.vertices(): - pts.append((float(v.X), float(v.Y), float(v.Z))) - for e in solid.edges(): - try: - c = e.center() - pts.append((float(c.X), float(c.Y), float(c.Z))) - except Exception: - pass - if len(pts) > max_n: - pts = random.sample(pts, max_n) - return pts - - def point_to_solid_dist(px, py, pz, solid_wrapped): - vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(px, py, pz)).Vertex() - ds = BRepExtrema_DistShapeShape() - ds.LoadS1(vertex) - ds.LoadS2(solid_wrapped) - ds.Perform() - if ds.IsDone() and ds.NbSolution() > 0: - return ds.Value() - return float('inf') - - gw = gold.wrapped - rw = rebuilt.wrapped - pts_g = sample_points(gold, n_points) - pts_r = sample_points(rebuilt, n_points) - - deltas = [] - for (px, py, pz) in pts_g: - d = point_to_solid_dist(px, py, pz, rw) - if d < float('inf'): - deltas.append(d) - for (px, py, pz) in pts_r: - d = point_to_solid_dist(px, py, pz, gw) - if d < float('inf'): - deltas.append(d) - - if not deltas: - return {"shape_mean_delta_mm": 0.0, "shape_max_delta_mm": 0.0, - "shape_median_delta_mm": 0.0, "shape_n_samples": 0} - - deltas.sort() - n = len(deltas) - mean_d = sum(deltas) / n - max_d = deltas[-1] - median_d = deltas[n // 2] - p90 = deltas[int(n * 0.9)] if n > 10 else max_d - p95 = deltas[int(n * 0.95)] if n > 20 else max_d - p99 = deltas[int(n * 0.99)] if n > 100 else max_d - - over_01mm = sum(1 for d in deltas if d > 0.01) - over_05mm = sum(1 for d in deltas if d > 0.5) - over_pct = round(over_05mm / n * 100, 1) if n else 0 - - return { - "shape_mean_delta_mm": round(mean_d, 4), - "shape_max_delta_mm": round(max_d, 4), - "shape_median_delta_mm": round(median_d, 4), - "shape_p90_delta_mm": round(p90, 4), - "shape_p95_delta_mm": round(p95, 4), - "shape_p99_delta_mm": round(p99, 4), - "shape_n_samples": n, - "shape_n_over_0.01mm": over_01mm, - "shape_n_over_0.5mm": over_05mm, - "shape_over_0.5mm_pct": over_pct, - } - - -# 保留旧版本的_sample_surface_points清理掉 -# (下面的不再需要,新逻辑已在_surface_deviation中实现) - - -# ═══════════════════════════════════════════════════════════════ -# Geometric compensations(项目特例;拷贝到其他项目时可删) -# ═══════════════════════════════════════════════════════════════ - -def _apply_geometric_compensations(code: str, part_id: str) -> str: - """为SW导出中缺失的特征添加几何补偿切操作""" - if part_id == "113246": - if "export_step(result, " not in code: - return code - comp = ( - " # === COMPENSATION: 侧槽 (SW缺失特征) ===\n" - " with BuildSketch(Plane(origin=(-70.0, -13.0, 10.0), " - "x_dir=(0.0, 1.0, 0.0), z_dir=(1.0, 0.0, 0.0))) as comp_sk:\n" - " Rectangle(10.0, 3.0, align=(Align.MIN, Align.MIN))\n" - " comp_cutter = extrude(comp_sk.sketch, amount=10.0)\n" - " result = safe_subtract(result, comp_cutter)\n" - ) - code = code.replace("export_step(result, ", comp + " export_step(result, ") - return code - - -# =========================================================================== -# CLI(便携:显式路径,无项目目录假设) -# =========================================================================== - -def main(): - import argparse - - ap = argparse.ArgumentParser(description="CDSL -> STEP rebuild (portable engine)") - ap.add_argument("--cdsl", type=Path, required=True, help="CDSL JSON path") - ap.add_argument("--out", type=Path, required=True, help="output STEP path") - ap.add_argument("--gold", type=Path, default=None, help="optional gold STEP") - ap.add_argument("--ctx", type=Path, default=None, help="optional compiler_context") - ap.add_argument("--force-exact", action="store_true") - ap.add_argument("--report", type=Path, default=None) - args = ap.parse_args() - - cdsl = json.loads(args.cdsl.read_text(encoding="utf-8")) - out_step = args.out - out_step.parent.mkdir(parents=True, exist_ok=True) - - print(f"Rebuild: {args.cdsl} -> {out_step}") - try: - result = run_rebuild( - cdsl, out_step, ctx_file=args.ctx, gold_step=args.gold, force_exact=args.force_exact - ) - except Exception as e: - print(f"REBUILD ERROR: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - - print(f" engine={result.get('engine')} volume={result['volume_mm3']:.2f} mm3") - report = { - "cdsl_path": str(args.cdsl), - "rebuilt_step": str(out_step), - "engine_result": result, - } - status = "OK" - if args.gold and args.gold.exists(): - comp = compare_with_gold(args.gold, out_step) - report["comparison"] = comp - status = "PASS" if comp["passed"] else "FAIL" - print( - f" gold compare: {status} vol_err={comp['volume_rel_err_pct']:.2f}% " - f"shape={comp.get('shape_grade')} p99={comp.get('shape_p99_delta_mm')}" - ) - report_path = args.report or out_step.with_suffix(".rebuild_report.json") - report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8") - print(f"Report: {report_path}") - print(f"Final: {status}") - if args.gold and args.gold.exists() and not report.get("comparison", {}).get("passed", True): - sys.exit(1) - - -if __name__ == "__main__": - main() + pack = compile_cdsl({k: v for k, v in cdsl.items() if k != "compiler_context"}) + pack.pop("compiler_context", None) + return pack + + +def run_engine(pack: dict[str, Any], out_step: Path) -> dict[str, Any]: + return run_engine_plan(pack, out_step) + + +def compare_with_gold(gold_step: Path, rebuilt_step: Path) -> dict[str, Any]: + from build123d import import_step + import math, random, time + gold = import_step(str(gold_step)) + rebuilt = import_step(str(rebuilt_step)) + gv = float(gold.volume) + rv = float(rebuilt.volume) + rel_err = abs(rv - gv) / gv * 100 if gv > 0 else 0 + gb = gold.bounding_box() + rb = rebuilt.bounding_box() + bbox_delta = max( + abs(gb.min.X - rb.min.X), abs(gb.min.Y - rb.min.Y), + abs(gb.min.Z - rb.min.Z), abs(gb.max.X - rb.max.X), + abs(gb.max.Y - rb.max.Y), abs(gb.max.Z - rb.max.Z), + ) + shape_deltas = _surface_deviation(gold, rebuilt, n_points=500) + shape_p99 = shape_deltas.get("shape_p99_delta_mm", 999) + shape_median = shape_deltas.get("shape_median_delta_mm", 999) + over_pct = shape_deltas.get("shape_over_0.5mm_pct", 100) + + # 形状一致性分级(形状为主,体积/包围盒仅作参考) + if shape_p99 <= 1.0: + shape_grade = "A" # 完美形状匹配 + elif shape_p99 <= 6.0: + shape_grade = "B" # 优质形状匹配(6mm容忍build123d对SW有机Loft/放样的偏差) + elif shape_p99 <= 8.0: + shape_grade = "C" # 可接受 + else: + shape_grade = "F" # 形状偏差过大 + + # 形状通过: P99≤6mm(99%采样点偏差≤6mm),体积误差≤10%,包围盒≤2mm + shape_pass = shape_p99 <= 6.0 + vol_sane = rel_err <= 10.0 + bbox_sane = bbox_delta <= 2.0 + passed = shape_pass and vol_sane and bbox_sane + + report = { + "gold_volume_mm3": gv, + "rebuilt_volume_mm3": rv, + "volume_rel_err_pct": round(rel_err, 4), + "gold_bbox_mm": {"min": [gb.min.X, gb.min.Y, gb.min.Z], + "max": [gb.max.X, gb.max.Y, gb.max.Z]}, + "rebuilt_bbox_mm": {"min": [rb.min.X, rb.min.Y, rb.min.Z], + "max": [rb.max.X, rb.max.Y, rb.max.Z]}, + "bbox_max_delta_mm": round(bbox_delta, 4), + **shape_deltas, + "shape_grade": shape_grade, + "passed": passed, + } + if not passed: + reasons = [] + if not shape_pass: + reasons.append(f"shape_p99={shape_p99:.1f}mm > 6mm") + if not vol_sane: + reasons.append(f"vol_err={rel_err:.1f}% > 10%") + if not bbox_sane: + reasons.append(f"bbox_delta={bbox_delta:.1f}mm > 2.0mm") + report["fail_reasons"] = " | ".join(reasons) + return report + + +def _surface_deviation(gold, rebuilt, n_points: int = 500) -> dict[str, Any]: + """用BRepExtrema计算gold和rebuilt表面顶点间的精确距离偏差""" + from OCP.BRepExtrema import BRepExtrema_DistShapeShape + from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex + from OCP.gp import gp_Pnt + import random, math + + random.seed(42) + + def sample_points(solid, max_n): + pts = [] + for v in solid.vertices(): + pts.append((float(v.X), float(v.Y), float(v.Z))) + for e in solid.edges(): + try: + c = e.center() + pts.append((float(c.X), float(c.Y), float(c.Z))) + except Exception: + pass + if len(pts) > max_n: + pts = random.sample(pts, max_n) + return pts + + def point_to_solid_dist(px, py, pz, solid_wrapped): + vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(px, py, pz)).Vertex() + ds = BRepExtrema_DistShapeShape() + ds.LoadS1(vertex) + ds.LoadS2(solid_wrapped) + ds.Perform() + if ds.IsDone() and ds.NbSolution() > 0: + return ds.Value() + return float('inf') + + gw = gold.wrapped + rw = rebuilt.wrapped + pts_g = sample_points(gold, n_points) + pts_r = sample_points(rebuilt, n_points) + + deltas = [] + for (px, py, pz) in pts_g: + d = point_to_solid_dist(px, py, pz, rw) + if d < float('inf'): + deltas.append(d) + for (px, py, pz) in pts_r: + d = point_to_solid_dist(px, py, pz, gw) + if d < float('inf'): + deltas.append(d) + + if not deltas: + return {"shape_mean_delta_mm": 0.0, "shape_max_delta_mm": 0.0, + "shape_median_delta_mm": 0.0, "shape_n_samples": 0} + + deltas.sort() + n = len(deltas) + mean_d = sum(deltas) / n + max_d = deltas[-1] + median_d = deltas[n // 2] + p90 = deltas[int(n * 0.9)] if n > 10 else max_d + p95 = deltas[int(n * 0.95)] if n > 20 else max_d + p99 = deltas[int(n * 0.99)] if n > 100 else max_d + + over_01mm = sum(1 for d in deltas if d > 0.01) + over_05mm = sum(1 for d in deltas if d > 0.5) + over_pct = round(over_05mm / n * 100, 1) if n else 0 + + return { + "shape_mean_delta_mm": round(mean_d, 4), + "shape_max_delta_mm": round(max_d, 4), + "shape_median_delta_mm": round(median_d, 4), + "shape_p90_delta_mm": round(p90, 4), + "shape_p95_delta_mm": round(p95, 4), + "shape_p99_delta_mm": round(p99, 4), + "shape_n_samples": n, + "shape_n_over_0.01mm": over_01mm, + "shape_n_over_0.5mm": over_05mm, + "shape_over_0.5mm_pct": over_pct, + } + + +# 保留旧版本的_sample_surface_points清理掉 +# (下面的不再需要,新逻辑已在_surface_deviation中实现) + + +# =========================================================================== +# CLI(便携:显式路径,无项目目录假设) +# =========================================================================== + +def main(): + import argparse + + ap = argparse.ArgumentParser(description="CDSL -> STEP rebuild (portable engine)") + ap.add_argument("--cdsl", type=Path, required=True, help="CDSL JSON path") + ap.add_argument("--out", type=Path, required=True, help="output STEP path") + ap.add_argument("--gold", type=Path, default=None, help="optional gold STEP") + ap.add_argument("--ctx", type=Path, default=None, help="optional compiler_context") + ap.add_argument("--force-exact", action="store_true") + ap.add_argument("--report", type=Path, default=None) + args = ap.parse_args() + + cdsl = json.loads(args.cdsl.read_text(encoding="utf-8")) + out_step = args.out + out_step.parent.mkdir(parents=True, exist_ok=True) + + print(f"Rebuild: {args.cdsl} -> {out_step}") + try: + result = run_rebuild( + cdsl, out_step, ctx_file=args.ctx, gold_step=args.gold, force_exact=args.force_exact + ) + except Exception as e: + print(f"REBUILD ERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + print(f" engine={result.get('engine')} volume={result['volume_mm3']:.2f} mm3") + report = { + "cdsl_path": str(args.cdsl), + "rebuilt_step": str(out_step), + "engine_result": result, + } + status = "OK" + if args.gold and args.gold.exists(): + comp = compare_with_gold(args.gold, out_step) + report["comparison"] = comp + status = "PASS" if comp["passed"] else "FAIL" + print( + f" gold compare: {status} vol_err={comp['volume_rel_err_pct']:.2f}% " + f"shape={comp.get('shape_grade')} p99={comp.get('shape_p99_delta_mm')}" + ) + report_path = args.report or out_step.with_suffix(".rebuild_report.json") + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8") + print(f"Report: {report_path}") + print(f"Final: {status}") + if args.gold and args.gold.exists() and not report.get("comparison", {}).get("passed", True): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/backend/engine/cdsl_engine/registry.py b/backend/engine/cdsl_engine/registry.py new file mode 100644 index 00000000..129ccfdb --- /dev/null +++ b/backend/engine/cdsl_engine/registry.py @@ -0,0 +1,83 @@ +"""Atomic executor registry and dispatch for the session runtime. + +Executors register themselves with :func:`atomic_executor` from their own +modules; ``cdsl_engine.executors`` imports every executor module exactly once +to build the registry. Adding a new atomic operation therefore means adding +a decorated function in a family module -- the registry itself never changes. + +``registry`` deliberately imports no executor module, so pattern executors +can re-enter :func:`execute_node` for replay without an import cycle. +""" + +from __future__ import annotations + +from typing import Any, Callable, Protocol + +from .session import ExecutionSession +from .topology import CapabilityResult, FeaturePlanNode, FeatureResult + + +class AtomicExecutor(Protocol): + atomic_id: str + + def preflight(self, node: FeaturePlanNode, session: ExecutionSession) -> CapabilityResult: ... + def execute(self, node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: ... + + +#: The complete capability contract of this runtime. Every registered +#: executor must name an id from this set, so a mistyped registration fails +#: at import time instead of surfacing as an unknown-atomic blocker later. +ALL_ATOMIC_IDS = frozenset({ + "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface", + "extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add", + "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", + "hole_counterbore", "sphere_add", "box_add", "cylinder_add", + "reference_plane", "reference_axis", + "hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror", + "pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies", + "thread_add", "thread_cut", + "bend_add", + "gear_add", "rack_add", +}) + +#: ``atomic_id -> executor``. Populated by ``cdsl_engine.executors``. +EXECUTORS: dict[str, "ExecutorFunction"] = {} + +ExecutorFunction = Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult] + + +def atomic_executor(*atomic_ids: str) -> Callable[["ExecutorFunction"], "ExecutorFunction"]: + """Register one executor function for one or more atomic ids. + + Registration validates against ``ALL_ATOMIC_IDS`` and rejects duplicates, + keeping the registry consistent with the declared capability contract. + """ + unknown = [atomic_id for atomic_id in atomic_ids if atomic_id not in ALL_ATOMIC_IDS] + if unknown: + raise ValueError(f"cannot register executors for unknown atomic ids: {unknown}") + + def decorator(func: "ExecutorFunction") -> "ExecutorFunction": + for atomic_id in atomic_ids: + if atomic_id in EXECUTORS: + raise ValueError(f"duplicate executor registration for {atomic_id!r}") + EXECUTORS[atomic_id] = func + return func + + return decorator + + +def execute_node( + node: FeaturePlanNode, + session: ExecutionSession, + sketch_override: dict[str, Any] | None = None, +) -> FeatureResult: + """Dispatch one plan node through its registered executor.""" + executor = EXECUTORS.get(node.atomic_id) + if executor is None: + raise ValueError(f"No executor registered for {node.atomic_id!r}") + previous_feature_id = session.active_feature_id + session.active_feature_id = node.feature_id + try: + return executor(node, session, sketch_override) + finally: + session.active_feature_id = previous_feature_id diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index 56b9198a..e01c20bd 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -1,2577 +1,94 @@ -"""Session-based CDSL execution with atomic executor registry.""" +"""Session-based CDSL execution entry points. + +The runtime was split into focused modules (behavior-preserving move): + +- ``registry``: atomic executor registry, ``atomic_executor`` decorator, and + the ``execute_node`` dispatcher. +- ``executors/``: one module per executor family; importing the package + performs the registration and verifies registry completeness. +- ``session``: ``ExecutionSession`` and the ``GeometryAdapter`` protocol. +- ``runtime_base``: shared error types and the ``ExtentVector`` value. +- ``extents``: end-condition planning. +- ``pattern_transform``: translate/mirror/rotate parameter algebra for replay. + +This module keeps the ``analyze_cdsl`` / ``rebuild_cdsl`` entry points and +re-exports the historical ``cdsl_engine.runtime`` names, including the +private helpers referenced by the test suite, so every existing import keeps +working. +""" from __future__ import annotations from copy import deepcopy -from dataclasses import dataclass, field -import math from pathlib import Path -from typing import Any, Callable, Protocol +from typing import Any -from .build123d_adapter import Build123dGeometryAdapter +from . import executors # noqa: F401 (importing performs executor registration) from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract -from .runtime_types import ( - AxisSpec, BendSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec, - GearSpec, RackSpec, ThreadSpec, TopologyDelta, TopologyDeltaRelation, Vector3, - RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry, +# Historical private name still imported by the test suite. +from .executors.primitives import _execute_box # noqa: F401 +from .extents import ( + _extent_reference, + _extent_vectors, + _extent_vectors_from_normal, + _normal_from_sketch, + _side_extent_vectors, + _targeted_extent_vector, +) +from .pattern_transform import ( + _box_circular_is_exact, + _coordinate_axis_direction, + _mirrored_node, + _mirrored_sketch, + _normal_is_coordinate_axis, + _owner_plane_frame, + _pattern_operation_node, + _reflect_point, + _rotated_node, + _rotated_point, + _rotated_sketch, + _rotated_vector, + _transformed_loft_profiles, + _translated_node, + _translated_sketch, +) +from .registry import ( + ALL_ATOMIC_IDS, + EXECUTORS, + AtomicExecutor, + ExecutorFunction, + atomic_executor, + execute_node, +) +from .runtime_base import ExtentVector, FeatureExecutionError, RuntimeExecutionError +from .session import ExecutionSession, GeometryAdapter +from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches +from .specs import ( + AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit, ) -from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches - - -ALL_ATOMIC_IDS = frozenset({ - "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface", - "extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add", - "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", - "hole_counterbore", "sphere_add", "box_add", "cylinder_add", - "reference_plane", "reference_axis", - "hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror", - "pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies", - "thread_add", "thread_cut", - "bend_add", - "gear_add", "rack_add", -}) - - -class RuntimeExecutionError(RuntimeError): - """A feature execution failure with serializable runtime evidence.""" - - def __init__(self, diagnostic: RuntimeDiagnostic, selector_resolutions: list[dict[str, Any]]) -> None: - super().__init__(diagnostic.message) - self.diagnostic = diagnostic - self.selector_resolutions = selector_resolutions - - -class FeatureExecutionError(RuntimeError): - """An expected feature-level execution rejection with a stable code.""" - - def __init__(self, code: str, message: str, **detail: Any) -> None: - super().__init__(message) - self.code = code - self.detail = detail - - -@dataclass(frozen=True) -class ExtentVector: - """Single-directional extrusion displacement for one profile face. - - ``trim_to`` stays ``None`` for an exact vector extrusion. When set, the - extent means "extrude until the target face, trimming any profile region - that does not reach it" (up_to_surface trim semantics, issue #5). The - piercing distance is computed inside the adapter, so ``vector`` only - supplies the direction. - """ - - vector: Vector3 - trim_to: Any | None = None - - -class AtomicExecutor(Protocol): - atomic_id: str - - def preflight(self, node: FeaturePlanNode, session: "ExecutionSession") -> CapabilityResult: ... - def execute(self, node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: ... - - -class GeometryAdapter(Protocol): - """Kernel boundary consumed by the session runtime. - - Geometry values remain opaque here. A future adapter may use a different - B-rep kernel as long as it preserves these construction/query contracts. - """ - - def topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ... - def body_solids(self, body: Any) -> list[Any]: ... - def body_geometry(self, body: Any) -> dict[str, Any]: ... - def surface_geometry(self, surface: Any) -> dict[str, Any]: ... - def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ... - def face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ... - def loft(self, sketches: list[dict[str, Any]]) -> Any: ... - def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Any, TopologyDelta | None]: ... - def loft_with_cap_face(self, cap_face: Any, sketches: list[dict[str, Any]]) -> Any: ... - def sweep(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> Any: ... - def sweep_with_topology_delta(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> tuple[Any, TopologyDelta | None]: ... - def sweep_path(self, points: list[Vector3], *, start_tangent: Vector3 | None = None, end_tangent: Vector3 | None = None, parameters: list[float] | None = None) -> Any: ... - def face_normal(self, face: Any) -> Vector3: ... - def extrude(self, face: Any, direction: Vector3) -> Any: ... - def extrude_with_topology_delta(self, face: Any, direction: Vector3) -> tuple[Any, TopologyDelta]: ... - def extrude_taper_with_topology_delta(self, face: Any, direction: Vector3, taper_deg: float) -> tuple[Any, TopologyDelta | None]: ... - def extrude_taper(self, face: Any, direction: Vector3, taper_deg: float) -> Any: ... - def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ... - def surface_wires_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ... - def extrude_surface(self, wires: list[Any], direction: Vector3) -> Any: ... - def combine_surfaces(self, *surfaces: Any) -> Any: ... - def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ... - def revolve_surface(self, wire: Any, angle_deg: float, axis: AxisSpec) -> Any: ... - def intersect(self, left: Any, right: Any) -> Any: ... - def intersect_with_topology_delta(self, left: Any, right: Any) -> tuple[Any, TopologyDelta | None]: ... - def transform(self, body: Any, transform: dict[str, Any]) -> Any: ... - def transform_with_topology_delta(self, body: Any, transform: dict[str, Any]) -> tuple[Any, TopologyDelta]: ... - def fuse(self, body: Any | None, solid: Any) -> Any: ... - def fuse_with_topology_delta(self, body: Any | None, solid: Any) -> tuple[Any, TopologyDelta | None]: ... - def combine(self, body: Any | None, solid: Any) -> Any: ... - def cut(self, body: Any, tool: Any) -> Any: ... - def cut_with_topology_delta(self, body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]: ... - def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ... - def cylinder_with_topology_delta(self, radius_mm: float, height_mm: float, axis: AxisSpec | None = None) -> tuple[Any, TopologyDelta]: ... - def thread_solid(self, spec: ThreadSpec) -> Any: ... - def bend_solid(self, spec: BendSpec) -> Any: ... - def gear_solid(self, spec: GearSpec) -> Any: ... - def rack_solid(self, spec: RackSpec) -> Any: ... - def hole_tool(self, spec: HoleSpec, starts: list[Vector3], inward: Vector3, through_depth_mm: float) -> Any: ... - def body_center(self, body: Any) -> Vector3: ... - def body_span(self, body: Any, direction: Vector3) -> float: ... - def vertex_coordinates(self, vertex: Any) -> Vector3: ... - def intersection_vertex(self, body: Any, face_sets: list[list[Any]]) -> Any: ... - def profile_sample_points(self, face: Any) -> list[Any]: ... - def profile_touches_target(self, target: Any, faces: list[Any]) -> bool: ... - def next_body_face_after(self, body: Any, faces: list[Any], direction: Vector3, *, excluded_face: Any) -> Any: ... - def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ... - def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ... - def fillet_with_topology_delta(self, body: Any, radius_mm: float, edges: list[Any]) -> tuple[Any, TopologyDelta | None]: ... - def tangent_edges(self, body: Any, seeds: list[Any]) -> list[Any]: ... - def chamfer(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> Any: ... - def chamfer_with_topology_delta(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> tuple[Any, TopologyDelta | None]: ... - def surface_limited_chamfer(self, body: Any, distance_mm: float, edges: list[Any], surfaces: list[Any]) -> Any: ... - def shell(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> Any: ... - def shell_with_topology_delta(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> tuple[Any, TopologyDelta]: ... - def export(self, body: Any, path: str) -> None: ... - - -@dataclass -class ExecutionSession: - sketches: dict[str, dict[str, Any]] - nodes: dict[str, FeaturePlanNode] - adapter: GeometryAdapter = field(default_factory=Build123dGeometryAdapter) - topology: TopologyRegistry = field(default_factory=TopologyRegistry) - body: Any | None = None - body_id: str | None = None - results: dict[str, FeatureResult] = field(default_factory=dict) - replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict) - body_members: dict[str, Any] = field(default_factory=dict) - surface_members: dict[str, Any] = field(default_factory=dict) - selector_resolutions: list[dict[str, Any]] = field(default_factory=list) - active_feature_id: str = "" - - def register_body( - self, - feature_id: str, - body: Any, - *, - replay_node: FeaturePlanNode | None = None, - body_members: dict[str, Any] | None = None, - topology_delta: TopologyDelta | None = None, - topology_predecessors: list[TopologyRecord] | None = None, - ) -> None: - # #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的 - # 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id": - # 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标 - # 实体;单体保持 body:{feature}(与历史行为完全一致)。 - self.body = body - self.body_id = f"body:{feature_id}" - self.body_members = dict(body_members) if body_members is not None else {feature_id: body} - solids = self.adapter.body_solids(body) - if len(solids) <= 1: - self.topology.replace_body_topology( - feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id), - topology_delta=topology_delta, - additional_predecessors=topology_predecessors or (), - ) - else: - # 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让 - # 已登记的本轮成员成为下一个成员的 predecessor,进而把 pattern copy - # 的 owner 错误转移到相邻实例。必须原子替换整个多 body 拓扑快照。 - members = [ - (member_id, self.adapter.topology_records(solid, feature_id, member_id)) - for index, solid in enumerate(solids) - for member_id in [f"{self.body_id}:{index}"] - ] - self.topology.replace_body_topologies( - feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta, - additional_predecessors=topology_predecessors or (), - ) - self.topology.register(TopologyRecord( - record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id, - geometry=self.adapter.body_geometry(body), value=body, owner_feature_ids=(feature_id,), - )) - if replay_node is not None: - self.replay_definitions[feature_id] = replay_node - - def register_surface(self, feature_id: str, surface: Any) -> str: - # 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body, - # 否则 surface 会覆盖 active solid 并改变最终 STEP 的实体结果。 - surface_id = f"surface:{feature_id}" - self.surface_members[feature_id] = surface - for record in self.adapter.topology_records(surface, feature_id, surface_id): - self.topology.register(record) - self.topology.register(TopologyRecord( - record_id=surface_id, kind="surface", feature_id=feature_id, body_id=surface_id, - geometry=self.adapter.surface_geometry(surface), value=surface, owner_feature_ids=(feature_id,), - )) - return surface_id - - def clear_body(self) -> None: - """Clear the active solid after an explicit deleteBodies result.""" - self.body = None - self.body_id = None - self.body_members = {} - - def _record_selector_resolution(self, resolution: SelectorResolution) -> SelectorResolution: - evidence = resolution.as_dict() - evidence["feature_id"] = self.active_feature_id - self.selector_resolutions.append(evidence) - return resolution - - def _intersection_component_records(self, selector: dict[str, Any]) -> list[TopologyRecord]: - matched = selector.get("matched_selectors") if selector.get("match_mode") == "all" else None - if matched is not None: - if not isinstance(matched, list) or not matched: - raise FeatureExecutionError("intersection_selector_unbound", "Intersection selector has no bound face matches") - resolved = [self._record_selector_resolution(self.topology.resolve(item, active_body_id=self.body_id)) for item in matched] - else: - binding_feature_id = selector.get("binding_feature_id") - active_body_id = None if binding_feature_id and self.body_id != f"body:{binding_feature_id}" else self.body_id - resolved = [self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))] - failures = [item for item in resolved if item.status != "resolved" or (item.record is None and not item.records)] - if failures: - detail = failures[0].diagnostic.message if failures[0].diagnostic else "intersection selector component was not resolved" - raise FeatureExecutionError("intersection_selector_component_unresolved", detail) - return [ - record - for item in resolved - for record in (item.records or ((item.record,) if item.record is not None else ())) - ] - - def _resolve_intersection_vertex(self, selector: dict[str, Any]) -> SelectorResolution: - components = selector.get("intersection_of") - if self.body is None: - return SelectorResolution( - selector=selector, status="not_found", candidates=(), - diagnostic=RuntimeDiagnostic("missing_extent_body", "Intersection selector requires an existing body"), - ) - if not isinstance(components, list) or len(components) < 2: - return SelectorResolution( - selector=selector, status="not_found", candidates=(), - diagnostic=RuntimeDiagnostic("intersection_selector_incomplete", "Intersection selector requires at least two face components"), - ) - try: - face_sets = [self._intersection_component_records(component) for component in components] - if any(record.kind != "face" for records in face_sets for record in records): - raise FeatureExecutionError("intersection_selector_kind", "Intersection selector components must resolve to faces") - vertex = self.adapter.intersection_vertex(self.body, [[record.value for record in records] for records in face_sets]) - except FeatureExecutionError as error: - return SelectorResolution( - selector=selector, status="not_found", candidates=(), - diagnostic=RuntimeDiagnostic(error.code, str(error), detail=error.detail), - ) - except ValueError as error: - return SelectorResolution( - selector=selector, status="not_found", candidates=(), - diagnostic=RuntimeDiagnostic("intersection_vertex_unresolved", str(error)), - ) - point = self.adapter.vertex_coordinates(vertex) - record = TopologyRecord( - record_id=str(selector.get("stable_id") or f"intersection:{id(vertex)}"), - kind="vertex", feature_id=self.active_feature_id, body_id=self.body_id, - geometry={"center_mm": list(point)}, value=vertex, - owner_feature_ids=tuple(filter(None, [str(selector.get("owner_feature_id") or "")])), - ) - return SelectorResolution( - selector=selector, status="resolved", record=record, - candidates=({"score": 1.0, **record.public_dict()},), - ) - - def resolve(self, selector: dict[str, Any]) -> SelectorResolution: - if selector.get("intersection_of") is not None: - return self._record_selector_resolution(self._resolve_intersection_vertex(selector)) - owner = str(selector.get("owner_feature_id") or "") - active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id - return self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id)) - - def result( - self, - node: FeaturePlanNode, - *, - context: PlaneSpec | AxisSpec | None = None, - diagnostics: list[RuntimeDiagnostic] | None = None, - include_body: bool = True, - surface_id: str | None = None, - ) -> FeatureResult: - result = FeatureResult( - feature_id=node.feature_id, atomic_id=node.atomic_id, status="executed", - body_id=self.body_id if include_body else None, surface_id=surface_id, - context=context, replay_definition={"atomic_id": node.atomic_id, "params": deepcopy(node.params), "sketch_id": node.sketch_id}, - diagnostics=diagnostics or [], - ) - self.results[node.feature_id] = result - return result - - def replay_sources(self, source_feature_ids: list[Any]) -> list[FeaturePlanNode]: - """Return selected source features in their original history order. - - A pattern's exported selection order is not an execution order. In - particular, a boolean cut may appear before its parent boss in the - raw selection array. The CDSL feature list is dependency-ordered by - semantic validation, so it is the stable order for replay. - """ - requested = {str(feature_id) for feature_id in source_feature_ids} - sources = [ - feature - for feature_id, feature in self.nodes.items() - if feature_id in requested and feature_id in self.replay_definitions - ] - if len(sources) != len(requested): - missing = sorted(requested - {source.feature_id for source in sources}) - raise ValueError(f"pattern source features have no replay definitions: {', '.join(missing)}") - return sources - - -def _normal_from_sketch(sketch: dict[str, Any]) -> Vector3: - return PlaneSpec.from_mapping(sketch.get("workplane") or {}).normal - - -def _extent_reference(node: FeaturePlanNode, condition: dict[str, Any] | None = None) -> dict[str, Any]: - condition = condition or node.params.get("end_condition") or {} - reference = condition.get("reference") - if not isinstance(reference, dict): - raise FeatureExecutionError( - "missing_extent_reference", - "This end condition requires a captured target selector", - extent=condition.get("type"), - ) - return reference - - -def _targeted_extent_vector( - node: FeaturePlanNode, - faces: list[Any], - direction: Vector3, - session: ExecutionSession, - condition: str, - *, - end_condition: dict[str, Any] | None = None, - offset_mm: float | None = None, -) -> ExtentVector: - if session.body is None: - raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition) - if condition == "through_next": - target = session.body - else: - reference = _extent_reference(node, end_condition) - resolution = session.resolve(reference) - if resolution.status != "resolved" or resolution.record is None: - raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "extent target was not resolved") - expected_kind = {"up_to_vertex": "vertex", "up_to_body": "body"}.get(condition, "face") - if resolution.record.kind != expected_kind: - raise FeatureExecutionError( - "unsupported_extent_target", - "The resolved target kind is incompatible with this end condition", - extent=condition, expected_kind=expected_kind, actual_kind=resolution.record.kind, - ) - target = resolution.record.value - if condition == "up_to_vertex": - target_point = session.adapter.vertex_coordinates(target) - projections = [ - vector_dot(vector_subtract(target_point, point), direction) - for face in faces - for point in session.adapter.profile_sample_points(face) - ] - if not projections or min(projections) <= 1e-6: - raise FeatureExecutionError("extent_target_not_in_direction", "The target vertex is not ahead of the profile", extent=condition) - if max(projections) - min(projections) > 1e-5: - raise FeatureExecutionError("non_uniform_extent_target", "The target vertex does not define one extrusion distance", extent=condition) - distance = sum(projections) / len(projections) - else: - if condition == "up_to_surface" and session.adapter.profile_touches_target(target, faces): - # 草图轮廓本身就在所选终止面上时,selected face 只是拉伸的起始 - # 边界。应沿实际拉伸方向穿过当前 body,取下一张完整截获 profile - # 的边界面作为终止面;直接裁剪到 selected face 会生成零厚度工具体。 - try: - next_face = session.adapter.next_body_face_after( - session.body, faces, direction, excluded_face=target, - ) - except ValueError as error: - raise FeatureExecutionError("extent_target_not_reached", str(error), extent=condition) from error - return ExtentVector(vector_scale(direction, 1.0), trim_to=next_face) - try: - distance = session.adapter.uniform_intersection_distance(target, faces, direction) - except ValueError as error: - message = str(error) - code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached" - if condition in {"up_to_surface", "through_next"}: - # #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未 - # 命中目标 → 悬空;或各点命中距离不一 → 斜目标面)时不再整体 - # 拒绝,而是"裁剪"——只保留从 profile 到目标面之间的材料。 - # extrude_trimmed 内部做穿透拉伸 + 与目标面体层布尔求交,未达 - # 目标的部分被切掉(CAD "拉伸到面"标准语义)。若全部采样点都 - # 未命中(profile 与目标面无交叠),extrude_trimmed 内部仍抛 - # "not reached",保持显式拒绝。 - # through_next 从当前主体中选取实际命中的下一张面; - # up_to_vertex/up_to_body/offset_from_surface 无 face 可构造 - # 裁剪体层,仍保持显式拒绝。 - return ExtentVector(vector_scale(direction, 1.0), trim_to=target) - raise FeatureExecutionError(code, message, extent=condition) from error - offset = abs(float((end_condition or {}).get("offset_mm") or 0.0)) - if condition == "offset_from_surface": - offset = abs(float(offset_mm if offset_mm is not None else offset or node.params.get("distance_mm") or 0.0)) - if offset: - distance -= offset - if distance <= 1e-6: - raise FeatureExecutionError( - "invalid_extent_offset", - "Offset distance reaches or passes the target extent", - extent=condition, offset_mm=offset, - ) - return ExtentVector(vector_scale(direction, distance)) - - -def _side_extent_vectors( - node: FeaturePlanNode, - faces: list[Any], - direction: Vector3, - session: ExecutionSession, - *, - end_condition: dict[str, Any], - distance_mm: float, -) -> list[ExtentVector]: - """Resolve one directional extent without borrowing the opposite side. - - ``extrude_add_two_sided`` and ``extrude_cut_two_sided`` call this once for each independently captured - termination. The regular one-sided executor also uses it for all simple - termination modes, keeping the geometry adapter interface uniform. - """ - condition = str(end_condition.get("type") or "blind") - distance = abs(float(distance_mm or 0.0)) - if condition == "blind": - if distance <= 0: - raise ValueError("blind extent requires distance_mm > 0") - return [ExtentVector(vector_scale(direction, distance))] - if condition == "mid_plane": - if distance <= 0: - raise ValueError("mid_plane extent requires distance_mm > 0") - return [ - ExtentVector(vector_scale(direction, distance / 2)), - ExtentVector(vector_scale(direction, -distance / 2)), - ] - if condition == "through_all": - if session.body is None: - if distance <= 0: - raise ValueError("through_all on an initial feature has no body and no fallback distance") - # 注意:Vector3 是 tuple,不能直接做 direction * distance(那是元组 - # 重复),这里必须用 vector_scale 做数乘(顺带修复的隐藏 bug)。 - return [ExtentVector(vector_scale(direction, distance))] - return [ExtentVector(vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0))] - if condition in {"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body"}: - return [ - _targeted_extent_vector( - node, faces, direction, session, condition, - end_condition=end_condition, offset_mm=distance, - ) - ] - raise ValueError(f"unsupported directional extent {condition!r}") - - -def _extent_vectors( - node: FeaturePlanNode, - faces: list[Any], - sketch: dict[str, Any], - session: ExecutionSession, -) -> list[ExtentVector]: - return _extent_vectors_from_normal( - node, faces, vector_unit(_normal_from_sketch(sketch), field_name="sketch normal"), session, - ) - - -def _extent_vectors_from_normal( - node: FeaturePlanNode, - faces: list[Any], - profile_normal: Vector3, - session: ExecutionSession, -) -> list[ExtentVector]: - """Resolve extents from an explicit profile normal. - - A derived profile can be an actual B-rep face rather than a sketch. Its - outward normal is just as authoritative as a sketch workplane normal, so - both profile sources share the same bounded extent semantics. - """ - params = node.params - normal = vector_unit(profile_normal, field_name="profile normal") - if bool(params.get("reverse")): - normal = vector_scale(normal, -1) - end_condition = params.get("end_condition") or {"type": "blind"} - condition = end_condition.get("type", "blind") - distance = abs(float(params.get("distance_mm") or 0.0)) - if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"} or bool(params.get("two_sided")): - reverse_condition = params.get("reverse_end_condition") or {"type": "blind"} - reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0)) - if reverse_distance <= 0: - raise ValueError("two-sided extrusion requires reverse_distance_mm > 0") - return [ - *_side_extent_vectors( - node, faces, normal, session, end_condition=end_condition, distance_mm=distance, - ), - *_side_extent_vectors( - node, faces, vector_scale(normal, -1), session, - end_condition=reverse_condition, distance_mm=reverse_distance, - ), - ] - if condition in {"through_all", "through_all_both", "through_all_and_blind"}: - if session.body is None: - # A first feature with through-all has no body to terminate - # against. The source must provide a usable blind component. - if distance <= 0: - raise ValueError("through_all on an initial feature has no body and no fallback distance") - return [ExtentVector(vector_scale(normal, distance))] - span = max(session.adapter.body_span(session.body, normal), 1.0) + 2.0 - if condition == "through_all": - return [ExtentVector(vector_scale(normal, span))] - if condition == "through_all_both": - return [ExtentVector(vector_scale(normal, span)), ExtentVector(vector_scale(normal, -span))] - # Through-all-and-blind is represented by a through direction plus - # its captured opposite blind direction when available. - reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0)) - return [ - ExtentVector(vector_scale(normal, span)), - ExtentVector(vector_scale(normal, -(reverse_distance or span))), - ] - return _side_extent_vectors( - node, faces, normal, session, end_condition=end_condition, distance_mm=distance, - ) - - -def _revolve_axis(node: FeaturePlanNode, session: ExecutionSession) -> AxisSpec: - raw_axis = node.params.get("axis") or {} - if raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None: - return AxisSpec.from_mapping(raw_axis) - selector = raw_axis.get("selector") if isinstance(raw_axis, dict) else None - if not isinstance(selector, dict): - selector = next((item for item in node.selectors if item.get("kind") == "axis"), None) - if not isinstance(selector, dict): - raise FeatureExecutionError( - "missing_revolve_axis", - "Revolve requires an explicit axis or an owner-qualified reference-axis selector", - ) - resolution = session.resolve(selector) - if resolution.status != "resolved" or resolution.record is None: - raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "revolve axis was not resolved") - if not isinstance(resolution.record.value, AxisSpec): - raise FeatureExecutionError( - "unsupported_revolve_axis", "The resolved context is not an axis", actual_kind=resolution.record.kind, - ) - return resolution.record.value - - -def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any]) -> None: - """Defend direct CDSL execution from an out-of-plane revolve axis.""" - plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) - direction_normal_dot = abs(vector_dot(axis.direction, plane.normal)) - if direction_normal_dot > 1e-7: - raise ValueError( - "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.direction must be parallel to " - f"sketch.workplane; abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}" - ) - origin_plane_offset = abs(vector_dot(vector_subtract(axis.origin_mm, plane.origin_mm), plane.normal)) - if origin_plane_offset > 1e-6: - raise ValueError( - "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.origin_mm must lie in " - f"sketch.workplane; plane_offset_mm={origin_plane_offset:.3g}" - ) - - -def _cut_explicit_body_members(session: ExecutionSession, tool: Any) -> dict[str, Any]: - """Apply a cut to each independently owned body without erasing ownership. - - A CADFS NEW body stays independently addressable even when a later REMOVE - feature affects several active bodies. Cutting the aggregate first loses - that identity, so this path uses the equivalent per-member set difference - and drops only members that the tool removes completely. - """ - members: dict[str, Any] = {} - for feature_id, body in session.body_members.items(): - result = session.adapter.cut(body, tool) - if abs(float(result.volume)) > 1e-12: - members[feature_id] = result - return members - - -def _extruded_tool( - node: FeaturePlanNode, - faces: list[Any], - profile_normal: Vector3, - session: ExecutionSession, -) -> tuple[Any, TopologyDelta | None]: - """Build one extrude tool, retaining caps only from one exact builder result.""" - extents = _extent_vectors_from_normal(node, faces, profile_normal, session) - draft = node.params.get("draft") - taper_deg = 0.0 - if isinstance(draft, dict): - taper_deg = float(draft["angle_deg"]) - if not bool(draft["pull_direction"]): - taper_deg = -taper_deg - topology_delta: TopologyDelta | None = None - solids: list[Any] = [] - for face in faces: - for extent in extents: - if draft is not None: - if len(faces) == 1 and len(extents) == 1: - solid, topology_delta = session.adapter.extrude_taper_with_topology_delta( - face, extent.vector, taper_deg, - ) - solids.append(solid) - else: - solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg)) - elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1: - solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector) - solids.append(solid) - elif extent.trim_to is None: - solids.append(session.adapter.extrude(face, extent.vector)) - else: - solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector)) - tool = None - for solid in solids: - tool = session.adapter.fuse(tool, solid) - if tool is None: - raise ValueError("extrude produced no solid") - return tool, topology_delta - - -def _apply_primary_tool( - node: FeaturePlanNode, - session: ExecutionSession, - tool: Any, - *, - cutting: bool, - topology_delta: TopologyDelta | None = None, -) -> FeatureResult: - """Apply a profile-derived tool while preserving only final-snapshot topology evidence.""" - if cutting: - if session.body is None: - raise ValueError("cut feature has no body") - members = _cut_explicit_body_members(session, tool) - if not members: - session.clear_body() - return session.result(node) - body = session.adapter.cut(session.body, tool) - topology_delta = None - elif node.params.get("result_mode") == "new_body": - body = session.adapter.combine(session.body, tool) - members = {**session.body_members, node.feature_id: tool} - else: - body = session.adapter.fuse(session.body, tool) - members = {node.feature_id: body} - # A fuse rebuilds subshape identity. Builder evidence belongs only to - # an unchanged standalone/new-body prism snapshot. - if session.body is not None: - topology_delta = None - session.register_body( - node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta, - ) - return session.result(node) - - -def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, sketch: dict[str, Any] | None = None) -> FeatureResult: - # 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。 - - # 1. 取草图:优先使用外部传入的 sketch_override(阵列/镜像等重放场景), - # 否则按 sketch_id 从会话草图表中取原始草图。 - selected_sketch = sketch or session.sketches.get(str(node.sketch_id)) - if selected_sketch is None: - raise ValueError("primary feature has no resolved sketch") - # 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。 - faces = session.adapter.faces_for_sketch(selected_sketch) - if not faces: - raise ValueError("sketch does not create a closed profile region") - if node.atomic_id == "extrude_add_blind_with_hole": - resolved = [session.resolve(selector) for selector in node.selectors] - failed = next((item for item in resolved if item.status != "resolved"), None) - if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face": - raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "profile hole selector is unresolved") - if len(faces) != 1: - raise ValueError("profile hole extrusion requires exactly one outer sketch region") - faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])] - topology_delta: TopologyDelta | None = None - # 3. 按特征类型生成子实体: - if node.atomic_id.startswith("extrude_"): - # 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量, - # 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在 - # profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪 - # 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。 - tool, topology_delta = _extruded_tool( - node, faces, _normal_from_sketch(selected_sketch), session, - ) - else: - # 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。 - axis = _revolve_axis(node, session) - _validate_revolve_axis_in_sketch_plane(axis, selected_sketch) - angle = float(node.params.get("angle_deg") or 0.0) - if angle <= 0: - raise ValueError("revolve requires angle_deg > 0") - # reverse=true 表示绕轴反向扫掠(SolidWorks 旋转方向反转):取负 - # 旋转角,与 extrude 的 reverse(_extent_vectors 反转拉伸方向)同一 - # 语义。profile_schema.json 已声明 revolve.* optional_params 含 - # reverse,cdsl_schema.json revolveParams 也已允许,这里补齐 runtime - # 侧实现,使三方合同一致。 - if bool(node.params.get("reverse")): - angle = -angle - tool = None - for solid in (session.adapter.revolve(face, angle, axis) for face in faces): - tool = session.adapter.fuse(tool, solid) - if tool is None: - raise ValueError("revolve produced no solid") - return _apply_primary_tool( - node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta, - ) - - -def _execute_extrude_from_face(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - resolved = [session.resolve(selector) for selector in node.selectors] - failed = next((item for item in resolved if item.status != "resolved"), None) - if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face": - raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "derived profile face is unresolved") - face = resolved[0].record.value - tool, topology_delta = _extruded_tool(node, [face], session.adapter.face_normal(face), session) - return _apply_primary_tool( - node, session, tool, cutting=node.params.get("operation") == "cut", topology_delta=topology_delta, - ) - - -def _execute_revolve_surface(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # Surface revolve 的 profile 是单一闭合 wire。它只生成独立 shell,不能参与 - # 当前实体 body 的 fuse/cut,也不能把其结果误报为新的实体 body。 - sketch = session.sketches.get(str(node.sketch_id)) - if sketch is None: - raise ValueError("surface revolve has no resolved sketch") - faces = session.adapter.faces_for_sketch(sketch) - if len(faces) != 1 or faces[0].inner_wires(): - raise ValueError("surface revolve requires exactly one closed profile without holes") - axis = _revolve_axis(node, session) - _validate_revolve_axis_in_sketch_plane(axis, sketch) - angle = float(node.params.get("angle_deg") or 0.0) - if angle <= 0: - raise ValueError("surface revolve requires angle_deg > 0") - if bool(node.params.get("reverse")): - angle = -angle - surface_id = session.register_surface( - node.feature_id, - session.adapter.revolve_surface(faces[0].outer_wire(), angle, axis), - ) - return session.result(node, include_body=False, surface_id=surface_id) - - -def _execute_extrude_surface(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # surfaceEntities 的曲面拉伸沿用实体特征已 lower 的距离,但始终独立登记为 - # shell。它既不改变 active solid,也不以曲面参与实体 fuse/cut。 - sketch = session.sketches.get(str(node.sketch_id)) - if sketch is None: - raise ValueError("surface extrude has no resolved sketch") - direction = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal") - if bool(node.params.get("reverse")): - direction = vector_scale(direction, -1) - distance = float(node.params.get("distance_mm") or 0.0) - if distance <= 0: - raise ValueError("surface extrude requires distance_mm > 0") - wires = session.adapter.surface_wires_for_sketch(sketch) - surface = session.adapter.extrude_surface(wires, vector_scale(direction, distance)) - reverse_distance = float(node.params.get("reverse_distance_mm") or 0.0) - if reverse_distance > 0: - opposite = session.adapter.extrude_surface(wires, vector_scale(direction, -reverse_distance)) - surface = session.adapter.combine_surfaces(surface, opposite) - surface_id = session.register_surface(node.feature_id, surface) - return session.result(node, include_body=False, surface_id=surface_id) - - -def _combine_members(session: ExecutionSession, members: dict[str, Any]) -> Any: - body = None - for member in members.values(): - body = session.adapter.combine(body, member) - if body is None: - raise ValueError("booleanBodies produced no result bodies") - return body - - -def _execute_boolean_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # booleanBodies 总是作用于 source feature 的明确 body 输出,不能回退为 - # 当前聚合 body。这样相邻独立实体不会意外成为工具或目标。 - params = node.params - target_ids = _member_sources( - node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs", - ) - tool_ids = _member_sources( - node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs", - ) - targets = {feature_id: session.body_members[feature_id] for feature_id in target_ids} - tools = {feature_id: session.body_members[feature_id] for feature_id in tool_ids} - target = _combine_members(session, targets) - tool = _combine_members(session, tools) - operation = str(params.get("operation") or "") - topology_delta: TopologyDelta | None = None - if operation == "union": - result, topology_delta = session.adapter.fuse_with_topology_delta(target, tool) - elif operation == "subtract": - result, topology_delta = session.adapter.cut_with_topology_delta(target, tool) - elif operation == "intersect": - result, topology_delta = session.adapter.intersect_with_topology_delta(target, tool) - else: - raise ValueError(f"unsupported booleanBodies operation {operation!r}") - members = { - feature_id: body - for feature_id, body in session.body_members.items() - if feature_id not in set(target_ids + tool_ids) - } - members[node.feature_id] = result - if bool(params.get("keep_tools")): - members.update(tools) - session.register_body( - node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta, - ) - return session.result(node) - - -def _pattern_instance_sources( - node: FeaturePlanNode, - session: ExecutionSession, - parameter: str = "pattern_instance_refs", -) -> list[str]: - """Resolve CDSL pattern-instance refs to their internal body-member keys.""" - resolved: list[str] = [] - for reference in node.params.get(parameter) or (): - if not isinstance(reference, dict): - raise ValueError("pattern instance reference must be an object") - pattern_id = str(reference.get("pattern_feature_id") or "") - source_id = str(reference.get("source_feature_id") or "") - instance = reference.get("instance_index") - if not pattern_id or not source_id or not isinstance(instance, int): - raise ValueError("pattern instance reference is incomplete") - pattern = session.nodes.get(pattern_id) - if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}: - raise ValueError(f"pattern instance owner is unavailable: {pattern_id}") - params = pattern.params - if source_id not in {str(value) for value in params.get("source_feature_ids") or ()}: - raise ValueError("pattern instance source is not selected by its pattern") - count = int(params.get("pattern_count") or 0) - excluded = {int(value) for value in params.get("excluded_instance_indices") or ()} - if ( - pattern.atomic_id == "pattern_mirror" and instance != 1 - ) or ( - pattern.atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded) - ): - raise ValueError("pattern instance is outside the pattern's surviving instances") - member_id = pattern_instance_member_id(pattern_id, source_id, instance) - if member_id not in session.body_members: - raise ValueError(f"pattern instance body is unavailable: {pattern_id}/{source_id}/{instance}") - if member_id not in resolved: - resolved.append(member_id) - return resolved - - -def _transform_copy_sources(node: FeaturePlanNode, session: ExecutionSession) -> list[str]: - """Resolve source-qualified outputs of preceding multi-body COPY transforms.""" - resolved: list[str] = [] - for reference in node.params.get("transform_copy_refs") or (): - if not isinstance(reference, dict): - raise ValueError("transform COPY reference must be an object") - transform_id = str(reference.get("transform_feature_id") or "") - source_id = str(reference.get("source_feature_id") or "") - if not transform_id or not source_id: - raise ValueError("transform COPY reference is incomplete") - transform = session.nodes.get(transform_id) - params = transform.params if transform is not None else {} - sources = params.get("source_feature_ids") or [] - if ( - transform is None - or transform.atomic_id != "transform_bodies" - or not bool(params.get("make_copy")) - or not isinstance(sources, list) - or len(sources) < 2 - or source_id not in {str(value) for value in sources} - ): - raise ValueError(f"transform COPY owner/source is unavailable: {transform_id}/{source_id}") - member_id = transform_copy_member_id(transform_id, source_id) - if member_id not in session.body_members: - raise ValueError(f"transform COPY body is unavailable: {transform_id}/{source_id}") - if member_id not in resolved: - resolved.append(member_id) - return resolved - - -def _member_sources( - node: FeaturePlanNode, - session: ExecutionSession, - parameter: str, - *, - pattern_instance_parameter: str | None = None, - allow_transform_copies: bool = False, -) -> list[str]: - source_ids = [str(value) for value in node.params.get(parameter) or []] - if pattern_instance_parameter is not None: - source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter)) - if allow_transform_copies: - source_ids.extend(_transform_copy_sources(node, session)) - if not source_ids: - raise ValueError(f"{node.atomic_id} requires explicit {parameter}") - missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members] - if missing: - raise ValueError(f"{node.atomic_id} source bodies are unavailable: " + ", ".join(missing)) - return source_ids - - -def _execute_transform_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # FeatureScript transform targets explicit bodies. Do not move the - # aggregate session body, because it may include unrelated members. - source_ids = _member_sources( - node, session, "source_feature_ids", pattern_instance_parameter="pattern_instance_refs", allow_transform_copies=True, - ) - make_copy = bool(node.params.get("make_copy")) - direct_sources = node.params.get("source_feature_ids") or [] - if make_copy and isinstance(direct_sources, list) and len(direct_sources) > 1: - # The aggregate is only an export compound. Each source transform has - # its own B-rep builder and is the only output a later COPY query may - # select. Do not attach an aggregate topology delta to source members. - members = dict(session.body_members) - members.update({ - transform_copy_member_id(node.feature_id, source_id): session.adapter.transform( - session.body_members[source_id], dict(node.params.get("transform") or {}), - ) - for source_id in source_ids - }) - session.register_body( - node.feature_id, _combine_members(session, members), body_members=members, - ) - return session.result(node) - source = _combine_members(session, {feature_id: session.body_members[feature_id] for feature_id in source_ids}) - transformed, topology_delta = session.adapter.transform_with_topology_delta( - source, dict(node.params.get("transform") or {}), - ) - members = dict(session.body_members) - if not make_copy: - for feature_id in source_ids: - members.pop(feature_id) - members[node.feature_id] = transformed - session.register_body( - node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta, - ) - return session.result(node) - - -def _execute_delete_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # Deletion is a body-graph operation, never a Boolean subtraction. A - # selected member can be disjoint or overlap another independent body. - source_ids = _member_sources(node, session, "target_feature_ids") - members = {feature_id: body for feature_id, body in session.body_members.items() if feature_id not in set(source_ids)} - if members: - session.register_body(node.feature_id, _combine_members(session, members), body_members=members) - else: - session.clear_body() - return session.result(node) - - -def _execute_loft_add(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 放样截面不占用 feature.sketch_id;按有序 profile_sketch_ids 取已解析 - # 草图,并由 adapter 统一校验单闭环、无内环等内核输入约束。 - profile_ids = node.params.get("profile_sketch_ids") or [] - profiles: list[dict[str, Any]] = [] - for sketch_id in profile_ids: - sketch = session.sketches.get(str(sketch_id)) - if sketch is None: - raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved") - profiles.append(sketch) - solid, topology_delta = session.adapter.loft_with_topology_delta(profiles) - body = session.adapter.fuse(session.body, solid) - # Fusing a loft into an existing body replaces its subshapes through a - # different builder. Only an initial direct loft can expose this builder's - # cap evidence for the final B-rep snapshot. - if session.body is not None: - topology_delta = None - session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) - return session.result(node) - - -def _execute_loft_add_with_cap_face(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - resolved = [session.resolve(selector) for selector in node.selectors] - failed = next((item for item in resolved if item.status != "resolved"), None) - if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face": - raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "cap-face loft selector is unresolved") - profile_ids = node.params.get("profile_sketch_ids") or [] - profiles: list[dict[str, Any]] = [] - for sketch_id in profile_ids: - sketch = session.sketches.get(str(sketch_id)) - if sketch is None: - raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved") - profiles.append(sketch) - solid = session.adapter.loft_with_cap_face(resolved[0].record.value, profiles) - session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) - return session.result(node) - - -def _sweep_path(node: FeaturePlanNode, session: ExecutionSession) -> Any: - # 路径是 self-contained CDSL 数据,避免重放时依赖临时草图或 source id。 - path = node.params.get("path") or {} - if not isinstance(path, dict): - raise ValueError("sweep path must be an object") - plane = PlaneSpec.from_mapping(path.get("workplane") or {}) - segment = path.get("segment") or {} - if not isinstance(segment, dict): - raise ValueError("sweep path segment must be an object") - kind = str(segment.get("type") or "") - if kind == "line": - local_points = [segment.get("start"), segment.get("end")] - elif kind == "bspline": - local_points = segment.get("points") or [] - else: - raise ValueError(f"unsupported sweep path segment {kind!r}") - if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points): - raise ValueError("sweep path requires two-dimensional points") - - def point(value: list[float]) -> Vector3: - return vector_add( - plane.origin_mm, - vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))), - ) - - def tangent(value: Any) -> Vector3 | None: - if value is None: - return None - if not isinstance(value, list) or len(value) != 2: - raise ValueError("sweep path tangent must contain two coordinates") - return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))) - - return session.adapter.sweep_path( - [point(value) for value in local_points], - start_tangent=tangent(segment.get("start_tangent")), - end_tangent=tangent(segment.get("end_tangent")), - parameters=[float(value) for value in segment.get("parameters") or []] or None, - ) - - -def _execute_sweep_add(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None = None) -> FeatureResult: - profile = sketch or session.sketches.get(str(node.sketch_id)) - if profile is None: - raise ValueError("sweep has no resolved profile sketch") - faces = session.adapter.faces_for_sketch(profile) - if len(faces) != 1: - raise ValueError("sweep requires exactly one closed profile region") - solid, topology_delta = session.adapter.sweep_with_topology_delta( - faces[0], _sweep_path(node, session), - is_frenet=bool(node.params.get("is_frenet", False)), - ) - is_new_body = node.params.get("result_mode") == "new_body" - body = session.adapter.combine(session.body, solid) if is_new_body else session.adapter.fuse(session.body, solid) - # A union rebuilds topology, so the pipe-shell builder cannot prove the - # final aggregate's relations. The independent-body path retains its exact - # subshape identity and may expose evidence for the new member. - if session.body is not None and not is_new_body: - topology_delta = None - session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) - return session.result(node) - - -def _execute_reference_plane(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 基准面特征(reference_plane)执行入口:从参数解析平面并登记为拓扑上下文。 - - # 1. 从特征参数 plane 中解析出平面定义 PlaneSpec(原点到法向)。 - plane = PlaneSpec.from_mapping(node.params.get("plane") or {}) - # 2. 将该平面注册到拓扑上下文,供后续特征(如草图基准、参考轴)引用。 - session.topology.register_context(node.feature_id, plane) - # 3. 返回结果对象,并将该平面作为上下文一并携带。 - return session.result(node, context=plane) - - -def _execute_reference_axis(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 基准轴特征(reference_axis)执行入口:由参数直接定义轴,或由两个基准平面求交线得到轴。 - - # 1. 尝试直接取参数:若同时给出原点 origin_mm 与方向 direction,则直接构造轴。 - params = node.params.get("axis") or {} - if params.get("origin_mm") and params.get("direction"): - axis = AxisSpec.from_mapping(params) - else: - # 2. 否则从特征选择器中筛选出已解析的基准平面。 - planes = [session.resolve(selector) for selector in node.selectors if selector.get("kind") == "plane"] - resolved = [item.record.value for item in planes if item.status == "resolved" and isinstance(item.record.value, PlaneSpec)] - # 3. 校验:轴需要两个非平行的平面,不足两个则报错。 - if len(resolved) < 2: - raise ValueError("reference axis requires two uniquely resolved planes") - # 4. 用两平面法线叉积求交线方向;若方向长度接近 0 说明两平面平行,无法成轴。 - first, second = resolved[0], resolved[1] - n1, n2 = first.normal, second.normal - direction = vector_cross(n1, n2) - squared_length = vector_dot(direction, direction) - if squared_length <= 1e-18: - raise ValueError("reference planes are parallel and cannot define an axis") - # 5. 求交线上的一点:两平面到各自原点的垂距参与线性组合,得到交线上的最近点。 - d1 = vector_dot(n1, first.origin_mm) - d2 = vector_dot(n2, second.origin_mm) - point = vector_scale(vector_add(vector_scale(vector_cross(n2, direction), d1), vector_scale(vector_cross(direction, n1), d2)), 1 / squared_length) - # 6. 由该点与归一化的交线方向组合成基准轴 AxisSpec。 - axis = AxisSpec(origin_mm=point, direction=vector_unit(direction, field_name="reference axis")) - # 7. 注册为拓扑上下文,并返回结果对象(携带该轴)。 - session.topology.register_context(node.feature_id, axis) - return session.result(node, context=axis) - - -def _register_added_solid( - session: ExecutionSession, - node: FeaturePlanNode, - solid: Any, - *, - topology_delta: TopologyDelta | None = None, -) -> None: - """Register an additive primitive solid (box/cyl/sphere/thread/gear/rack/bend). - - When ``node.params['result_mode'] == "new_body"`` the primitive is kept as - an independent body member so that downstream ``boolean_bodies`` can - reference it without pulling in the accumulated fuse history. The current - body is replaced by a Compound that preserves both, matching the - ``extrude_add_blind`` ``new_body`` semantics. Any other value (including - missing) falls back to the legacy fuse-into-body behavior. - """ - if node.params.get("result_mode") == "new_body": - combined = session.adapter.combine(session.body, solid) - members = {**session.body_members, node.feature_id: solid} - session.register_body( - node.feature_id, combined, replay_node=node, body_members=members, - topology_delta=topology_delta, - ) - return - if session.body is None: - session.register_body(node.feature_id, solid, replay_node=node, topology_delta=topology_delta) - return - if topology_delta is None: - session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) - return - # Preserve primitive output roles only through the exact OCC fuse history. - # The temporary records are not selectable snapshots; they provide the - # source handles that let TopologyRegistry carry a role to the final body. - role_records = _direct_output_role_records(session, node, solid, topology_delta) - fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, solid) - session.register_body( - node.feature_id, fused, replay_node=node, topology_delta=fuse_delta, - topology_predecessors=role_records, - ) - - -def _direct_output_role_records( - session: ExecutionSession, - node: FeaturePlanNode, - solid: Any, - topology_delta: TopologyDelta, -) -> list[TopologyRecord]: - """Attach only builder-proven output roles to a transient primitive snapshot.""" - records = session.adapter.topology_records(solid, node.feature_id, f"transient:{node.feature_id}") - result: list[TopologyRecord] = [] - for record in records: - roles = { - relation.output_role - for relation in topology_delta.relations - if relation.output_role is not None - and relation.kind == record.kind - and any(session.topology._same_topology_value(record.value, value) for value in relation.result_values) - } - if roles: - result.append(TopologyRecord( - record_id=record.record_id, - kind=record.kind, - feature_id=record.feature_id, - body_id=record.body_id, - geometry=record.geometry, - value=record.value, - owner_feature_ids=(node.feature_id,), - output_roles=tuple(sorted(roles)), - )) - return result - - -def _execute_sphere(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 球体特征(sphere_add)执行入口:按球心与半径生成球体并并入当前主体。 - - # 1. 解析参数:半径 radius_mm 与球心 center_mm。 - radius = float(node.params.get("radius_mm") or 0.0) - center = node.params.get("center_mm") or [] - # 2. 校验:半径必须大于 0,球心必须是三维坐标。 - if radius <= 0 or len(center) != 3: - raise ValueError("sphere_add requires radius_mm and a three-dimensional center_mm") - # 3. 由适配器创建球体实体。 - solid = session.adapter.sphere(radius, (float(center[0]), float(center[1]), float(center[2]))) - # 4. 球体与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid) - return session.result(node) - - -def _execute_box(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 长方体特征(box_add)执行入口:以几何中心 center_mm 与三向尺寸生成原生长方体。 - # 1. 解析并校验尺寸与中心,非法输入抛出带具体原因的 ValueError。 - try: - length = float(node.params.get("length_mm") or 0.0) - width = float(node.params.get("width_mm") or 0.0) - height = float(node.params.get("height_mm") or 0.0) - center = node.params.get("center_mm") or [] - except (TypeError, ValueError) as error: - raise ValueError("box dimensions must be numeric") from error - if length <= 0 or width <= 0 or height <= 0 or len(center) != 3: - raise ValueError("box_add requires positive length_mm/width_mm/height_mm and a three-dimensional center_mm") - # 2. 生成世界轴对齐的 plane frame:plane 原点是长方体的最小角点(中心减去半 - # 尺寸),长/宽/高分别沿世界 x/y/z 生长(build123d Solid.make_box 语义)。 - corner = ( - float(center[0]) - length / 2, - float(center[1]) - width / 2, - float(center[2]) - height / 2, - ) - plane = PlaneSpec.from_mapping({"origin_mm": corner, "x_dir": [1, 0, 0], "normal": [0, 0, 1]}) - solid = session.adapter.box(length, width, height, plane) - # 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid) - return session.result(node) - - -def _execute_cylinder(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 圆柱特征(cylinder_add)执行入口:axis 的原点是底面圆心、方向为轴向; - # axis 缺省为世界 +Z 过原点(底面圆心落在 (0,0,0))。 - # 1. 解析并校验半径与高度,非法输入抛出带具体原因的 ValueError。 - try: - radius = float(node.params.get("radius_mm") or 0.0) - height = float(node.params.get("height_mm") or 0.0) - except (TypeError, ValueError) as error: - raise ValueError("cylinder dimensions must be numeric") from error - if radius <= 0 or height <= 0: - raise ValueError("cylinder_add requires positive radius_mm and height_mm") - raw_axis = node.params.get("axis") - if raw_axis is not None and not ( - isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None - ): - raise ValueError("cylinder_add axis must define origin_mm and direction") - axis = AxisSpec.from_mapping(raw_axis) if isinstance(raw_axis, dict) else None - # 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。 - solid, topology_delta = session.adapter.cylinder_with_topology_delta(radius, height, axis) - # 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid, topology_delta=topology_delta) - return session.result(node) - - -def _execute_thread(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 螺纹特征(thread_add)执行入口:按规格生成参数化螺纹段并并入当前主体。 - # 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。 - spec = ThreadSpec.from_feature(node.atomic_id, node.params) - # 2. 由适配器门面生成沿 spec.axis 放置的外螺纹实心段。 - solid = session.adapter.thread_solid(spec) - # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid) - return session.result(node) - - -def _thread_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - # 螺纹特征(thread_add/thread_cut)不需要草图平面,丢弃该参数后执行。 - # thread_cut 走布尔差分支:从已有主体切出内螺纹槽,而非并入外螺纹段。 - del sketch - if node.atomic_id == "thread_cut": - return _execute_thread_cut(node, session) - return _execute_thread(node, session) - - -def _execute_thread_cut(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 内螺纹(thread_cut)执行入口:ThreadSpec.from_feature 对 thread_cut 恒置 - # internal=True,生成牙顶外放 INTERNAL_CUT_OVERLAP_MM 的切削刀具,沿 - # spec.axis 放置后从当前主体布尔差出全深螺旋牙槽(宿主通常已预打光孔, - # 刀具 core 落在孔腔中,仅外放的牙槽层切入孔壁)。 - # 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。 - spec = ThreadSpec.from_feature(node.atomic_id, node.params) - # 2. 由适配器门面生成沿 spec.axis 放置的内螺纹切削刀具实心段。 - tool = session.adapter.thread_solid(spec) - # 3. 从当前主体布尔差(cut)后登记为新主体,并返回该特征的结果对象。 - session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node) - return session.result(node) - - -def _execute_bend(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 折弯特征(bend_add)执行入口:按规格生成等厚折弯板并并入当前主体。 - # 1. 解析并校验板厚/宽度/折痕链与放置平面,非法输入抛出带具体原因的 ValueError。 - spec = BendSpec.from_feature(node.params) - # 2. 由适配器门面生成沿 spec.frame 放置的折弯实心段。 - solid = session.adapter.bend_solid(spec) - # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid) - return session.result(node) - - -def _bend_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - # 折弯特征(bend_add)不需要草图平面,丢弃该参数后执行。 - del sketch - return _execute_bend(node, session) - - -def _execute_gear(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 齿轮特征(gear_add)执行入口:按规格生成渐开线齿轮并入当前主体。 - # 1. 解析并校验模数/齿数/齿宽/螺旋角与放置轴,非法输入抛出带具体原因的 ValueError。 - spec = GearSpec.from_feature(node.params) - # 2. 由适配器门面生成沿 spec.axis 放置的齿轮实体(直齿/斜齿/人字齿)。 - solid = session.adapter.gear_solid(spec) - # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid) - # 4. 小齿数根切风险:不阻断执行,附加 info 级诊断供完成报告如实披露。 - diagnostics: list[RuntimeDiagnostic] = [] - if spec.teeth_count < 17: - diagnostics.append(RuntimeDiagnostic( - code="undercut_risk", - message=( - f"Gear with {spec.teeth_count} teeth and a 20 degree pressure angle is undercut-prone; " - "standard involute geometry is generated without profile shift" - ), - feature_id=node.feature_id, - )) - return session.result(node, diagnostics=diagnostics) - - -def _gear_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - # 齿轮特征(gear_add)不需要草图平面,丢弃该参数后执行。 - del sketch - return _execute_gear(node, session) - - -def _execute_rack(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 齿条特征(rack_add)执行入口:按规格生成直线齿条并入当前主体。 - # 1. 解析并校验模数/齿数/厚度/压力角与放置轴,非法输入抛出带具体原因的 ValueError。 - spec = RackSpec.from_feature(node.params) - # 2. 由适配器门面生成沿 spec.axis 放置的齿条实体(齿沿轴方向伸出)。 - solid = session.adapter.rack_solid(spec) - # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid) - return session.result(node) - - -def _rack_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - # 齿条特征(rack_add)不需要草图平面,丢弃该参数后执行。 - del sketch - return _execute_rack(node, session) - - -def _host_plane(resolution: SelectorResolution) -> PlaneSpec: - if resolution.record is None: - raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved") - geometry = resolution.record.geometry - return PlaneSpec.from_mapping({ - "origin_mm": geometry["center_mm"], - "x_dir": [1, 0, 0] if abs(float(geometry["normal"][0])) < 0.9 else [0, 1, 0], - "normal": geometry["normal"], - }) - - -def _hole_starts( - spec: HoleSpec, - *, - host_plane: PlaneSpec, - positions_are_local: bool, -) -> list[Vector3]: - starts: list[Vector3] = [] - for point in spec.positions_mm: - if positions_are_local: - start = vector_add( - vector_add( - vector_add(host_plane.origin_mm, vector_scale(host_plane.x_dir, point[0])), - vector_scale(host_plane.y_dir, point[1]), - ), - vector_scale(host_plane.normal, point[2]), - ) - else: - start = point - starts.append(start) - return starts - - -def _execute_hole(node: FeaturePlanNode, session: ExecutionSession, *, wizard: bool = False) -> FeatureResult: - # 孔特征(hole)执行入口:在指定宿主面上按孔规格生成切除工具,并从主体上减去。 - - # 1. 校验:孔是切除操作,必须先有主体。 - if session.body is None: - raise ValueError("hole feature has no body") - # 2. 确定宿主面 host_face: - host_selector = node.params.get("host_face") - if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict): - # 若直接带 frame(平面定义),则以该平面为宿主,孔位按局部坐标解释。 - host = PlaneSpec.from_mapping(host_selector["frame"]) - positions_are_local = True - else: - # 否则从特征选择器中取 face,解析出宿主平面,孔位按世界坐标解释。 - selectors = list(node.selectors) - if isinstance(host_selector, dict): - selectors.append(host_selector) - selector = next((item for item in selectors if item.get("kind") == "face"), None) - if selector is None: - raise ValueError("hole requires host_face selector or frame") - host = _host_plane(session.resolve(selector)) - positions_are_local = False - # 3. 解析孔规格 HoleSpec(直径、深度、类型等,wizard 模式提供额外默认值)。 - spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard) - # 4. A host-face normal is an outward B-rep orientation, so its inverse - # always enters the material. Inferring direction from the global body - # centre fails for concave or multi-leg parts: for example, the top face - # of an L bracket can sit below the whole body's centre and the old rule - # drilled outward, producing a no-op feature reported as successful. - # The selected topology face is the local, authoritative orientation. - inward = vector_scale(host.normal, -1) - # 5. 生成孔切除工具:按孔规格、起始位置、内方向及“贯穿到主体底面”的深度构造工具实体。 - tool = session.adapter.hole_tool( - spec, - _hole_starts(spec, host_plane=host, positions_are_local=positions_are_local), - inward, - session.adapter.body_span(session.body, inward) + 2.0, - ) - # 6. 从主体上减去工具实体,登记新主体并返回结果。 - # thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔 - # 即光滑孔):孔按光滑圆柱孔执行,同时记录 info 级诊断便于批量报告 - # 追溯降级数量(issue #9,capabilities 已不再拒绝 thread)。 - diagnostics: list[RuntimeDiagnostic] = [] - if wizard and node.params.get("thread"): - diagnostics.append(RuntimeDiagnostic( - code="thread_decoration_ignored", - 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) - return session.result(node, diagnostics=diagnostics) - - -def _selector_edges(node: FeaturePlanNode, session: ExecutionSession, *, tangent_propagation: bool = False) -> list[Any]: - resolved: list[SelectorResolution] = [session.resolve(selector) for selector in node.selectors] - failed = next((item for item in resolved if item.status != "resolved"), None) - if failed: - raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") - - def is_body_boundary(edge: Any) -> bool: - # 圆柱、圆锥等周期面会带一条仅属于自身的参数 seam。该线不是实体 - # 边界;FeatureScript 以 FACE 选择倒角时不应将其当作额外的待倒角边, - # 否则连续的锥面会被错误切成两段。显式 EDGE selector 仍可表达真正的 - # 单边选择,所以这里只约束由 FACE 展开的候选边。 - face_count = sum( - 1 - for face in session.body.faces() - if any(candidate.is_same(edge) for candidate in face.edges()) - ) - return face_count >= 2 - - edges: list[Any] = [] - for item in resolved: - records = item.records or ((item.record,) if item.record is not None else ()) - for record in records: - if record.kind == "edge": - edges.append(record.value) - elif record.kind == "face": - edges.extend(edge for edge in record.value.edges() if is_body_boundary(edge)) - if not edges: - raise ValueError("selectors did not resolve any edges") - return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges - - -def _shell_target(node: FeaturePlanNode, session: ExecutionSession) -> tuple[Any, list[Any]]: - # shell 的 remove-face selector 必须全部属于同一实体。CADFS 允许一个 - # Compound 中保留多个独立 body,不能将整组 body 交给 OCC 后由内核猜测 - # 应抽壳的成员。 - resolved = [session.resolve(selector) for selector in node.selectors] - failed = next((item for item in resolved if item.status != "resolved"), None) - if failed: - raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") - records = [ - record - for item in resolved - for record in (item.records or ((item.record,) if item.record is not None else ())) - ] - if not records or any(record.kind != "face" for record in records): - raise ValueError("shell selectors must resolve to faces") - target_ids = {record.body_id for record in records} - if len(target_ids) != 1: - raise ValueError("shell faces must belong to one target body") - target_id = next(iter(target_ids)) - members = session.adapter.body_solids(session.body) - if len(members) == 1: - target = members[0] - else: - if target_id is None or session.body_id is None: - raise ValueError("shell target body is unresolved") - prefix = f"{session.body_id}:" - if not target_id.startswith(prefix): - raise ValueError("shell target body is outside the active body set") - try: - member_index = int(target_id[len(prefix):]) - except ValueError as error: - raise ValueError("shell target body has an invalid member id") from error - if member_index < 0 or member_index >= len(members): - raise ValueError("shell target body member is unavailable") - target = members[member_index] - target_feature_id = node.params.get("target_feature_id") - if target_feature_id is not None: - if not isinstance(target_feature_id, str) or not target_feature_id: - raise ValueError("shell target_feature_id is invalid") - declared = session.body_members.get(target_feature_id) - if declared is None: - raise ValueError("shell target body is no longer an independently selectable member") - declared_solids = session.adapter.body_solids(declared) - if len(declared_solids) != 1: - raise ValueError("shell target body must resolve to exactly one active solid") - if not declared_solids[0].is_same(target): - raise ValueError("shell target body does not match the resolved face member") - return target, [record.value for record in records] - - -def _replace_shell_target(session: ExecutionSession, target: Any, replacement: Any) -> Any: - # 仅替换抽壳目标实体;其他独立实体保持原样和原有相对顺序。 - members = session.adapter.body_solids(session.body) - if len(members) == 1: - return replacement - replaced = False - result = None - for member in members: - if member.is_same(target): - result = session.adapter.combine(result, replacement) - replaced = True - else: - result = session.adapter.combine(result, member) - if not replaced or result is None: - raise ValueError("shell target solid is no longer part of the active body") - return result - - -def _execute_shell(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 抽壳特征:移除 selector 所指面,并按 CADFS thickness 向实体内部偏置。 - if session.body is None: - raise ValueError("shell has no body") - thickness = float(node.params.get("thickness_mm") or 0) - if thickness <= 0: - raise ValueError("shell thickness_mm must be > 0") - target, faces = _shell_target(node, session) - result, topology_delta = session.adapter.shell_with_topology_delta( - target, faces, thickness, inward=bool(node.params.get("inward", True)), - ) - session.register_body( - node.feature_id, _replace_shell_target(session, target, result), replay_node=node, - topology_delta=topology_delta, - ) - return session.result(node) - - -def _execute_fillet(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。 - - # 1. 校验:圆角作用于已有主体,必须先有主体。 - if session.body is None: - raise ValueError("fillet has no body") - # 2. 解析圆角半径并校验必须大于 0。 - radius = float(node.params.get("radius_mm") or 0) - if radius <= 0: - raise ValueError("fillet radius_mm must be > 0") - # 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。 - body, topology_delta = session.adapter.fillet_with_topology_delta( - session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))), - ) - # 4. 登记新主体并返回结果。 - session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) - return session.result(node) - - -def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 倒角特征(chamfer)执行入口:对选中边按距离做倒角(可带第二距离形成不对称倒角)。 - - # 1. 校验:倒角作用于已有主体,必须先有主体。 - if session.body is None: - raise ValueError("chamfer has no body") - # 2. 解析主距离并校验必须大于 0。 - distance = float(node.params.get("distance_mm") or 0) - if distance <= 0: - raise ValueError("chamfer distance_mm must be > 0") - # 3. 解析第二距离与角度(importer 对 SolidWorks Distance-Angle 倒角产出 - # angle_rad,单位为弧度)。第二距离 = 主距离 * tan(angle);angle=45° 时 - # tan=1,退化为等距倒角(与历史行为一致,零回归)。 - # 注意:build123d 的 length/length2 侧向分配依赖面的枚举顺序,对非 45° - # 倒角仅保证量级正确,距离所在侧可能反转。 - distance_2 = node.params.get("distance_2_mm") - angle_rad = node.params.get("angle_rad") - if distance_2 is None and angle_rad is not None: - distance_2 = distance * math.tan(float(angle_rad)) - # 4. 解析目标边(支持相切传播),执行倒角。 - edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))) - diagnostics: list[RuntimeDiagnostic] = [] - topology_delta: TopologyDelta | None = None - try: - body, topology_delta = session.adapter.chamfer_with_topology_delta(session.body, distance, distance_2, edges) - except ValueError as error: - # 显式 surfaceEntities 可以在后续实体上留下曲面分区边界。若标准 - # OCC 倒角因环域宽度不足而拒绝,只允许在该 shell 给出同轴边界证据 - # 时按原始距离构造受限倒角;没有证明时仍保留原始内核失败。 - if distance_2 is not None or not session.surface_members: - raise - try: - body = session.adapter.surface_limited_chamfer( - session.body, distance, edges, list(session.surface_members.values()), - ) - except ValueError: - raise error - diagnostics.append(RuntimeDiagnostic( - "chamfer_surface_limited", - "Chamfer was limited by an explicit coaxial surface boundary", - feature_id=node.feature_id, - detail={"distance_mm": distance, "surface_count": len(session.surface_members)}, - )) - # 5. 登记新主体并返回结果。 - session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) - return session.result(node, diagnostics=diagnostics) - - -def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any]: - output = deepcopy(sketch) - components = offset - workplane = output.get("workplane") or {} - origin = workplane.get("origin_mm") or [0, 0, 0] - workplane["origin_mm"] = [float(origin[index]) + components[index] for index in range(3)] - output["workplane"] = workplane - for key in ("contour_edges_mm", "contour_regions_mm"): - def translate(value: Any) -> None: - if isinstance(value, dict): - for point_key in ("start_mm", "end_mm", "center_mm"): - if point_key in value: - value[point_key] = [float(value[point_key][index]) + components[index] for index in range(3)] - if "points_mm" in value: - value["points_mm"] = [ - [float(point[index]) + components[index] for index in range(3)] - for point in value["points_mm"] - ] - for child in value.values(): - translate(child) - elif isinstance(value, list): - for child in value: - translate(child) - translate(output.get(key)) - return output - - -def _transformed_loft_profiles( - node: FeaturePlanNode, - params: dict[str, Any], - instance_id: str, - session: ExecutionSession, - transform: Callable[[dict[str, Any]], dict[str, Any]], -) -> None: - """为 pattern replay 创建放样截面的变换副本。""" - if node.atomic_id != "loft_add": - return - profile_ids = params.get("profile_sketch_ids") or [] - transformed_ids: list[str] = [] - for index, sketch_id in enumerate(profile_ids): - source = session.sketches.get(str(sketch_id)) - if source is None: - raise ValueError(f"loft profile sketch {sketch_id!r} has no replay definition") - transformed_id = f"{instance_id}.profile.{index}" - # 不复用原 profile:pattern 中的每个截面都必须与 source feature - # 使用相同的平移、镜像或旋转,才能保持放样的真实空间位置。 - session.sketches[transformed_id] = transform(source) - transformed_ids.append(transformed_id) - params["profile_sketch_ids"] = transformed_ids - - -def _owner_plane_frame(session: ExecutionSession, selector: dict[str, Any]) -> dict[str, Any] | None: - """解析 selector 的 owner 特征(reference_plane)注册的显式平面 frame。 - - #6 pattern 引用重解析:pattern 重放 source(pattern_mirror)时,镜像面 - 是 selector,其 owner 是 reference_plane 特征;该特征执行时把显式 - PlaneSpec 登记为拓扑上下文,这里取出该 frame 供随实例变换使用。 - """ - owner = selector.get("owner_feature_id") - if not owner: - return None - for record in session.topology.records_for_feature(str(owner)): - if record.kind == "plane" and isinstance(record.value, PlaneSpec): - return record.value.as_dict() - return None - - -def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3, session: ExecutionSession) -> FeaturePlanNode: - params = deepcopy(node.params) - components = offset - if isinstance(params.get("plane"), dict) and params["plane"].get("origin_mm"): - params["plane"]["origin_mm"] = [float(params["plane"]["origin_mm"][index]) + components[index] for index in range(3)] - host = params.get("host_face") - host_frame = host.get("frame") if isinstance(host, dict) else None - positions_are_local = isinstance(host_frame, dict) and all( - host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal") - ) - if positions_are_local and host_frame.get("origin_mm"): - host_frame["origin_mm"] = [float(host_frame["origin_mm"][index]) + components[index] for index in range(3)] - if not positions_are_local: - for position in params.get("positions") or []: - if position.get("mm"): - position["mm"] = [float(position["mm"][index]) + components[index] for index in range(3)] - axis = params.get("axis") or {} - if axis.get("origin_mm"): - axis["origin_mm"] = [float(axis["origin_mm"][index]) + components[index] for index in range(3)] - center = params.get("center_mm") - if center: - # box_add/sphere_add 以世界坐标几何中心定位;平移重放必须随实例移动该中心, - # 否则阵列副本会静默重合在原位置。 - params["center_mm"] = [float(center[index]) + components[index] for index in range(3)] - _transformed_loft_profiles( - node, params, instance_id, session, - lambda sketch: _translated_sketch(sketch, offset), - ) - mirror_plane = params.get("mirror_plane") - if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror": - # #6 pattern 引用重解析:镜像面是 reference_plane 引用,随实例平移 - # 到新位置后内联为显式 frame;否则重放时 resolve 到原始面,镜像 - # 副本会错误地重合在源特征附近。同时源特征也必须平移后重放:镜像 - # 副本 = reflect(源@t, 面@t),只平移面不平移源会落在 2P+t-x 处 - # 而非正确位置 2P-x+t。 - frame = _owner_plane_frame(session, mirror_plane) - if frame is None: - raise ValueError("mirror plane reference cannot be transformed for pattern replay") - cloned_selector = deepcopy(mirror_plane) - cloned_selector["frame"] = { - "origin_mm": [frame["origin_mm"][index] + components[index] for index in range(3)], - "x_dir": list(frame["x_dir"]), - "normal": list(frame["normal"]), - } - params["mirror_plane"] = cloned_selector - transformed_ids: list[str] = [] - for source_id in node.params.get("source_feature_ids") or []: - source_node = session.replay_definitions.get(str(source_id)) - if source_node is None: - raise ValueError(f"mirror pattern source feature {source_id} has no replay definition") - temp_id = f"{instance_id}.src.{source_id}" - shifted = _translated_node(source_node, temp_id, offset, session) - if shifted.sketch_id: - source_sketch = session.sketches.get(str(source_node.sketch_id)) - if source_sketch is not None: - temp_sketch_id = f"{temp_id}.sk" - session.sketches[temp_sketch_id] = _translated_sketch(source_sketch, offset) - shifted = FeaturePlanNode( - shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on, - shifted.params, shifted.selectors, temp_sketch_id, - shifted.declared_status, shifted.source_feature, - ) - # 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。 - session.nodes[temp_id] = shifted - session.replay_definitions[temp_id] = shifted - transformed_ids.append(temp_id) - params["source_feature_ids"] = transformed_ids - return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature) - - -def _execute_linear_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult: - # 线性阵列特征(pattern)执行入口:沿两个方向按数量与间距重放源特征形成阵列。 - - # 1. 取源特征的 replay 定义(源特征按 feature_id 在会话中登记,供本阵列重放)。 - params = node.params - sources = session.replay_sources(params.get("source_feature_ids") or []) - if not sources: - raise ValueError("pattern source features have no replay definitions") - # 2. 解析两个方向的实例数量。 - count_1 = int(params.get("pattern_count_1") or 1) - count_2 = int(params.get("pattern_count_2") or 1) - # 3. 解析两个方向的步长向量(方向单位向量 × 间距),作为阵列位移基准。 - direction_1 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_1") or [1, 0, 0])), field_name="pattern direction_1"), float(params.get("spacing_1_mm") or 0)) - direction_2 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_2") or [0, 1, 0])), field_name="pattern direction_2"), float(params.get("spacing_2_mm") or 0)) - # 4. 双重循环生成每个阵列实例(跳过原点 0,0 处,那里是源特征本身)。 - for first in range(count_1): - for second in range(count_2): - if first == 0 and second == 0: - continue - # 计算当前实例相对源特征的偏移向量。 - offset = vector_add(vector_scale(direction_1, first), vector_scale(direction_2, second)) - for source in sources: - # 逐个源特征克隆并按偏移平移后重放执行(草图也同步平移)。 - dependency = pattern_transform_blocker(source) - if dependency: - raise ValueError(f"pattern source uses an unsupported {dependency}") - cloned = _translated_node(source, f"{node.feature_id}.p{first}_{second}.{source.feature_id}", offset, session) - sketch = session.sketches.get(str(source.sketch_id)) - execute(cloned, session, _translated_sketch(sketch, offset) if sketch else None) - # 5. 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放, - # 而非复制当前主体做近似。 - # A later pattern may select this pattern feature. The definition is - # replayed recursively, never approximated by copying the current body. - session.replay_definitions[node.feature_id] = node - return session.result(node) - - -def _reflect_point(point: list[float] | tuple[float, float, float], plane: PlaneSpec, *, vector: bool = False) -> list[float]: - value = tuple(float(component) for component in point) - offset = value if vector else vector_subtract(value, plane.origin_mm) - mirrored = vector_subtract(value, vector_scale(plane.normal, 2 * vector_dot(offset, plane.normal))) - return list(mirrored) - - -def _mirrored_sketch(sketch: dict[str, Any], plane: PlaneSpec) -> dict[str, Any]: - output = deepcopy(sketch) - workplane = output.get("workplane") or {} - if workplane.get("origin_mm"): - workplane["origin_mm"] = _reflect_point(workplane["origin_mm"], plane) - for key in ("x_dir", "y_dir", "normal"): - if workplane.get(key): - workplane[key] = _reflect_point(workplane[key], plane, vector=True) - output["workplane"] = workplane - - # A reflection reverses handedness. ``PlaneSpec`` reconstructs its local - # y direction as normal x x, so keeping the reflected normal means that - # local y is the inverse of the reflected source y. Profiles represented - # as local circles (rather than already-transformed contour edges) must - # therefore invert v to remain at their actual reflected world position. - def mirror_local_coordinates(value: Any) -> None: - if isinstance(value, dict): - for point_key in ("center", "start", "end"): - point = value.get(point_key) - if isinstance(point, list) and len(point) == 2: - value[point_key] = [float(point[0]), -float(point[1])] - if isinstance(value.get("points"), list): - value["points"] = [ - [float(point[0]), -float(point[1])] - for point in value["points"] - if isinstance(point, list) and len(point) == 2 - ] - for child in value.values(): - mirror_local_coordinates(child) - elif isinstance(value, list): - for child in value: - mirror_local_coordinates(child) - - mirror_local_coordinates(output.get("entities")) - # This is not consumed after sketch resolution, but retaining the same - # local semantics makes an overridden sketch safe to inspect or replay. - mirror_local_coordinates(output.get("profile")) - - def mirror(value: Any) -> None: - if isinstance(value, dict): - for point_key in ("start_mm", "end_mm", "center_mm"): - if point_key in value: - value[point_key] = _reflect_point(value[point_key], plane) - if "points_mm" in value: - value["points_mm"] = [_reflect_point(point, plane) for point in value["points_mm"]] - if value.get("normal"): - value["normal"] = _reflect_point(value["normal"], plane, vector=True) - for child in value.values(): - mirror(child) - elif isinstance(value, list): - for child in value: - mirror(child) - mirror(output.get("contour_edges_mm")) - mirror(output.get("contour_regions_mm")) - return output - - -def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec, session: ExecutionSession) -> FeaturePlanNode: - params = deepcopy(node.params) - if isinstance(params.get("plane"), dict): - for key in ("origin_mm", "x_dir", "y_dir", "normal"): - if params["plane"].get(key): - params["plane"][key] = _reflect_point(params["plane"][key], plane, vector=key != "origin_mm") - host = params.get("host_face") - host_frame = host.get("frame") if isinstance(host, dict) else None - positions_are_local = isinstance(host_frame, dict) and all( - host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal") - ) - if positions_are_local: - for key in ("origin_mm", "x_dir", "normal"): - if host_frame.get(key): - host_frame[key] = _reflect_point(host_frame[key], plane, vector=key != "origin_mm") - # #1 y_dir 保留:PlaneSpec 现在会尊重显式正交 y_dir。镜像后 frame 的 - # canonical y 轴必须是 n×x(x 已反射 → y 反转),否则反射后的 frame - # 会保留反射前的 y_dir,与下方"局部坐标 v 取反"双重翻转。 - x_reflected = host_frame.get("x_dir") - n_reflected = host_frame.get("normal") - if x_reflected is not None and n_reflected is not None: - host_frame["y_dir"] = [ - n_reflected[1] * x_reflected[2] - n_reflected[2] * x_reflected[1], - n_reflected[2] * x_reflected[0] - n_reflected[0] * x_reflected[2], - n_reflected[0] * x_reflected[1] - n_reflected[1] * x_reflected[0], - ] - # See _mirrored_sketch: the canonical reflected plane reverses local - # y, so local hole coordinates must do the same. - for position in params.get("positions") or []: - point = position.get("mm") - if isinstance(point, list) and len(point) == 3: - position["mm"] = [float(point[0]), -float(point[1]), float(point[2])] - else: - for position in params.get("positions") or []: - if position.get("mm"): - position["mm"] = _reflect_point(position["mm"], plane) - axis = params.get("axis") or {} - if axis.get("origin_mm"): - axis["origin_mm"] = _reflect_point(axis["origin_mm"], plane) - if axis.get("direction"): - axis["direction"] = _reflect_point(axis["direction"], plane, vector=True) - center = params.get("center_mm") - if isinstance(center, list) and len(center) == 3: - # box_add/sphere_add 以世界坐标几何中心定位:反射该中心即可。box_add 固定 - # 世界轴对齐,跨坐标平面镜像后仍保持朝向(斜镜像面在 _execute_mirror_pattern - # 中已被显式拒绝)。 - params["center_mm"] = _reflect_point(center, plane) - _transformed_loft_profiles( - node, params, instance_id, session, - lambda sketch: _mirrored_sketch(sketch, plane), - ) - mirror_plane = params.get("mirror_plane") - if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror": - # #6 pattern 引用重解析:镜像重放 mirror source 时,其镜像面引用 - # 随本实例的镜像面一起反射(内联为显式 frame),否则重放 resolve - # 到原始面,嵌套镜像会退化成与源镜像重合的错误几何。源特征同样 - # 反射后重放:镜像副本 = reflect(源@P_B, reflect(面,P_B))。 - frame = _owner_plane_frame(session, mirror_plane) - if frame is None: - raise ValueError("mirror plane reference cannot be transformed for pattern replay") - cloned_selector = deepcopy(mirror_plane) - cloned_selector["frame"] = { - "origin_mm": _reflect_point(frame["origin_mm"], plane), - "x_dir": _reflect_point(frame["x_dir"], plane, vector=True), - "normal": _reflect_point(frame["normal"], plane, vector=True), - } - params["mirror_plane"] = cloned_selector - transformed_ids: list[str] = [] - for source_id in node.params.get("source_feature_ids") or []: - source_node = session.replay_definitions.get(str(source_id)) - if source_node is None: - raise ValueError(f"mirror pattern source feature {source_id} has no replay definition") - temp_id = f"{instance_id}.src.{source_id}" - shifted = _mirrored_node(source_node, temp_id, plane, session) - if shifted.sketch_id: - source_sketch = session.sketches.get(str(source_node.sketch_id)) - if source_sketch is not None: - temp_sketch_id = f"{temp_id}.sk" - session.sketches[temp_sketch_id] = _mirrored_sketch(source_sketch, plane) - shifted = FeaturePlanNode( - shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on, - shifted.params, shifted.selectors, temp_sketch_id, - shifted.declared_status, shifted.source_feature, - ) - # 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。 - session.nodes[temp_id] = shifted - session.replay_definitions[temp_id] = shifted - transformed_ids.append(temp_id) - params["source_feature_ids"] = transformed_ids - return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature) - - -def _normal_is_coordinate_axis(normal: Any) -> bool: - # 判断单位法向是否平行于任一世界坐标轴:跨这样的平面镜像会保持轴对齐朝向。 - return ( - isinstance(normal, (list, tuple)) - and len(normal) == 3 - and any(abs(float(normal[index])) > 1 - 1e-9 for index in range(3)) - ) - - -def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - mirror = node.params.get("mirror_plane") or {} - resolution = session.resolve(mirror) - if resolution.status != "resolved" or not isinstance(resolution.record.value, PlaneSpec): - raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "mirror plane was not resolved") - source_ids = [str(value) for value in node.params.get("source_feature_ids") or ()] - if ( - source_ids - and all(source_id in session.body_members for source_id in source_ids) - and all( - (source := session.nodes.get(source_id)) is not None - and source.params.get("result_mode") == "new_body" - for source_id in source_ids - ) - ): - # Only a direct NEW body has a standalone source identity after a - # mirror. A hole, dress-up, or ordinary additive source is merely an - # aggregate successor and must use the feature-replay path below. - # Keeping this condition identical to capability preflight prevents a - # downstream COPY body query from selecting an arbitrary aggregate. - members = dict(session.body_members) - body = session.body - for source_id in source_ids: - mirrored = session.adapter.mirror(session.body_members[source_id], resolution.record.value) - members[pattern_instance_member_id(node.feature_id, source_id, 1)] = mirrored - body = session.adapter.fuse(body, mirrored) - if body is None: - raise ValueError("mirror pattern produced no body") - session.register_body(node.feature_id, body, replay_node=node, body_members=members) - return session.result(node) - if node.params.get("mirror_current_body"): - # CADFS SWEPT_BODY 表示被后续 feature 持续修改的同一实体。这里复制 - # 当前 B-rep 再镜像并合并,不能重放其初始 additive feature,否则会 - # 丢失后续 cut/fillet 并生成独立错误实体。 - if session.body is None: - raise ValueError("mirror current body has no active body") - mirrored = session.adapter.mirror(session.body, resolution.record.value) - session.register_body(node.feature_id, session.adapter.fuse(session.body, mirrored), replay_node=node) - return session.result(node) - sources = session.replay_sources(node.params.get("source_feature_ids") or []) - if not sources: - raise ValueError("mirror pattern source features have no replay definitions") - for source in sources: - dependency = pattern_transform_blocker(source) - if dependency: - raise ValueError(f"mirror pattern source uses an unsupported {dependency}") - if source.atomic_id == "box_add" and not _normal_is_coordinate_axis(resolution.record.value.normal): - # box_add 是固定世界轴对齐的原生图元:跨非坐标平面镜像会产生倾斜朝向, - # 当前参数语义无法表达,静默重放会得到错误几何 → 明确拒绝。跨坐标平面 - # (法向平行于任一坐标轴)的镜像仍然精确。 - raise ValueError("box_add mirror is exact only across coordinate-aligned mirror planes") - cloned = _mirrored_node(source, f"{node.feature_id}.m.{source.feature_id}", resolution.record.value, session) - sketch = session.sketches.get(str(source.sketch_id)) - _execute_node(cloned, session, _mirrored_sketch(sketch, resolution.record.value) if sketch else None) - session.replay_definitions[node.feature_id] = node - return session.result(node) - - -def _coordinate_axis_direction(direction: Any) -> bool: - # 判断方向是否平行于任一世界坐标轴(circular 的 box 限制用)。 - # 不依赖输入已是单位向量:非零向量至多一个分量非零即为坐标轴方向 - # (_box_circular_is_exact 对任意长度/含小残差的 direction 都稳健)。 - if not isinstance(direction, (list, tuple)) or len(direction) != 3: - return False - return sum(1 for component in direction if abs(float(component)) > 1e-9) == 1 - - -def _rotated_vector(value: Vector3, axis: AxisSpec, angle_rad: float) -> Vector3: - # Rodrigues 旋转公式:绕单位轴 axis.direction 旋转向量(无平移项)。 - cosine = math.cos(angle_rad) - sine = math.sin(angle_rad) - axis_direction = axis.direction - cross = vector_cross(axis_direction, value) - dot = vector_dot(axis_direction, value) - return tuple( # type: ignore[return-value] - value[index] * cosine + cross[index] * sine + axis_direction[index] * dot * (1.0 - cosine) - for index in range(3) - ) - - -def _rotated_point(point: Any, axis: AxisSpec, angle_rad: float) -> list[float]: - # 绕轴旋转三维点:先平移到轴原点、旋转向量、再平移回。 - value = tuple(float(component) for component in point) - relative = vector_subtract(value, axis.origin_mm) - rotated = _rotated_vector(relative, axis, angle_rad) - return [axis.origin_mm[index] + rotated[index] for index in range(3)] - - -def _rotated_sketch(sketch: dict[str, Any], axis: AxisSpec, angle_rad: float) -> dict[str, Any]: - # 环形阵列实例的草图:工作平面 frame(原点为点、x/y/normal 为向量)绕轴旋转; - # 2D 局部实体坐标不动(frame 旋转后由草图求解器映射到新世界位置)。与 - # _translated_sketch 对"世界坐标轮廓点"的处理对称,这里把 start/end/center - # 世界坐标点和圆弧法向绕轴旋转。 - output = deepcopy(sketch) - workplane = output.get("workplane") or {} - if workplane.get("origin_mm"): - workplane["origin_mm"] = _rotated_point(workplane["origin_mm"], axis, angle_rad) - for key in ("x_dir", "y_dir", "normal"): - if workplane.get(key): - workplane[key] = list(_rotated_vector(tuple(float(v) for v in workplane[key]), axis, angle_rad)) - output["workplane"] = workplane - - def rotate(value: Any) -> None: - if isinstance(value, dict): - for point_key in ("start_mm", "end_mm", "center_mm"): - if point_key in value: - value[point_key] = _rotated_point(value[point_key], axis, angle_rad) - if "points_mm" in value: - value["points_mm"] = [_rotated_point(point, axis, angle_rad) for point in value["points_mm"]] - if "normal" in value: - value["normal"] = list(_rotated_vector(tuple(float(v) for v in value["normal"]), axis, angle_rad)) - for child in value.values(): - rotate(child) - elif isinstance(value, list): - for child in value: - rotate(child) - - for key in ("contour_edges_mm", "contour_regions_mm"): - rotate(output.get(key)) - return output - - -def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle_rad: float, session: ExecutionSession) -> FeaturePlanNode: - # 环形阵列实例节点:把源特征的全部绝对坐标参数绕 axis 旋转(参数键布局与 - # _translated_node/_mirrored_node 一致)。workplane/宿主 frame 的轴方向旋转, - # 世界坐标点旋转;局部 positions(随宿主 frame)不动。特征自带 axis(圆柱轴/ - # 旋转轴/嵌套 circular 轴)与几何中心 center_mm 随实例旋转。嵌套 pattern - # source(pattern_mirror/pattern_circular)带绝对引用:镜像面 frame / 内层 - # 源需连同本实例一起旋转,否则重放会退化成与源重合的错误几何。 - params = deepcopy(node.params) - plane = params.get("plane") - if isinstance(plane, dict): - for key in ("origin_mm", "x_dir", "y_dir", "normal"): - if plane.get(key): - if key == "origin_mm": - plane[key] = _rotated_point(plane[key], axis, angle_rad) - else: - plane[key] = list(_rotated_vector(tuple(float(v) for v in plane[key]), axis, angle_rad)) - path = params.get("path") - path_plane = path.get("workplane") if isinstance(path, dict) else None - if isinstance(path_plane, dict): - if path_plane.get("origin_mm"): - path_plane["origin_mm"] = _rotated_point(path_plane["origin_mm"], axis, angle_rad) - for key in ("x_dir", "y_dir", "normal"): - if path_plane.get(key): - path_plane[key] = list(_rotated_vector(tuple(float(v) for v in path_plane[key]), axis, angle_rad)) - host = params.get("host_face") - host_frame = host.get("frame") if isinstance(host, dict) else None - positions_are_local = isinstance(host_frame, dict) and all( - host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal") - ) - if positions_are_local: - if host_frame.get("origin_mm"): - host_frame["origin_mm"] = _rotated_point(host_frame["origin_mm"], axis, angle_rad) - for key in ("x_dir", "normal"): - if host_frame.get(key): - host_frame[key] = list(_rotated_vector(tuple(float(v) for v in host_frame[key]), axis, angle_rad)) - else: - for position in params.get("positions") or []: - if position.get("mm"): - position["mm"] = _rotated_point(position["mm"], axis, angle_rad) - feature_axis = params.get("axis") - if isinstance(feature_axis, dict): - if feature_axis.get("origin_mm"): - feature_axis["origin_mm"] = _rotated_point(feature_axis["origin_mm"], axis, angle_rad) - if feature_axis.get("direction"): - feature_axis["direction"] = list(_rotated_vector(tuple(float(v) for v in feature_axis["direction"]), axis, angle_rad)) - center = params.get("center_mm") - if isinstance(center, list) and len(center) == 3: - params["center_mm"] = _rotated_point(center, axis, angle_rad) - _transformed_loft_profiles( - node, params, instance_id, session, - lambda sketch: _rotated_sketch(sketch, axis, angle_rad), - ) - if node.atomic_id in {"pattern_mirror", "pattern_circular"}: - # pattern 引用旋转重解析:镜像面 / 内层源随本实例一起旋转,否则嵌套 - # pattern 作为 circular source 时重放会退化成错误几何(见 _translated_node)。 - if node.atomic_id == "pattern_mirror": - mirror_plane = params.get("mirror_plane") - if not isinstance(mirror_plane, dict): - raise ValueError("mirror pattern replayed by circular pattern has no mirror plane reference") - frame = _owner_plane_frame(session, mirror_plane) - if frame is None: - raise ValueError("mirror plane reference cannot be transformed for circular pattern replay") - cloned_selector = deepcopy(mirror_plane) - cloned_selector["frame"] = { - "origin_mm": _rotated_point(frame["origin_mm"], axis, angle_rad), - "x_dir": list(_rotated_vector(tuple(frame["x_dir"]), axis, angle_rad)), - "normal": list(_rotated_vector(tuple(frame["normal"]), axis, angle_rad)), - } - params["mirror_plane"] = cloned_selector - transformed_ids: list[str] = [] - for source_id in node.params.get("source_feature_ids") or []: - source_node = session.replay_definitions.get(str(source_id)) - if source_node is None: - raise ValueError(f"pattern source feature {source_id} has no replay definition") - temp_id = f"{instance_id}.src.{source_id}" - shifted = _rotated_node(source_node, temp_id, axis, angle_rad, session) - if shifted.sketch_id: - source_sketch = session.sketches.get(str(source_node.sketch_id)) - if source_sketch is not None: - temp_sketch_id = f"{temp_id}.sk" - session.sketches[temp_sketch_id] = _rotated_sketch(source_sketch, axis, angle_rad) - shifted = FeaturePlanNode( - shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on, - shifted.params, shifted.selectors, temp_sketch_id, - shifted.declared_status, shifted.source_feature, - ) - # 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。 - session.nodes[temp_id] = shifted - session.replay_definitions[temp_id] = shifted - transformed_ids.append(temp_id) - params["source_feature_ids"] = transformed_ids - return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature) - - -def _pattern_operation_node(node: FeaturePlanNode, operation_mode: str) -> FeaturePlanNode: - # REMOVE pattern 的实例必须沿用 source 的 profile/extent,但以 cut 而不是 - # add 写入当前主体。lowering 已将初始 source 同步改写,运行时保留此处以 - # 支持完整的 CDSL replay contract。 - if operation_mode != "remove": return node - atomic_id = { - "extrude_add_blind": "extrude_cut_blind", - "extrude_add_two_sided": "extrude_cut_two_sided", - "revolve_add": "revolve_cut", - }.get(node.atomic_id, node.atomic_id) - if atomic_id == node.atomic_id and "cut" not in atomic_id: - raise ValueError("REMOVE pattern source is not a replayable cutting feature") - params = {key: value for key, value in node.params.items() if key != "result_mode"} - return FeaturePlanNode( - node.feature_id, atomic_id, node.name, node.depends_on, params, - node.selectors, node.sketch_id, node.declared_status, node.source_feature, - ) - - -def _circular_source_is_axisymmetric(node: FeaturePlanNode, session: ExecutionSession, axis: AxisSpec) -> bool: - """Whether rotating a direct circular extrusion creates no new geometry.""" - if node.atomic_id not in {"extrude_add_blind", "extrude_add_two_sided"}: - return False - sketch = session.sketches.get(str(node.sketch_id)) - if sketch is None: - return False - profile = sketch.get("profile") or {} - circle = profile if profile.get("type") == "circle" else None - if circle is None: - contours = profile.get("contours") or [] - segments = (contours[0] or {}).get("segments") if len(contours) == 1 else [] - circle = segments[0] if isinstance(segments, list) and len(segments) == 1 and segments[0].get("type") == "circle" else None - center = (circle or {}).get("center") - if not isinstance(center, list) or len(center) != 2: - return False - try: - plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) - except (TypeError, ValueError): - return False - if abs(vector_dot(plane.normal, axis.direction)) < 1 - 1e-7: - return False - world_center = vector_add( - plane.origin_mm, - vector_add(vector_scale(plane.x_dir, float(center[0])), vector_scale(plane.y_dir, float(center[1]))), - ) - offset = vector_subtract(world_center, axis.origin_mm) - radial = vector_subtract(offset, vector_scale(axis.direction, vector_dot(offset, axis.direction))) - return math.sqrt(vector_dot(radial, radial)) <= 1e-6 - - -def _advance_copy_topology_records( - records: list[TopologyRecord], topology_delta: TopologyDelta | None, -) -> list[TopologyRecord]: - """Carry COPY provenance through one exact adapter-history operation. - - Pattern copies are separate CDSL results even when their solids fuse into - a single final body. The temporary records here are never selector - candidates themselves. They only retain instance ownership while opaque - OCC history proves a unique subshape continuation to the final snapshot. - """ - if topology_delta is None: - return [] - advanced: list[TopologyRecord] = [] - for record in records: - values: list[Any] = [] - for relation in topology_delta.relations: - if ( - relation.kind != record.kind - or relation.event not in {"preserved", "modified"} - or not TopologyRegistry._same_topology_value(record.value, relation.source_value) - ): - continue - for value in relation.result_values: - if not any(TopologyRegistry._same_topology_value(value, known) for known in values): - values.append(value) - # A split/merge has no unique COPY owner in the present selector - # contract. Keep the executable model, but do not make a claim that a - # later COPY selector can bind one arbitrary descendant. - if len(values) != 1: - continue - advanced.append(TopologyRecord( - record_id=record.record_id, - kind=record.kind, - feature_id=record.feature_id, - body_id=record.body_id, - geometry=dict(record.geometry), - value=values[0], - owner_feature_ids=record.owners, - output_roles=record.output_roles, - output_role_sources=record.output_role_sources, - )) - return advanced - - -def _copy_snapshot_topology_delta(records: list[TopologyRecord]) -> TopologyDelta | None: - """Bridge traced final COPY handles into the one registered body snapshot.""" - if not records: - return None - return TopologyDelta( - operation="pattern_circular_copy_snapshot", - relations=tuple( - # ``record.value`` has already passed through every transform/fuse - # builder in this pattern and is an actual final-B-rep handle. The - # identity relation merely connects that evidence to the fresh - # adapter snapshot; it is not a geometric rebinding shortcut. - TopologyDeltaRelation("preserved", record.kind, record.value, (record.value,)) - for record in records - ), - ) - - -def _has_usable_pattern_body(session: ExecutionSession, body: Any | None) -> bool: - """Reject a formally valid but empty OCC boolean result before publishing it.""" - if body is None or not session.adapter.body_solids(body): - return False - try: - return abs(float(body.volume)) > 1e-12 - except (AttributeError, TypeError, ValueError): - return False - - -def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult: - # 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征 - # 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。 - params = node.params - raw_axis = params.get("axis") - if not (isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None): - raise ValueError("circular pattern requires an explicit axis with origin_mm and direction") - axis = AxisSpec.from_mapping(raw_axis) - count = int(params.get("pattern_count") or 1) - if count < 1: - raise ValueError("circular pattern pattern_count must be >= 1") - sweep_angle_deg = float(params.get("sweep_angle_deg") or 360.0) - operation_mode = str(params.get("operation_mode") or "add") - if operation_mode not in {"add", "remove"}: - raise ValueError("circular pattern operation_mode must be add or remove") - excluded = {int(value) for value in params.get("excluded_instance_indices") or []} - if any(instance < 1 or instance >= count for instance in excluded): - raise ValueError("circular pattern excluded instance is outside the generated range") - sources = session.replay_sources(params.get("source_feature_ids") or []) - if not sources: - raise ValueError("circular pattern source features have no replay definitions") - source_ids = [source.feature_id for source in sources] - pre_pattern_members = dict(session.body_members) - if operation_mode == "add" and all(source_id in session.body_members for source_id in source_ids): - # A pattern over explicit NEW/kept body members has a stronger contract - # than replay: each copy is an independently addressable rigid image of - # the named source member. Keep the instance keys in the body graph so - # a later CADFS COPY(BODY) transform/delete can name exactly one copy. - members = dict(session.body_members) - body = session.body - traced_copy_records: list[TopologyRecord] = [] - for instance in range(1, count): - if instance in excluded: - continue - angle_deg = sweep_angle_deg * instance / count - transform = { - "type": "rotation", - "axis": {"origin_mm": list(axis.origin_mm), "direction": list(axis.direction)}, - "angle_deg": angle_deg, - } - for source_id in source_ids: - member_id = pattern_instance_member_id(node.feature_id, source_id, instance) - owner_id = f"{node.feature_id}.c{instance}.{source_id}" - source_body = session.body_members[source_id] - copy, transform_delta = session.adapter.transform_with_topology_delta(source_body, transform) - source_records = session.adapter.topology_records( - source_body, owner_id, f"body:{node.feature_id}:copy:{instance}:{source_id}:source", - ) - copy_records = _advance_copy_topology_records(source_records, transform_delta) - members[member_id] = copy - body, fuse_delta = session.adapter.fuse_with_topology_delta(body, copy) - traced_copy_records = _advance_copy_topology_records( - [*traced_copy_records, *copy_records], fuse_delta, - ) - if _has_usable_pattern_body(session, body): - session.register_body( - node.feature_id, body, replay_node=node, body_members=members, - topology_delta=_copy_snapshot_topology_delta(traced_copy_records), - topology_predecessors=traced_copy_records, - ) - return session.result(node) - # An OCC boolean may report IsDone/valid for an empty result when a - # copied fused body contains coincident internal topology. The normal - # pattern contract can replay the source feature contribution instead; - # it is the only sound fallback because it keeps source operation, - # sketch frame, and body lifecycle semantics intact. - for instance in range(1, count): - if instance in excluded: - continue - # 实例 i 位于包角 sweep_angle_deg 的 i/count 处(i=0 即源特征本身)。 - angle_deg = sweep_angle_deg * instance / count - angle_rad = math.radians(angle_deg) - for source in sources: - # 与阵列轴同心、法向平行的圆形实体拉伸在任意环形实例中均与 - # 原实体完全重合。重复执行它会把同一 B-rep 再次交给 OCC fuse, - # 后续非轴对称 source 可能因此丢失已生成的实体分支。 - if _circular_source_is_axisymmetric(source, session, axis): - continue - dependency = pattern_transform_blocker(source) - if dependency: - raise ValueError(f"circular pattern source uses an unsupported {dependency}") - if source.atomic_id == "box_add" and not _box_circular_is_exact(axis, angle_rad): - raise ValueError( - "box_add circular pattern is exact only for coordinate-axis rotation " - "by multiples of 180 degrees" - ) - cloned = _rotated_node(source, f"{node.feature_id}.c{instance}.{source.feature_id}", axis, angle_rad, session) - cloned = _pattern_operation_node(cloned, operation_mode) - # CADFS pattern instances are copies of the source result, not - # independent `NEW` operations. Replay them through normal add - # semantics: intersecting or face-sharing instances fuse, while - # spatially separate copies remain separate solids in the result. - if cloned.params.get("result_mode") == "new_body": - cloned = FeaturePlanNode( - cloned.feature_id, cloned.atomic_id, cloned.name, cloned.depends_on, - {key: value for key, value in cloned.params.items() if key != "result_mode"}, - cloned.selectors, cloned.sketch_id, cloned.declared_status, cloned.source_feature, - ) - sketch = session.sketches.get(str(source.sketch_id)) - execute(cloned, session, _rotated_sketch(sketch, axis, angle_rad) if sketch else None) - # 环形阵列本身是完整 B-rep 结果的 producer。每个 replay 子特征都会更新 - # active body;循环结束后必须用 pattern feature 重新登记最终快照,否则后续 - # selector binding 会只保留最后一个实例的 body id,漏掉其它 COPY 实例。 - if session.body is None: - raise ValueError("circular pattern produced no body") - # Replaying a fused sole-body source may be more robust than copying its - # full aggregate B-rep (for example, when a rotationally invariant base - # would otherwise be unioned with itself). If that replay still has one - # physical body, the direct source remains a proven alias of the current - # member. Preserve it for a following parts-scoped operation such as - # shell; do not extend this alias across multi-body patterns or multiple - # source members. - members = {node.feature_id: session.body} - if ( - len(source_ids) == 1 - and len(pre_pattern_members) == 1 - and source_ids[0] in pre_pattern_members - and _has_usable_pattern_body(session, session.body) - and len(session.adapter.body_solids(session.body)) == 1 - ): - members[source_ids[0]] = session.body - session.register_body(node.feature_id, session.body, replay_node=node, body_members=members) - return session.result(node) - - -def _box_circular_is_exact(axis: AxisSpec, angle_rad: float) -> bool: - # box_add 是固定世界轴对齐的原生图元:绕轴旋转任意角度会使其棱偏离坐标轴, - # 当前参数语义无法表达 → 仅坐标轴旋转且每份转角为 180° 的整数倍时精确 - # (180° 翻转把轴对齐 box 映射回轴对齐 box)。与 _execute_mirror_pattern 的 - # box 坐标平面限制同思路:宁可显式拒绝,也不静默产出错误几何。 - if not _coordinate_axis_direction(axis.direction): - return False - half_turns = abs(math.degrees(angle_rad)) / 180.0 - return abs(half_turns - round(half_turns)) < 1e-9 - - -def _circular_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_circular_pattern(node, session, _execute_node) - - -def _execute_node(node: FeaturePlanNode, session: ExecutionSession, sketch_override: dict[str, Any] | None = None) -> FeatureResult: - executor = EXECUTORS.get(node.atomic_id) - if executor is None: - raise ValueError(f"No executor registered for {node.atomic_id!r}") - previous_feature_id = session.active_feature_id - session.active_feature_id = node.feature_id - try: - return executor(node, session, sketch_override) - finally: - session.active_feature_id = previous_feature_id - - -ExecutorFunction = Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult] - - -def _primary_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - return _shape_from_primary(node, session, sketch=sketch) - - -def _revolve_surface_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_revolve_surface(node, session) - - -def _extrude_surface_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_extrude_surface(node, session) - - -def _loft_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_loft_add(node, session) - - -def _loft_cap_face_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_loft_add_with_cap_face(node, session) - - -def _sweep_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - return _execute_sweep_add(node, session, sketch) - - -def _reference_plane_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_reference_plane(node, session) - - -def _reference_axis_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_reference_axis(node, session) - - -def _sphere_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_sphere(node, session) - - -def _box_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_box(node, session) - - -def _cylinder_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_cylinder(node, session) - - -def _hole_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_hole(node, session) - - -def _hole_wizard_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_hole(node, session, wizard=True) - - -def _fillet_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_fillet(node, session) - - -def _chamfer_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_chamfer(node, session) - - -def _shell_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_shell(node, session) - - -def _linear_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_linear_pattern(node, session, _execute_node) - - -def _mirror_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_mirror_pattern(node, session) - - -EXECUTORS: dict[str, ExecutorFunction] = { - "reference_plane": _reference_plane_executor, - "reference_axis": _reference_axis_executor, - "sphere_add": _sphere_executor, - "box_add": _box_executor, - "cylinder_add": _cylinder_executor, - "thread_add": _thread_executor, - "thread_cut": _thread_executor, - "bend_add": _bend_executor, - "gear_add": _gear_executor, - "rack_add": _rack_executor, - "extrude_add_blind": _primary_executor, - "extrude_add_blind_with_hole": _primary_executor, - "extrude_add_two_sided": _primary_executor, - "extrude_cut_blind": _primary_executor, - "extrude_cut_two_sided": _primary_executor, - "extrude_cut_through": _primary_executor, - "extrude_from_face": lambda node, session, _sketch: _execute_extrude_from_face(node, session), - "loft_add": _loft_executor, - "loft_add_with_cap_face": _loft_cap_face_executor, - "sweep_add": _sweep_executor, - "revolve_add": _primary_executor, - "revolve_cut": _primary_executor, - "revolve_surface": _revolve_surface_executor, - "extrude_surface": _extrude_surface_executor, - "hole_blind": _hole_executor, - "hole_countersink": _hole_executor, - "hole_counterbore": _hole_executor, - "hole_wizard": _hole_wizard_executor, - "fillet": _fillet_executor, - "chamfer": _chamfer_executor, - "shell": _shell_executor, - "boolean_bodies": lambda node, session, sketch: _execute_boolean_bodies(node, session), - "transform_bodies": lambda node, session, sketch: _execute_transform_bodies(node, session), - "delete_bodies": lambda node, session, sketch: _execute_delete_bodies(node, session), - "pattern_linear": _linear_pattern_executor, - "pattern_mirror": _mirror_pattern_executor, - "pattern_circular": _circular_pattern_executor, -} +from .topology import ( + CapabilityResult, FeaturePlanNode, FeatureResult, RuntimeDiagnostic, + SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry, +) + +__all__ = [ + "ALL_ATOMIC_IDS", + "EXECUTORS", + "ExecutionSession", + "ExecutorFunction", + "ExtentVector", + "FeatureExecutionError", + "FeaturePlanNode", + "FeatureResult", + "GeometryAdapter", + "RuntimeDiagnostic", + "RuntimeExecutionError", + "analyze_cdsl", + "execute_node", + "rebuild_cdsl", +] def analyze_cdsl(cdsl: dict[str, Any]): @@ -2613,7 +130,7 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) - break continue try: - _execute_node(node, session) + execute_node(node, session) except Exception as error: failed_resolution = next( (item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None, diff --git a/backend/engine/cdsl_engine/runtime_base.py b/backend/engine/cdsl_engine/runtime_base.py new file mode 100644 index 00000000..5d2449b0 --- /dev/null +++ b/backend/engine/cdsl_engine/runtime_base.py @@ -0,0 +1,46 @@ +"""Shared runtime error types and extent planning values. + +These primitives sit below both ``session`` (execution state) and the +executor modules, so geometry-independent helpers can raise the same stable +execution errors without importing the session or any executor. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .topology import RuntimeDiagnostic, Vector3 + + +class RuntimeExecutionError(RuntimeError): + """A feature execution failure with serializable runtime evidence.""" + + def __init__(self, diagnostic: RuntimeDiagnostic, selector_resolutions: list[dict[str, Any]]) -> None: + super().__init__(diagnostic.message) + self.diagnostic = diagnostic + self.selector_resolutions = selector_resolutions + + +class FeatureExecutionError(RuntimeError): + """An expected feature-level execution rejection with a stable code.""" + + def __init__(self, code: str, message: str, **detail: Any) -> None: + super().__init__(message) + self.code = code + self.detail = detail + + +@dataclass(frozen=True) +class ExtentVector: + """Single-directional extrusion displacement for one profile face. + + ``trim_to`` stays ``None`` for an exact vector extrusion. When set, the + extent means "extrude until the target face, trimming any profile region + that does not reach it" (up_to_surface trim semantics, issue #5). The + piercing distance is computed inside the adapter, so ``vector`` only + supplies the direction. + """ + + vector: Vector3 + trim_to: Any | None = None diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index 770e64bc..ffdc6b16 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -1,1841 +1,81 @@ -"""Runtime-neutral CDSL planning, diagnostics, and topology contracts. +"""Compatibility shim for the runtime-neutral CDSL contracts. -This module deliberately has no build123d dependency. The planner and -selector resolver can therefore be used by validation, batch reporting, and -any geometry adapter without importing OCC objects. +The contracts now live in two focused modules: + +- ``specs``: vector math, canonical plane/axis helpers, and parametric + feature spec dataclasses. +- ``topology``: diagnostics, planning contracts, and the topology registry + with explainable selector resolution. + +This module keeps the historical ``cdsl_engine.runtime_types`` import path +stable for the package, its tests, and external consumers. New code should +import from ``specs`` or ``topology`` directly. """ from __future__ import annotations -import warnings -from dataclasses import dataclass, field -from math import cos, pi, radians, sqrt -from typing import Any, Iterable - - -Vector3 = tuple[float, float, float] - - -def pattern_instance_member_id(pattern_feature_id: str, source_feature_id: str, instance_index: int) -> str: - """Return the runtime-only body-member key for one proven pattern copy. - - CDSL keeps the three source fields separately, so callers never need to - manufacture this internal key. The body graph uses the same derivation in - runtime and capability preflight. - """ - return f"pattern:{pattern_feature_id}:{source_feature_id}:copy:{instance_index}" - - -def transform_copy_member_id(transform_feature_id: str, source_member_id: str) -> str: - """Return the runtime-only key for one source of a multi-body COPY. - - A multi-source ``transform_bodies`` COPY has several independently - addressable outputs. CDSL records the transform and its selected source as - separate fields; the opaque key stays internal to the body graph. - """ - return f"transform:{transform_feature_id}:{source_member_id}:copy" - -# y_dir 与 x_dir / normal 点积的绝对值不超过该值时,认为 y_dir 是正交的, -# 予以保留;否则视为偏斜数据,正交化并显式警告。 -_Y_DIR_ORTHOGONALITY_TOL = 1e-6 - - -def _vector3(value: Any, *, field_name: str) -> Vector3: - if not isinstance(value, (list, tuple)) or len(value) != 3: - raise ValueError(f"{field_name} must contain three coordinates") - try: - return (float(value[0]), float(value[1]), float(value[2])) - except (TypeError, ValueError) as error: - raise ValueError(f"{field_name} must contain numeric coordinates") from error - - -def _length(value: Vector3) -> float: - return sqrt(sum(component * component for component in value)) - - -def _unit(value: Vector3, *, field_name: str) -> Vector3: - magnitude = _length(value) - if magnitude <= 1e-12: - raise ValueError(f"{field_name} must be non-zero") - return tuple(component / magnitude for component in value) # type: ignore[return-value] - - -def _dot(left: Vector3, right: Vector3) -> float: - return sum(a * b for a, b in zip(left, right)) - - -def _cross(left: Vector3, right: Vector3) -> Vector3: - return ( - left[1] * right[2] - left[2] * right[1], - left[2] * right[0] - left[0] * right[2], - left[0] * right[1] - left[1] * right[0], - ) - - -def vector_add(left: Vector3, right: Vector3) -> Vector3: - return tuple(a + b for a, b in zip(left, right)) # type: ignore[return-value] - - -def vector_subtract(left: Vector3, right: Vector3) -> Vector3: - return tuple(a - b for a, b in zip(left, right)) # type: ignore[return-value] - - -def vector_scale(value: Vector3, factor: float) -> Vector3: - return tuple(component * factor for component in value) # type: ignore[return-value] - - -def vector_dot(left: Vector3, right: Vector3) -> float: - return _dot(left, right) - - -def vector_cross(left: Vector3, right: Vector3) -> Vector3: - return _cross(left, right) - - -def vector_unit(value: Vector3, *, field_name: str = "vector") -> Vector3: - return _unit(value, field_name=field_name) - - -def canonical_plane_signature(normal: Vector3, point_mm: Vector3) -> tuple[Vector3, float]: - """Normalize a plane sign so source and OCC face orientations compare.""" - unit_normal = _unit(normal, field_name="plane.normal") - offset = _dot(unit_normal, point_mm) - for component in unit_normal: - if abs(component) <= 1e-12: - continue - if component < 0: - unit_normal = tuple(-value for value in unit_normal) # type: ignore[assignment] - offset = -offset - break - return unit_normal, offset - - -def normalize_selector_geometry(geometry: Any) -> dict[str, Any]: - """Convert legacy SolidWorks selector evidence into runtime-neutral units. - - Current CDSL records may already carry ``*_mm`` fields. Older exported - evidence instead stores SolidWorks surface parameters, boxes, and areas in - SI units. The selector remains the source of truth; this function only - makes its geometric signature comparable to an OCC topology snapshot. - """ - if not isinstance(geometry, dict): - return {} - result = dict(geometry) - surface = geometry.get("surface") - if isinstance(surface, dict): - surface_type = str(surface.get("type") or "").lower() - if surface_type: - result.setdefault("surface_type", surface_type) - parameters = surface.get("parameters") - if surface_type == "plane" and isinstance(parameters, list) and len(parameters) >= 6: - try: - raw_normal = _vector3(parameters[:3], field_name="selector surface normal") - # SolidWorks evidence uses metres for surface locations. - point_mm = tuple(float(value) * 1000.0 for value in parameters[3:6]) - plane_normal, plane_offset = canonical_plane_signature(raw_normal, point_mm) # type: ignore[arg-type] - result.setdefault("plane_normal", list(plane_normal)) - result.setdefault("plane_offset_mm", plane_offset) - except (TypeError, ValueError): - pass - curve = geometry.get("curve") - if isinstance(curve, dict) and curve.get("type"): - result.setdefault("curve_type", str(curve["type"]).lower()) - raw_box = geometry.get("box") - if isinstance(raw_box, list) and len(raw_box) == 6: - try: - result.setdefault("bbox_mm", [float(value) * 1000.0 for value in raw_box]) - except (TypeError, ValueError): - pass - raw_area = geometry.get("area") - if raw_area is not None: - try: - result.setdefault("area_mm2", float(raw_area) * 1_000_000.0) - except (TypeError, ValueError): - pass - for raw_key, normalized_key in (("start", "start_mm"), ("end", "end_mm")): - value = geometry.get(raw_key) - if isinstance(value, list) and len(value) == 3: - try: - result.setdefault(normalized_key, [float(component) * 1000.0 for component in value]) - except (TypeError, ValueError): - pass - return result - - -@dataclass(frozen=True) -class AxisSpec: - """Canonical axis with a normalized direction.""" - - origin_mm: Vector3 - direction: Vector3 - - @classmethod - def from_mapping(cls, value: dict[str, Any]) -> "AxisSpec": - return cls( - origin_mm=_vector3(value.get("origin_mm"), field_name="axis.origin_mm"), - direction=_unit(_vector3(value.get("direction"), field_name="axis.direction"), field_name="axis.direction"), - ) - - def as_dict(self) -> dict[str, list[float]]: - return {"origin_mm": list(self.origin_mm), "direction": list(self.direction)} - - -@dataclass(frozen=True) -class PlaneSpec: - """Canonical right-handed plane frame. - - SolidWorks exports may contain a redundant or non-orthogonal y direction. - The runtime persists the orthonormalized frame so later features all use - the same coordinate system. - """ - - origin_mm: Vector3 - x_dir: Vector3 - y_dir: Vector3 - normal: Vector3 - - @classmethod - def from_mapping(cls, value: dict[str, Any]) -> "PlaneSpec": - origin = _vector3(value.get("origin_mm"), field_name="plane.origin_mm") - normal = _unit(_vector3(value.get("normal"), field_name="plane.normal"), field_name="plane.normal") - x_raw = _vector3(value.get("x_dir"), field_name="plane.x_dir") - projected_x = tuple(x_raw[index] - _dot(x_raw, normal) * normal[index] for index in range(3)) - x_dir = _unit(projected_x, field_name="plane.x_dir") - generated = _unit(_cross(normal, x_dir), field_name="plane.y_dir") - y_raw = value.get("y_dir") - if y_raw is None: - # y_dir 缺失:用 normal × x_dir 补全右手系(默认行为)。 - y_dir = generated - else: - y_vec = _unit(_vector3(y_raw, field_name="plane.y_dir"), field_name="plane.y_dir") - if abs(_dot(y_vec, x_dir)) <= _Y_DIR_ORTHOGONALITY_TOL and abs(_dot(y_vec, normal)) <= _Y_DIR_ORTHOGONALITY_TOL: - # 输入 y_dir 与 x_dir / normal 正交:尊重文档作者给的坐标方向, - # 不再静默丢弃(SolidWorks 导出的非标准 y_dir 得以保留)。 - y_dir = y_vec - else: - # 偏斜 y_dir:正交化并显式警告,避免"静默丢语义"。 - warnings.warn( - f"plane y_dir {list(y_vec)} is not orthogonal to x_dir/normal; re-orthogonalized to {list(generated)}", - UserWarning, - stacklevel=2, - ) - y_dir = generated - return cls(origin_mm=origin, x_dir=x_dir, y_dir=y_dir, normal=normal) - - def as_dict(self) -> dict[str, list[float]]: - return { - "origin_mm": list(self.origin_mm), - "x_dir": list(self.x_dir), - "y_dir": list(self.y_dir), - "normal": list(self.normal), - } - - -@dataclass(frozen=True) -class HoleSpec: - """Runtime-neutral definition of a cylindrical Hole Wizard operation. - - The spec deliberately contains no OCC planes or shapes. The runtime - resolves the host frame and the adapter turns this definition into a - cutting tool, keeping source-contract parsing separate from B-rep work. - """ - - diameter_mm: float - depth_mm: float - end_condition: str - positions_mm: tuple[Vector3, ...] - countersink: tuple[float, float] | None = None - counterbore: tuple[float, float] | None = None - - @classmethod - def from_feature(cls, atomic_id: str, params: dict[str, Any], *, wizard: bool) -> "HoleSpec": - try: - diameter = float(params.get("diameter_mm") or 0.0) - depth = float(params.get("depth_mm") or 0.0) - except (TypeError, ValueError) as error: - raise ValueError("hole dimensions must be numeric") from error - if diameter <= 0 or depth <= 0: - raise ValueError("hole requires positive diameter_mm and depth_mm") - condition = str((params.get("end_condition") or {"type": "blind"}).get("type") or "blind") - if condition not in {"blind", "through_all", "through_all_both"}: - raise ValueError(f"unsupported hole extent {condition!r}") - raw_positions = params.get("positions") or [] - positions = tuple(_vector3(item.get("mm"), field_name="hole position") for item in raw_positions if isinstance(item, dict)) - if len(positions) != len(raw_positions) or not positions: - raise ValueError("hole requires non-empty positions with three-dimensional mm coordinates") - - raw_sink: dict[str, Any] | None = params.get("countersink") if wizard else None - raw_bore: dict[str, Any] | None = params.get("counterbore") if wizard else None - if atomic_id == "hole_countersink": - raw_sink = {"diameter_mm": params.get("countersink_diameter_mm"), "angle_rad": params.get("countersink_angle_rad")} - if atomic_id == "hole_counterbore": - raw_bore = {"diameter_mm": params.get("counterbore_diameter_mm"), "depth_mm": params.get("counterbore_depth_mm")} - - def dimensions(value: dict[str, Any] | None, second: str, label: str) -> tuple[float, float] | None: - if value is None: - return None - try: - first_value = float(value.get("diameter_mm") or 0.0) - second_value = float(value.get(second) or 0.0) - except (AttributeError, TypeError, ValueError) as error: - raise ValueError(f"{label} dimensions must be numeric") from error - if first_value <= diameter or second_value <= 0: - raise ValueError(f"{label} requires a diameter larger than the main hole and a positive {second}") - return first_value, second_value - - return cls( - diameter_mm=diameter, - depth_mm=depth, - end_condition=condition, - positions_mm=positions, - countersink=dimensions(raw_sink, "angle_rad", "countersink"), - counterbore=dimensions(raw_bore, "depth_mm", "counterbore"), - ) - - -@dataclass(frozen=True) -class ThreadSpec: - """Runtime-neutral definition of a parametric screw thread. - - The spec deliberately contains no OCC planes or shapes. The adapter turns - this definition into a threaded solid segment keeping source-contract - parsing separate from B-rep work. - - ``axis.origin_mm`` anchors the leading end face of the threaded segment and - ``axis.direction`` is the outward thread axis; ``angle_deg`` is the full - flank angle (60 for ISO metric V threads, 30 for trapezoidal leadscrews). - ``internal`` selects the cutting form used by later thread_cut support. - """ - - major_diameter_mm: float - minor_diameter_mm: float - pitch_mm: float - length_mm: float - axis: AxisSpec - angle_deg: float = 60.0 - internal: bool = False - lefthand: bool = False - relief_length_mm: float = 0.0 - crest_radius_mm: float = 0.0 - root_radius_mm: float = 0.0 - - @classmethod - def from_feature(cls, atomic_id: str, params: dict[str, Any]) -> "ThreadSpec": - try: - major = float(params.get("major_diameter_mm") or 0.0) - minor = float(params.get("minor_diameter_mm") or 0.0) - pitch = float(params.get("pitch_mm") or 0.0) - length = float(params.get("length_mm") or 0.0) - except (TypeError, ValueError) as error: - raise ValueError("thread dimensions must be numeric") from error - if major <= 0 or minor <= 0 or pitch <= 0 or length <= 0: - raise ValueError("thread requires positive major/minor/pitch/length in millimetres") - if minor >= major: - raise ValueError("thread minor diameter must be smaller than the major diameter") - raw_axis = params.get("axis") - if not isinstance(raw_axis, dict): - raise ValueError("thread requires an axis definition") - angle = 60.0 - raw_angle = params.get("angle_deg") - if raw_angle is not None: - try: - angle = float(raw_angle) - except (TypeError, ValueError) as error: - raise ValueError("thread angle_deg must be numeric") from error - if not 0 < angle < 180: - raise ValueError("thread angle_deg must be between 0 and 180") - relief = float(params.get("relief_length_mm") or 0.0) - crest_r = float(params.get("crest_radius_mm") or 0.0) - root_r = float(params.get("root_radius_mm") or 0.0) - if relief < 0: - raise ValueError("thread relief_length_mm must be non-negative") - if crest_r < 0 or root_r < 0: - raise ValueError("thread crest/root radius must be non-negative") - # 清根退化保护:crest + root 圆角半径之和不能超过可用牙高的一半, - # 否则梯形牙截面会被 fillet 完全吃掉,无法形成封闭截面。 - depth_radius = (major - minor) / 2.0 - if crest_r + root_r > 0.5 * depth_radius: - raise ValueError("thread crest_radius + root_radius exceeds half the thread depth") - # thread_cut 语义上恒为内螺纹:CDSL 输入无需显式传 internal,即使传 - # internal=False 也强制为 True(外螺纹切槽没有任何物理意义,且外轮廓 - # 刀具做布尔差会在 coincident faces 上退化)。 - internal = True if atomic_id == "thread_cut" else bool(params.get("internal", False)) - return cls( - major_diameter_mm=major, - minor_diameter_mm=minor, - pitch_mm=pitch, - length_mm=length, - axis=AxisSpec.from_mapping(raw_axis), - angle_deg=angle, - internal=internal, - lefthand=bool(params.get("lefthand", False)), - relief_length_mm=relief, - crest_radius_mm=crest_r, - root_radius_mm=root_r, - ) - - -@dataclass(frozen=True) -class BendLeg: - """One straight wing of a bent sheet-metal chain (runtime-neutral). - - ``leg_mm`` is the straight mid-plane distance from the previous fold vertex - to the fold vertex leaving this wing (the unfolded chord length between the - two surrounding fold vertices). When the wing has a following wing, - ``bend_angle_deg`` is the required *interior* angle between the two wings - (0 < angle < 180, 90 = a right-angle bend), ``inner_radius_mm`` the inner - bend-surface fillet radius (>= 0; the outer radius is always - ``inner_radius_mm + thickness_mm``) and ``side`` the fold direction - (+1 / -1). A trailing wing carries no fold; stray fold fields on the last - wing are ignored for tolerance. - """ - - leg_mm: float - bend_angle_deg: float | None = None - inner_radius_mm: float = 0.0 - side: int = 1 - - -@dataclass(frozen=True) -class BendSpec: - """Runtime-neutral definition of a sheet-metal bend (``bend_add``). - - The part is described by its mid-plane centreline: ``chain`` lists the - straight wings joined by equal-thickness bend corners. ``thickness_mm`` - and ``width_mm`` are the sheet thickness and the full length along the fold - (width) axis. ``frame.origin_mm`` anchors the start of the first wing's - mid-plane path, ``frame.x_dir`` is the fold/width axis and - ``frame.normal`` is the mid-plane normal of the first wing; the first wing - extends along ``normal x x_dir``. - - The spec deliberately contains no OCC planes or shapes. The adapter turns - this definition into a bent solid keeping source-contract parsing separate - from B-rep work (same pattern as :class:`ThreadSpec`). - """ - - thickness_mm: float - width_mm: float - frame: PlaneSpec - chain: tuple[BendLeg, ...] - - @classmethod - def from_feature(cls, params: dict[str, Any]) -> "BendSpec": - try: - thickness = float(params.get("thickness_mm") or 0.0) - width = float(params.get("width_mm") or 0.0) - except (TypeError, ValueError) as error: - raise ValueError("bend thickness/width must be numeric") from error - if thickness <= 0 or width <= 0: - raise ValueError("bend requires positive thickness_mm and width_mm in millimetres") - raw_chain = params.get("chain") - if not isinstance(raw_chain, (list, tuple)) or not raw_chain: - raise ValueError("bend requires a non-empty chain of wing segments") - chain: list[BendLeg] = [] - for index, raw_leg in enumerate(raw_chain): - if not isinstance(raw_leg, dict): - raise ValueError(f"bend chain[{index}] must be an object with leg_mm") - try: - leg = float(raw_leg.get("leg_mm") or 0.0) - except (TypeError, ValueError) as error: - raise ValueError(f"bend chain[{index}].leg_mm must be numeric") from error - if leg <= 0: - raise ValueError(f"bend chain[{index}].leg_mm must be positive") - bend_angle: float | None = None - if index < len(raw_chain) - 1: - raw_angle = raw_leg.get("bend_angle_deg") - if raw_angle is None: - raise ValueError( - f"bend chain[{index}] needs bend_angle_deg for the fold towards the next wing" - ) - try: - bend_angle = float(raw_angle) - except (TypeError, ValueError) as error: - raise ValueError(f"bend chain[{index}].bend_angle_deg must be numeric") from error - if not 0 < bend_angle < 180: - raise ValueError("bend interior angle must be between 0 and 180 degrees") - radius = float(raw_leg.get("inner_radius_mm") or 0.0) - raw_side = raw_leg.get("side") - side = 1 if raw_side is None else int(raw_side) - if radius < 0: - raise ValueError(f"bend chain[{index}].inner_radius_mm must be non-negative") - if side not in (1, -1): - raise ValueError(f"bend chain[{index}].side must be +1 or -1") - chain.append(BendLeg( - leg_mm=leg, - bend_angle_deg=bend_angle, - inner_radius_mm=radius, - side=side, - )) - raw_frame = params.get("frame") - if isinstance(raw_frame, dict): - frame = PlaneSpec.from_mapping(raw_frame) - else: - # frame 缺省:首翼沿世界 +X 延伸、厚度沿 +Y、折痕沿 +Z(贴 XY 平面)。 - frame = PlaneSpec( - origin_mm=(0.0, 0.0, 0.0), - x_dir=(0.0, 0.0, 1.0), - y_dir=(1.0, 0.0, 0.0), - normal=(0.0, 1.0, 0.0), - ) - return cls(thickness_mm=thickness, width_mm=width, frame=frame, chain=tuple(chain)) - - -@dataclass(frozen=True) -class GearSpec: - """Runtime-neutral definition of an involute spur/helical/herringbone gear. - - ``module_mm``/``teeth_count``/``pressure_angle_rad`` follow the ISO - involute convention: pitch radius ``m*z/2``, base radius - ``m*z/2*cos(alpha)``, addendum ``m`` and dedendum ``1.25*m``. ``width_mm`` - is the axial face width. ``helix_angle_rad`` is the pitch-cylinder helix - angle (0 = spur); ``herringbone=True`` builds a symmetric double-helical - gear whose helix reverses at mid-width (the mid-plane seam is shared - phase, no V-notch). ``axis.origin_mm`` is the centre of the ``z = 0`` end - face and ``axis.direction`` the outward gear axis. - """ - - module_mm: float - teeth_count: int - width_mm: float - axis: AxisSpec - helix_angle_rad: float = 0.0 - pressure_angle_rad: float = pi / 9.0 # 20 degrees - herringbone: bool = False - - @classmethod - def from_feature(cls, params: dict[str, Any]) -> "GearSpec": - try: - module = float(params.get("module_mm") or 0.0) - width = float(params.get("width_mm") or 0.0) - teeth = int(params.get("teeth_count") or 0) - helix_raw = params.get("helix_angle_rad") - helix = float(helix_raw) if helix_raw is not None else 0.0 - pressure_raw = params.get("pressure_angle_rad") - pressure = float(pressure_raw) if pressure_raw is not None else pi / 9.0 - except (TypeError, ValueError) as error: - raise ValueError("gear dimensions must be numeric") from error - if module <= 0 or width <= 0: - raise ValueError("gear requires positive module_mm and width_mm") - if not 8 <= teeth <= 200: - raise ValueError("gear teeth_count must be between 8 and 200") - if not 0.0 <= helix < radians(45.0): - raise ValueError("gear helix_angle_rad must be within [0, 45) degrees") - if not 0.0 < pressure <= radians(35.0): - raise ValueError("gear pressure_angle_rad must be within (0, 35] degrees") - herringbone = bool(params.get("herringbone", False)) - if herringbone and helix <= 0.0: - raise ValueError("herringbone gear requires helix_angle_rad > 0") - raw_axis = params.get("axis") - if not isinstance(raw_axis, dict): - raise ValueError("gear requires an axis definition") - return cls( - module_mm=module, - teeth_count=teeth, - width_mm=width, - axis=AxisSpec.from_mapping(raw_axis), - helix_angle_rad=helix, - pressure_angle_rad=pressure, - herringbone=herringbone, - ) - - @property - def pitch_radius_mm(self) -> float: - return self.module_mm * self.teeth_count / 2.0 - - @property - def base_radius_mm(self) -> float: - return self.pitch_radius_mm * cos(self.pressure_angle_rad) - - @property - def tip_radius_mm(self) -> float: - return self.pitch_radius_mm + self.module_mm - - @property - def root_radius_mm(self) -> float: - return self.pitch_radius_mm - 1.25 * self.module_mm - - -@dataclass(frozen=True) -class RackSpec: - """Runtime-neutral definition of a straight-sided rack (``rack_add``). - - The rack is the linear counterpart of a gear: tooth pitch ``p = pi*m``, - addendum ``m`` above the reference line and dedendum ``1.25*m`` below it, - flanks inclined by ``pressure_angle_rad``. ``teeth_count`` sets the exact - overall length ``teeth_count * pi * m`` (both ends land in tooth valley - centres). ``thickness_mm`` is the width along the tooth crest direction. - ``axis.origin_mm`` is the corner point where the length-start end face, - the thickness-start face and the tooth-valley plane intersect, and - ``axis.direction`` is the direction the teeth point (local +Z). - """ - - module_mm: float - teeth_count: int - thickness_mm: float - axis: AxisSpec - pressure_angle_rad: float = pi / 9.0 # 20 degrees - - @classmethod - def from_feature(cls, params: dict[str, Any]) -> "RackSpec": - try: - module = float(params.get("module_mm") or 0.0) - thickness = float(params.get("thickness_mm") or 0.0) - teeth = int(params.get("teeth_count") or 0) - pressure_raw = params.get("pressure_angle_rad") - pressure = float(pressure_raw) if pressure_raw is not None else pi / 9.0 - except (TypeError, ValueError) as error: - raise ValueError("rack dimensions must be numeric") from error - if module <= 0 or thickness <= 0: - raise ValueError("rack requires positive module_mm and thickness_mm") - if not 1 <= teeth <= 2000: - raise ValueError("rack teeth_count must be between 1 and 2000") - if not 0.0 < pressure <= radians(35.0): - raise ValueError("rack pressure_angle_rad must be within (0, 35] degrees") - raw_axis = params.get("axis") - if not isinstance(raw_axis, dict): - raise ValueError("rack requires an axis definition") - return cls( - module_mm=module, - teeth_count=teeth, - thickness_mm=thickness, - axis=AxisSpec.from_mapping(raw_axis), - pressure_angle_rad=pressure, - ) - - @property - def pitch_mm(self) -> float: - return pi * self.module_mm - - @property - def length_mm(self) -> float: - return self.pitch_mm * self.teeth_count - - @property - def addendum_mm(self) -> float: - return self.module_mm - - @property - def dedendum_mm(self) -> float: - return 1.25 * self.module_mm - - -@dataclass(frozen=True) -class RuntimeDiagnostic: - code: str - message: str - feature_id: str | None = None - detail: dict[str, Any] = field(default_factory=dict) - - def as_dict(self) -> dict[str, Any]: - output: dict[str, Any] = {"code": self.code, "message": self.message} - if self.feature_id is not None: - output["feature_id"] = self.feature_id - if self.detail: - output["detail"] = self.detail - return output - - -@dataclass(frozen=True) -class CapabilityResult: - feature_id: str - atomic_id: str - resolved_status: str - required_capabilities: tuple[str, ...] = () - blockers: tuple[RuntimeDiagnostic, ...] = () - - @property - def executable(self) -> bool: - return self.resolved_status == "executable" - - def as_dict(self) -> dict[str, Any]: - return { - "feature_id": self.feature_id, - "atomic_id": self.atomic_id, - "resolved_status": self.resolved_status, - "required_capabilities": list(self.required_capabilities), - "blockers": [blocker.as_dict() for blocker in self.blockers], - } - - -@dataclass(frozen=True) -class FeaturePlanNode: - feature_id: str - atomic_id: str - name: str | None - depends_on: tuple[str, ...] - params: dict[str, Any] - selectors: tuple[dict[str, Any], ...] - sketch_id: str | None - declared_status: str | None - source_feature: dict[str, Any] - - -@dataclass -class FeatureResult: - feature_id: str - atomic_id: str - status: str - body_id: str | None = None - surface_id: str | None = None - context: PlaneSpec | AxisSpec | None = None - replay_definition: dict[str, Any] | None = None - diagnostics: list[RuntimeDiagnostic] = field(default_factory=list) - - def as_dict(self) -> dict[str, Any]: - output: dict[str, Any] = { - "feature_id": self.feature_id, - "atomic_id": self.atomic_id, - "status": self.status, - "diagnostics": [diagnostic.as_dict() for diagnostic in self.diagnostics], - } - if self.body_id is not None: - output["body_id"] = self.body_id - if self.surface_id is not None: - output["surface_id"] = self.surface_id - if self.context is not None: - output["context"] = self.context.as_dict() - if self.replay_definition is not None: - output["replay_definition"] = self.replay_definition - return output - - -@dataclass(frozen=True) -class TopologyRecord: - """Runtime-side signature of a topology item or context object. - - ``feature_id`` identifies the feature that produced this *snapshot*. - ``owner_feature_ids`` is durable semantic provenance for an unchanged - current B-rep item. Boolean and dress-up operations replace OCC objects, - so keeping these concepts separate prevents a later mutation from making - every surviving face appear to be owned by that mutation. - """ - - record_id: str - kind: str - feature_id: str - body_id: str | None = None - geometry: dict[str, Any] = field(default_factory=dict) - value: Any = None - owner_feature_ids: tuple[str, ...] = () - # Builder-produced roles describe a particular result subshape. They are - # deliberately separate from the geometric signature: equal geometry does - # not prove that two faces have the same feature-output meaning. - output_roles: tuple[str, ...] = () - # Generated roles may carry the direct feature-output role that the kernel - # operation transformed. This is semantic provenance, not a stable-id - # shortcut: the resolver still requires the exact active result snapshot. - output_role_sources: tuple[tuple[str, str, str], ...] = () - - @property - def owners(self) -> tuple[str, ...]: - """Return durable provenance, retaining compatibility for contexts.""" - return self.owner_feature_ids or (self.feature_id,) - - def public_dict(self) -> dict[str, Any]: - output: dict[str, Any] = { - "record_id": self.record_id, - "kind": self.kind, - "feature_id": self.feature_id, - "geometry": self.geometry, - } - if self.body_id is not None: - output["body_id"] = self.body_id - if self.owner_feature_ids: - output["owner_feature_ids"] = list(self.owner_feature_ids) - if self.output_roles: - output["output_roles"] = list(self.output_roles) - if self.output_role_sources: - output["output_role_sources"] = [ - {"output_role": role, "owner_feature_id": owner, "source_output_role": source_role} - for role, owner, source_role in self.output_role_sources - ] - return output - - -@dataclass(frozen=True) -class TopologyDeltaRelation: - """One opaque kernel-history relationship for a topology subshape. - - Geometry adapters retain ownership of the values in this structure. They - are intentionally opaque to the runtime: a build123d/OCC adapter may use - ``TopoDS_Shape`` values while another adapter can use its native handles. - The registry only asks whether a handle is exactly the same topology item; - it never uses this contract to score nearby geometry. - """ - - event: str - kind: str - source_value: Any - result_values: tuple[Any, ...] = () - output_role: str | None = None - derivation: str | None = None - source_role: str | None = None - result_role: str | None = None - source_slot: str | None = None - result_slot: str | None = None - coverage: str = "complete" - status: str = "proven" - - def __post_init__(self) -> None: - if self.event not in {"preserved", "modified", "generated", "deleted"}: - raise ValueError(f"unsupported topology delta event {self.event!r}") - if self.kind not in {"face", "edge", "vertex"}: - raise ValueError(f"unsupported topology delta kind {self.kind!r}") - if self.event == "deleted" and (self.result_values or self.output_role is not None): - raise ValueError("deleted topology delta relations cannot have result values or an output role") - if self.output_role is not None and (not isinstance(self.output_role, str) or not self.output_role): - raise ValueError("topology delta output_role must be a non-empty string when provided") - derivation = self.derivation - if derivation is None: - derivation = { - "preserved": "continuation", - "modified": "fragment" if len(self.result_values) > 1 else "continuation", - "generated": "boundary", - "deleted": "replacement", - }[self.event] - object.__setattr__(self, "derivation", derivation) - if derivation not in {"continuation", "fragment", "merge", "intersection", "boundary", "replacement"}: - raise ValueError(f"unsupported topology derivation {derivation!r}") - if self.coverage not in {"complete", "partial", "none"}: - raise ValueError(f"unsupported topology coverage {self.coverage!r}") - if self.status not in {"proven", "unknown", "rejected"}: - raise ValueError(f"unsupported topology relation status {self.status!r}") - - -@dataclass(frozen=True) -class TopologyDelta: - """Kernel-backed topology history for one adapter operation. - - ``operation`` is evidence only. The runtime transfers durable provenance - solely from a unique exact relationship, never from an operation name or a - geometric resemblance. - """ - - operation: str - relations: tuple[TopologyDeltaRelation, ...] = () - # Boolean section edges are not ordinary source continuations. They are - # retained independently so INTERSECT selectors can require this kernel - # evidence instead of matching a nearby edge geometrically. - section_values: tuple[Any, ...] = () - - -@dataclass(frozen=True) -class TopologyLineage: - """An N:M semantic edge backed by an adapter history relation.""" - - source_record_ids: tuple[str, ...] - result_record_ids: tuple[str, ...] - derivation: str - evidence: str - coverage: str - status: str - operation: str - output_role: str | None = None - - def as_dict(self) -> dict[str, Any]: - result = { - "source_record_ids": list(self.source_record_ids), - "result_record_ids": list(self.result_record_ids), - "derivation": self.derivation, - "evidence": self.evidence, - "coverage": self.coverage, - "status": self.status, - "operation": self.operation, - } - if self.output_role is not None: - result["output_role"] = self.output_role - return result - - -@dataclass(frozen=True) -class SelectorResolution: - selector: dict[str, Any] - status: str - record: TopologyRecord | None = None - # ``records`` carries policy-authorized 1:N selector results. ``record`` - # remains the compatibility field for a unique selection and context use. - 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" - - def as_dict(self) -> dict[str, Any]: - output = { - "selector": self.selector, - "status": self.status, - "resolution_mode": self.resolution_mode, - "candidates": list(self.candidates), - } - if self.record is not None: - output["record"] = self.record.public_dict() - output["selected"] = self.record.public_dict() - score = next( - (candidate.get("score") for candidate in self.candidates if candidate.get("record_id") == self.record.record_id), - None, - ) - if score is not None: - output["score"] = score - if self.records: - 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 ()))], - } - return output - - -class TopologyRegistry: - """Feature-scoped context/topology registry with explainable matching.""" - - def __init__(self) -> None: - self._records: list[TopologyRecord] = [] - self._by_feature: dict[str, list[TopologyRecord]] = {} - self._active_body_id: str | None = None - self._topology_deltas: list[dict[str, Any]] = [] - self._lineage: list[TopologyLineage] = [] - # #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer - # 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何 - # 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的 - # stable_id 引用可以解析到 active body 内的新形态。 - self._successors: dict[str, list[str]] = {} - - def register(self, record: TopologyRecord) -> None: - self._records.append(record) - self._by_feature.setdefault(record.feature_id, []).append(record) - - def records_for_feature(self, feature_id: str) -> tuple[TopologyRecord, ...]: - return tuple(self._by_feature.get(feature_id, ())) - - def records(self) -> tuple[TopologyRecord, ...]: - return tuple(self._records) - - def topology_deltas(self) -> tuple[dict[str, Any], ...]: - """Return serializable evidence derived from exact adapter history.""" - return tuple(self._topology_deltas) - - def lineage(self) -> tuple[TopologyLineage, ...]: - """Return kernel-backed N:M lineage without heuristic successors.""" - return tuple(self._lineage) - - def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord: - kind = "plane" if isinstance(context, PlaneSpec) else "axis" - record = TopologyRecord( - record_id=f"{feature_id}:{kind}", - kind=kind, - feature_id=feature_id, - geometry=context.as_dict(), - value=context, - ) - self.register(record) - return record - - 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, - additional_predecessors: Iterable[TopologyRecord] = (), - ) -> None: - self.replace_body_topologies( - feature_id, [(body_id, records)], active_body_id=active_body_id, topology_delta=topology_delta, - additional_predecessors=additional_predecessors, - ) - - def replace_body_topologies( - self, feature_id: str, bodies: Iterable[tuple[str, Iterable[TopologyRecord]]], - *, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None, - additional_predecessors: Iterable[TopologyRecord] = (), - ) -> None: - """Record a fresh B-rep snapshot after a feature mutates the body. - - OCC topology object identity is invalidated by most body mutations. - We therefore keep old objects out of active selector resolution but - carry their semantic owners forward when, and only when, one current - object has one geometrically equivalent predecessor. A changed or - split object intentionally becomes owned by this feature instead of - being guessed as belonging to an older one. - - ``active_body_id`` names the whole-body group when ``body_id`` is a - member of a multi-solid body (issue #7): the group id keeps the next - mutation's predecessor lookup scoped to every solid of the previous - body, while each member keeps its own ``body:{feature}:{index}`` id. - """ - active_previous = [ - record for record in self._records - if self._active_body_id is not None and record.body_id is not None - and ( - record.body_id == self._active_body_id - or record.body_id.startswith(f"{self._active_body_id}:") - ) - ] - # A pattern COPY can carry a chain of exact transform/boolean builder - # histories before its final aggregate snapshot is registered. These - # temporary records are valid predecessors only for that documented - # kernel-history bridge. They are deliberately excluded from geometric - # fallback matching: an equal-looking final face never proves that it - # belongs to one particular copy instance. - transient_previous = list(additional_predecessors) - previous = [*active_previous, *transient_previous] - # 同一 source feature 的 pattern copy 可以产生完全相同的几何面。它们 - # 必须保留为多个实例,不能在跨 body 的全局 predecessor 匹配中互相消费。 - # pattern 的 Compound 成员顺序是稳定的:已有实例以同一 member index - # 延续,新增实例只会出现在末尾。按该 index 限定后继匹配。 - current = [(body_id, list(records)) for body_id, records in bodies] - current_records = [record for _body_id, records in current for record in records] - ( - exact_predecessors, - exact_successors, - kernel_covered_predecessors, - exact_output_roles, - exact_output_role_sources, - delta_evidence, - ) = self._exact_delta_links( - topology_delta, previous, current_records, - ) - previous_member_ids = { - suffix for record in active_previous - for suffix in [str(record.body_id).rsplit(":", 1)[-1]] - if suffix.isdigit() - } - use_member_indexes = len(current) > 1 and previous_member_ids - consumed_predecessors: set[str] = set() - registered: list[TopologyRecord] = [] - for body_id, records in current: - member_id = str(body_id).rsplit(":", 1)[-1] - local_predecessors = [ - record for record in active_previous - if not use_member_indexes or str(record.body_id).rsplit(":", 1)[-1] == member_id - ] - for record in records: - exact_predecessor_id = exact_predecessors.get(record.record_id) - # Member order is a useful isolation boundary for geometric - # fallback matching, especially for coincident pattern - # copies. It is not a provenance boundary when a kernel - # builder explicitly relates one source subshape to one - # result subshape: boolean/delete lifecycle can remove an - # earlier member and shift a surviving source to another - # member index. A unique OCC continuation remains exact - # evidence across that index change. - predecessor = next( - (prior for prior in previous if prior.record_id == exact_predecessor_id), - None, - ) - if predecessor is None and record.record_id not in exact_predecessors: - predecessor = self._unique_equivalent_predecessor(record, local_predecessors, consumed_predecessors) - owners = predecessor.owners if predecessor is not None else (feature_id,) - output_roles = set(record.output_roles) - output_roles.update(exact_output_roles.get(record.record_id, ())) - output_role_sources = set(record.output_role_sources) - output_role_sources.update(exact_output_role_sources.get(record.record_id, ())) - # A feature-output role can survive a later operation only - # through the same unique kernel continuation used for owner - # provenance. Geometry equivalence alone never carries it. - if predecessor is not None and record.record_id in exact_predecessors: - output_roles.update(predecessor.output_roles) - if predecessor is not None and record.record_id not in exact_predecessors: - consumed_predecessors.add(predecessor.record_id) - registered.append(TopologyRecord( - record_id=record.record_id, - kind=record.kind, - feature_id=feature_id, - body_id=body_id, - geometry=dict(record.geometry), - value=record.value, - owner_feature_ids=owners, - output_roles=tuple(sorted(output_roles)), - output_role_sources=tuple(sorted(output_role_sources)), - )) - for record in registered: - self.register(record) - for predecessor_id, successor_ids in exact_successors.items(): - known = self._successors.setdefault(predecessor_id, []) - for successor_id in successor_ids: - if successor_id not in known: - known.append(successor_id) - if delta_evidence is not None: - 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], - }) - # #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续 - # selector 的 stable_id 引用解析到 active body 内的新形态。多条演化 - # 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段 - # 也有圆角过渡带的新边,前者的端点与原边重合、漂移更小);漂移并列 - # (如竖直边被完整消费成两条等距直段)属于本质歧义,保守不登记。 - for prior in previous: - if ( - prior.record_id in consumed_predecessors - or prior.record_id in exact_successors - or prior.record_id in kernel_covered_predecessors - ): - continue - candidates = sorted( - ( - (self._evolved_drift(prior, record), record.record_id) - for record in registered if self._evolved_equivalent(prior, record) - ), - key=lambda item: item[0], - ) - if not candidates: - continue - best, second = candidates[0], (candidates[1] if len(candidates) > 1 else None) - if second is None or (second[0] - best[0]) > max(0.5, 0.2 * best[0]): - self._successors[prior.record_id] = [best[1]] - self._active_body_id = active_body_id or body_id - - @staticmethod - def _same_topology_value(left: Any, right: Any) -> bool: - """Compare adapter handles only through their exact topology identity.""" - left_value = getattr(left, "wrapped", left) - right_value = getattr(right, "wrapped", right) - if left_value is right_value: - return True - for candidate, other in ((left_value, right_value), (right_value, left_value)): - for method_name in ("IsSame", "is_same"): - method = getattr(candidate, method_name, None) - if callable(method): - try: - return bool(method(other)) - except (AttributeError, TypeError, ValueError): - continue - return False - - @classmethod - def _exact_delta_links( - cls, - topology_delta: TopologyDelta | None, - previous: list[TopologyRecord], - current: list[TopologyRecord], - ) -> tuple[ - dict[str, str], - dict[str, list[str]], - set[str], - dict[str, tuple[str, ...]], - dict[str, tuple[tuple[str, str, str], ...]], - list[dict[str, Any]] | None, - ]: - """Bind opaque kernel history to snapshots without geometric guessing. - - Ownership transfer is intentionally limited to a single source item and - a single output item. Split/merge history remains useful evidence, but - has no unique owner continuation until a later operation-specific - contract can express it. - """ - if topology_delta is None: - return {}, {}, set(), {}, {}, None - candidate_sources: dict[str, set[str]] = {} - kernel_covered_predecessors: set[str] = set() - output_roles: dict[str, set[str]] = {} - output_role_sources: dict[str, set[tuple[str, str, str]]] = {} - relation_links: list[tuple[str, str] | None] = [] - evidence: list[dict[str, Any]] = [] - for relation in topology_delta.relations: - sources = [ - record for record in previous - if record.kind == relation.kind and cls._same_topology_value(record.value, relation.source_value) - ] - outputs = [ - record for record in current - if record.kind == relation.kind - and any(cls._same_topology_value(record.value, value) for value in relation.result_values) - ] - item = { - "event": relation.event, - "kind": relation.kind, - "source_record_ids": [record.record_id for record in sources], - "result_record_ids": [record.record_id for record in outputs], - "proof": "kernel_history", - "derivation": relation.derivation, - "coverage": relation.coverage if len(outputs) == len(relation.result_values) else "partial", - "lineage_status": relation.status, - } - for field_name in ("source_role", "result_role", "source_slot", "result_slot"): - value = getattr(relation, field_name) - if value is not None: - item[field_name] = value - if relation.output_role is not None: - item["output_role"] = relation.output_role - role_is_unique = len(relation.result_values) == 1 and len(outputs) == 1 - item["output_role_status"] = ( - "unique_result_snapshot" if role_is_unique else "non_unique_or_missing_result_snapshot" - ) - if role_is_unique: - output_roles.setdefault(outputs[0].record_id, set()).add(relation.output_role) - for source in sources: - for source_role in source.output_roles: - for source_owner in source.owners: - output_role_sources.setdefault(outputs[0].record_id, set()).add( - (relation.output_role, source_owner, source_role) - ) - if len(sources) == 1: - kernel_covered_predecessors.add(sources[0].record_id) - can_transfer = ( - relation.event in {"preserved", "modified"} - and len(relation.result_values) == 1 - and len(sources) == 1 - and len(outputs) == 1 - ) - if can_transfer: - source_id, result_id = sources[0].record_id, outputs[0].record_id - candidate_sources.setdefault(result_id, set()).add(source_id) - relation_links.append((source_id, result_id)) - else: - relation_links.append(None) - evidence.append(item) - if topology_delta.section_values: - section_outputs = [ - record for record in current - if record.kind == "edge" - and any(cls._same_topology_value(record.value, value) for value in topology_delta.section_values) - ] - evidence.append({ - "event": "generated", - "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", - "section_edge": True, - "status": "recorded_section_edge", - }) - predecessors = { - result_id: next(iter(source_ids)) - for result_id, source_ids in candidate_sources.items() - if len(source_ids) == 1 - } - successors: dict[str, list[str]] = {} - for result_id, source_id in predecessors.items(): - successors.setdefault(source_id, []).append(result_id) - for item, relation, link in zip(evidence, topology_delta.relations, relation_links): - if link is not None: - _source_id, result_id = link - item["status"] = ( - "unique_exact_continuation" - if len(candidate_sources[result_id]) == 1 else "ambiguous_exact_continuation" - ) - elif relation.event in {"preserved", "modified"}: - item["status"] = "non_unique_or_incomplete" - else: - item["status"] = "recorded_without_owner_transfer" - if item["coverage"] != "complete" or relation.status != "proven": - item["lineage_status"] = "unknown" - return ( - predecessors, - successors, - kernel_covered_predecessors, - {record_id: tuple(sorted(roles)) for record_id, roles in output_roles.items()}, - {record_id: tuple(sorted(sources)) for record_id, sources in output_role_sources.items()}, - evidence, - ) - - 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.""" - pending = [source_record_id] - visited = {source_record_id} - result_ids: set[str] = set() - 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 - for record_id in edge.result_record_ids: - 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 - - @staticmethod - def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: - try: - return abs(float(left) - float(right)) <= tolerance - except (TypeError, ValueError): - return False - - @classmethod - def _vectors_equal(cls, left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: - try: - first = _vector3(left, field_name="prior topology geometry") - second = _vector3(right, field_name="current topology geometry") - except ValueError: - return False - return all(abs(a - b) <= tolerance for a, b in zip(first, second)) - - @classmethod - def _geometry_equivalent(cls, prior: TopologyRecord, current: TopologyRecord) -> bool: - """Check a complete, orientation-aware snapshot signature. - - This is intentionally much stricter than selector scoring. Selector - scoring may compare partial source evidence; provenance transfer must - never manufacture ownership from a merely similar candidate. - """ - if prior.kind != current.kind: - return False - left, right = prior.geometry, current.geometry - for key in ("surface_type", "curve_type"): - if left.get(key) != right.get(key): - return False - for key in ("bbox_mm", "center_mm", "normal", "plane_normal"): - if key in left or key in right: - if key not in left or key not in right: - return False - left_value, right_value = left[key], right[key] - if key == "bbox_mm": - if not isinstance(left_value, (list, tuple)) or not isinstance(right_value, (list, tuple)): - return False - if len(left_value) != 6 or len(right_value) != 6: - return False - if not all(cls._numbers_equal(a, b) for a, b in zip(left_value, right_value)): - return False - elif not cls._vectors_equal(left_value, right_value): - return False - for key in ("area_mm2", "length_mm", "plane_offset_mm"): - if key in left or key in right: - if key not in left or key not in right or not cls._numbers_equal(left[key], right[key]): - return False - for key in ("adjacency_signature", "adjacent_face_count", "incident_edge_count"): - if key in left or key in right: - if key not in left or key not in right or left[key] != right[key]: - return False - left_start, left_end = left.get("start_mm"), left.get("end_mm") - right_start, right_end = right.get("start_mm"), right.get("end_mm") - if any(value is not None for value in (left_start, left_end, right_start, right_end)): - if None in (left_start, left_end, right_start, right_end): - return False - same_direction = cls._vectors_equal(left_start, right_start) and cls._vectors_equal(left_end, right_end) - reverse_direction = cls._vectors_equal(left_start, right_end) and cls._vectors_equal(left_end, right_start) - if not same_direction and not reverse_direction: - return False - return True - - @classmethod - def _unique_equivalent_predecessor( - cls, - current: TopologyRecord, - predecessors: Iterable[TopologyRecord], - consumed_predecessors: set[str], - ) -> TopologyRecord | None: - matches = [ - record for record in predecessors - if record.record_id not in consumed_predecessors and cls._geometry_equivalent(record, current) - ] - return matches[0] if len(matches) == 1 else None - - @staticmethod - def _evolved_drift(prior: TopologyRecord, current: TopologyRecord) -> float | None: - """Endpoint drift between direction-aligned straight edges. - - Returns the minimum total endpoint drift (mm) when the two edges are - collinear straight lines (either orientation), otherwise ``None``. - """ - if prior.kind != current.kind: - return None - left, right = prior.geometry, current.geometry - if left.get("curve_type") != "line" or right.get("curve_type") != "line": - return None - if None in (left.get("start_mm"), left.get("end_mm"), right.get("start_mm"), right.get("end_mm")): - return None - - def _delta(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]: - return (b[0] - a[0], b[1] - a[1], b[2] - a[2]) - - def _dist(a: tuple[float, float, float], b: tuple[float, float, float]) -> float: - return sqrt(sum((a[i] - b[i]) ** 2 for i in range(3))) - - left_dir = _delta(left["start_mm"], left["end_mm"]) - right_dir = _delta(right["start_mm"], right["end_mm"]) - if _length(left_dir) <= 1e-9 or _length(right_dir) <= 1e-9: - return None - cross = ( - left_dir[1] * right_dir[2] - left_dir[2] * right_dir[1], - left_dir[2] * right_dir[0] - left_dir[0] * right_dir[2], - left_dir[0] * right_dir[1] - left_dir[1] * right_dir[0], - ) - if _length(cross) / (_length(left_dir) * _length(right_dir)) > 1e-3: - return None - same_order = _dist(left["start_mm"], right["start_mm"]) + _dist(left["end_mm"], right["end_mm"]) - reversed_order = _dist(left["start_mm"], right["end_mm"]) + _dist(left["end_mm"], right["start_mm"]) - return min(same_order, reversed_order) - - @classmethod - def _evolved_equivalent(cls, prior: TopologyRecord, current: TopologyRecord, *, drift_mm: float = 5.0) -> bool: - """Loose "position trajectory" equivalence used for evolved successors. - - Unlike ``_geometry_equivalent`` (strict, anti-false-positive provenance), - this deliberately tolerates small endpoint drift: fillet/chamfer split a - straight edge into segments (a middle straight run plus end arcs). The - straight run keeps the same direction and stays within ``drift_mm`` of the - original edge, so it can serve as the edge's evolved successor. Uniqueness - is enforced by the caller (only a single best candidate is recorded). - """ - drift = cls._evolved_drift(prior, current) - return drift is not None and drift <= drift_mm - - @staticmethod - def _vector_score(expected: Any, actual: Any, tolerance: float = 1e-4) -> float | None: - try: - left = _vector3(expected, field_name="selector geometry") - right = _vector3(actual, field_name="record geometry") - except ValueError: - return None - error = _length(tuple(a - b for a, b in zip(left, right))) - return max(0.0, 1.0 - error / tolerance) - - @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"): - if key in selector_geometry: - score = cls._vector_score(selector_geometry[key], record_geometry.get(key)) - if score is None: - return None - scores.append(score) - for key in ("surface_type", "curve_type"): - if key in selector_geometry: - if record_geometry.get(key) != selector_geometry[key]: - # #8 selector 持久性:fillet/chamfer 会把直线边演化为圆弧、 - # 平面演化为柱面,但被选中拓扑的位置锚定(bbox/center/端点) - # 不变。曲线/曲面类型变化不再一票否决,而是记低分:位置完全 - # 重合的候选(同一条边的形态演化)仍可胜出;位置不重合的 - # 相邻边会被 0 分项拉低,仍被 minimum_score 挡住。 - scores.append(0.5) - else: - scores.append(1.0) - if "bbox_mm" in selector_geometry: - expected = selector_geometry["bbox_mm"] - actual = record_geometry.get("bbox_mm") - if not isinstance(expected, list) or not isinstance(actual, list) or len(expected) != len(actual): - return None - delta = max(abs(float(a) - float(b)) for a, b in zip(expected, actual)) - 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"))) - except (TypeError, ValueError): - return None - scores.append(max(0.0, 1.0 - delta / 1e-4)) - if "radius_mm" in selector_geometry: - try: - delta = abs(float(selector_geometry["radius_mm"]) - float(record_geometry.get("radius_mm"))) - except (TypeError, ValueError): - return None - scores.append(max(0.0, 1.0 - delta / 1e-4)) - if "area_mm2" in selector_geometry: - try: - expected_area = float(selector_geometry["area_mm2"]) - actual_area = float(record_geometry.get("area_mm2")) - except (TypeError, ValueError): - 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)) - return sum(scores) / len(scores) if scores else 0.0 - - def resolve( - self, - selector: dict[str, Any], - *, - minimum_score: float = 0.8, - active_body_id: str | None = None, - ) -> SelectorResolution: - 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] - if active_body_id and kind in {"face", "edge", "vertex", "body"}: - # #7 multi-body:记录 body_id 可能是 body:{feature}:{index}(多体 - # 成员),用前缀匹配把整个主体的记录纳入候选,同时保证旧 body 的 - # 记录(不同 feature 前缀)不会泄漏进来。 - candidates = [ - record for record in candidates - 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: - 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)时, - # mirror_plane 的 plane 引用由运行时随实例变换后内联为显式 frame - # (_translated_node / _mirrored_node),这里直接构造 PlaneSpec, - # 不再走 stable_id / 几何匹配,避免解析到未随实例变换的原始面。 - try: - plane = PlaneSpec.from_mapping(selector.get("frame") or {}) - except (TypeError, ValueError): - plane = None - if plane is not None: - record = TopologyRecord( - record_id=f"inline:{id(plane)}", - kind="plane", - feature_id=str(owner or "inline"), - geometry=plane.as_dict(), - value=plane, - ) - return SelectorResolution( - selector=selector, - status="resolved", - record=record, - candidates=({"score": 1.0, **record.public_dict()},), - ) - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_frame_incomplete", - message="An inline plane frame requires origin_mm, x_dir and normal", - detail={"frame": selector.get("frame")}, - ), - ) - geometry = normalize_selector_geometry(selector.get("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}, - ), - ) - output_role = str(selector.get("output_role") or "").strip() - if output_role: - if not owner: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_owner_required", - message="A feature output role selector requires owner_feature_id", - detail={"output_role": output_role}, - ), - ) - if active_body_id is None: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_active_body_required", - message="A feature output role selector requires an active body snapshot", - detail={"output_role": output_role}, - ), - ) - if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")): - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_mixed_evidence", - message="A feature output role selector cannot mix stable or geometry evidence", - detail={"output_role": output_role}, - ), - ) - role_candidates = [record for record in candidates if output_role in record.output_roles] - role_source = selector.get("output_role_source") - if role_source is not None: - source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None - source_role = role_source.get("output_role") if isinstance(role_source, dict) else None - if not isinstance(source_owner, str) or not isinstance(source_role, str): - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_source_invalid", - message="An output role selector source requires owner_feature_id and output_role", - ), - ) - if output_role != "shell.offset_face" or source_role not in {"extrude.start", "extrude.end"}: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_source_unsupported", - message="Output role sources are currently supported only for shell.offset_face from an extrusion cap", - ), - ) - role_candidates = [ - record for record in role_candidates - if (output_role, source_owner, source_role) in record.output_role_sources - ] - public_candidates = tuple( - {"score": 1.0, **record.public_dict()} for record in role_candidates - ) - if len(role_candidates) == 1: - return SelectorResolution( - selector=selector, - status="resolved", - record=role_candidates[0], - candidates=public_candidates, - ) - if len(role_candidates) > 1: - return SelectorResolution( - selector=selector, - status="ambiguous", - candidates=public_candidates, - diagnostic=RuntimeDiagnostic( - code="selector_output_role_ambiguous", - message="More than one active topology record has the requested output role", - detail={"output_role": output_role, "candidate_count": len(role_candidates)}, - ), - ) - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_not_found", - message="No active topology record has the requested output role", - detail={"output_role": output_role, "candidate_count": 0}, - ), - ) - stable_id = str(selector.get("stable_id") or "").strip() - if stable_id: - # #8 selector 持久性:stable_id 是跨 body 演化的持久标识符,精确 - # 匹配在 active body 过滤之前对整个记录集(kind + owner 过滤)执行。 - # 命中已过期(旧 body)的记录时,经演化后继映射解析到 active body - # 内的新形态(fillet/chamfer 拆段后的直段后继);无后继则回落到 - # 几何打分流程。 - stable_records = [ - record for record in self._records - if record.kind == kind 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] - 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}:")) - ) - if not is_active and provenance_intent: - policy = intent.get("derivation_policy") or {} - allowed = { - value for value in policy.get("allowed") or () - if value in {"continuation", "fragment", "merge", "intersection", "boundary", "replacement"} - } - successors = self._intent_lineage_successors( - record.record_id, - allowed=allowed, - active_body_id=active_body_id, - ) - if len(successors) == 1: - record = successors[0] - is_active = True - elif len(successors) > 1: - if policy.get("multiplicity") == "all_fragments" and "fragment" in allowed: - return SelectorResolution( - selector=selector, - status="resolved", - records=tuple(successors), - candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), - ) - return SelectorResolution( - selector=selector, - status="ambiguous", - candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), - diagnostic=RuntimeDiagnostic( - code="selector_relation_non_unique", - message="The proven topology lineage has more than one active result", - detail={"stable_id": stable_id, "candidate_count": len(successors), "multiplicity": policy.get("multiplicity")}, - ), - ) - else: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_kernel_history_missing", - message="No complete proven lineage reaches an active topology record", - detail={"stable_id": stable_id, "allowed": sorted(allowed)}, - ), - ) - if not is_active: - successors = [ - candidate for candidate in stable_records - if candidate.record_id in self._successors.get(record.record_id, ()) - and ( - candidate.body_id == active_body_id - or (candidate.body_id is not None and active_body_id and candidate.body_id.startswith(f"{active_body_id}:")) - ) - ] - if len(successors) == 1: - record = successors[0] - is_active = True - elif len(successors) > 1: - return SelectorResolution( - selector=selector, - status="ambiguous", - candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), - diagnostic=RuntimeDiagnostic( - code="selector_ambiguous", - message="More than one evolved successor record satisfies the stable_id", - detail={"stable_id": stable_id, "candidate_count": len(successors)}, - ), - ) - if is_active: - # A stable ID is only a lookup accelerator for snapshot-aware - # selectors. It cannot revive a B-rep entity whose geometric - # signature changed after an upstream rebuild. - if selector.get("snapshot_id"): - score = self._geometry_score(geometry, record.geometry) if geometry else None - if score is None or score < minimum_score: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=({"score": round(float(score or 0), 6), **record.public_dict()},), - diagnostic=RuntimeDiagnostic( - code="selector_geometry_mismatch", - message="The stable selector record no longer matches its geometry signature", - detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score}, - ), - ) - return SelectorResolution( - selector=selector, - status="resolved", - record=record, - candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},), - ) - if not geometry: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_stable_id_inactive", - message="The stable selector record is not active and has no geometry signature for rebinding", - detail={"stable_id": stable_id}, - ), - ) - if len(exact) > 1: - return SelectorResolution( - selector=selector, - status="ambiguous", - candidates=tuple({"score": 1.0, **record.public_dict()} for record in exact), - diagnostic=RuntimeDiagnostic( - code="selector_ambiguous", - message="More than one runtime topology record has the requested stable_id", - detail={"stable_id": stable_id, "candidate_count": len(exact)}, - ), - ) - if selector.get("snapshot_id") and not geometry: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_geometry_mismatch", - message="A snapshot selector requires a geometry signature", - detail={"minimum_score": minimum_score}, - ), - ) - 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")}, - ), - ) - scored: list[tuple[float, TopologyRecord]] = [] - for candidate in candidates: - # An owner-qualified context selector is deterministic when it has - # a single runtime candidate even if its source stable_id cannot - # survive the SolidWorks -> OCC boundary. - score = 1.0 if not geometry else self._geometry_score(geometry, candidate.geometry) - if score is not None: - scored.append((score, candidate)) - scored.sort(key=lambda item: (-item[0], item[1].record_id)) - public_candidates = tuple({"score": round(score, 6), **record.public_dict()} for score, record in scored) - if not scored or scored[0][0] < minimum_score: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=public_candidates, - diagnostic=RuntimeDiagnostic( - code="selector_not_found", - message="No runtime topology record satisfies the selector", - detail={"candidate_count": len(scored), "minimum_score": minimum_score}, - ), - ) - best_score, best_record = scored[0] - if len(scored) > 1 and abs(scored[1][0] - best_score) <= 1e-9: - return SelectorResolution( - selector=selector, - status="ambiguous", - candidates=public_candidates, - diagnostic=RuntimeDiagnostic( - code="selector_ambiguous", - message="More than one runtime topology record has the best selector score", - detail={"best_score": best_score, "candidate_count": len(scored)}, - ), - ) - return SelectorResolution(selector=selector, status="resolved", record=best_record, candidates=public_candidates) +from .specs import ( + AxisSpec, + BendLeg, + BendSpec, + GearSpec, + HoleSpec, + PlaneSpec, + RackSpec, + ThreadSpec, + Vector3, + canonical_plane_signature, + normalize_selector_geometry, + pattern_instance_member_id, + transform_copy_member_id, + vector_add, + vector_cross, + vector_dot, + vector_scale, + vector_subtract, + vector_unit, +) +from .topology import ( + CapabilityResult, + FeaturePlanNode, + FeatureResult, + RuntimeDiagnostic, + SelectorResolution, + TopologyDelta, + TopologyDeltaRelation, + TopologyLineage, + TopologyRecord, + TopologyRegistry, +) + +__all__ = [ + "AxisSpec", + "BendLeg", + "BendSpec", + "CapabilityResult", + "FeaturePlanNode", + "FeatureResult", + "GearSpec", + "HoleSpec", + "PlaneSpec", + "RackSpec", + "RuntimeDiagnostic", + "SelectorResolution", + "ThreadSpec", + "TopologyDelta", + "TopologyDeltaRelation", + "TopologyLineage", + "TopologyRecord", + "TopologyRegistry", + "Vector3", + "canonical_plane_signature", + "normalize_selector_geometry", + "pattern_instance_member_id", + "transform_copy_member_id", + "vector_add", + "vector_cross", + "vector_dot", + "vector_scale", + "vector_subtract", + "vector_unit", +] diff --git a/backend/engine/cdsl_engine/semantic_validation.py b/backend/engine/cdsl_engine/semantic_validation.py index 81d8ba17..3223a6db 100644 --- a/backend/engine/cdsl_engine/semantic_validation.py +++ b/backend/engine/cdsl_engine/semantic_validation.py @@ -242,6 +242,10 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: and id(selector) not in output_role_selector_ids and id(selector) not in selector_intent_ids ): + if contract.get("selector_slot") == "feature.selectors": + raise ValueError( + f"Feature {fid} output role selectors are only supported in feature.selectors" + ) raise ValueError(f"Feature {fid} output role selector is outside its operation contract slot") if feature.get("atomic_id") == "shell": target_feature_id = (feature.get("params") or {}).get("target_feature_id") diff --git a/backend/engine/cdsl_engine/session.py b/backend/engine/cdsl_engine/session.py new file mode 100644 index 00000000..7b8c1775 --- /dev/null +++ b/backend/engine/cdsl_engine/session.py @@ -0,0 +1,279 @@ +"""Execution session state and the geometry adapter boundary. + +``ExecutionSession`` owns the active body, body-member graph, replay +definitions, and selector-resolution evidence. ``GeometryAdapter`` is the +kernel-facing protocol the session consumes; geometry values stay opaque so a +different B-rep backend can replace build123d without touching the runtime. +""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any, Protocol + +from .build123d_adapter import Build123dGeometryAdapter +from .runtime_base import FeatureExecutionError +from .specs import AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec, Vector3 +from .topology import ( + FeaturePlanNode, + FeatureResult, + RuntimeDiagnostic, + SelectorResolution, + TopologyDelta, + TopologyRecord, + TopologyRegistry, +) + + +class GeometryAdapter(Protocol): + """Kernel boundary consumed by the session runtime. + + Geometry values remain opaque here. A future adapter may use a different + B-rep kernel as long as it preserves these construction/query contracts. + """ + + def topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ... + def body_solids(self, body: Any) -> list[Any]: ... + def body_geometry(self, body: Any) -> dict[str, Any]: ... + def surface_geometry(self, surface: Any) -> dict[str, Any]: ... + def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ... + def face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ... + def loft(self, sketches: list[dict[str, Any]]) -> Any: ... + def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Any, TopologyDelta | None]: ... + def loft_with_cap_face(self, cap_face: Any, sketches: list[dict[str, Any]]) -> Any: ... + def sweep(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> Any: ... + def sweep_with_topology_delta(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> tuple[Any, TopologyDelta | None]: ... + def sweep_path(self, points: list[Vector3], *, start_tangent: Vector3 | None = None, end_tangent: Vector3 | None = None, parameters: list[float] | None = None) -> Any: ... + def face_normal(self, face: Any) -> Vector3: ... + def extrude(self, face: Any, direction: Vector3) -> Any: ... + def extrude_with_topology_delta(self, face: Any, direction: Vector3) -> tuple[Any, TopologyDelta]: ... + def extrude_taper_with_topology_delta(self, face: Any, direction: Vector3, taper_deg: float) -> tuple[Any, TopologyDelta | None]: ... + def extrude_taper(self, face: Any, direction: Vector3, taper_deg: float) -> Any: ... + def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ... + def surface_wires_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ... + def extrude_surface(self, wires: list[Any], direction: Vector3) -> Any: ... + def combine_surfaces(self, *surfaces: Any) -> Any: ... + def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ... + def revolve_surface(self, wire: Any, angle_deg: float, axis: AxisSpec) -> Any: ... + def intersect(self, left: Any, right: Any) -> Any: ... + def intersect_with_topology_delta(self, left: Any, right: Any) -> tuple[Any, TopologyDelta | None]: ... + def transform(self, body: Any, transform: dict[str, Any]) -> Any: ... + def transform_with_topology_delta(self, body: Any, transform: dict[str, Any]) -> tuple[Any, TopologyDelta]: ... + def fuse(self, body: Any | None, solid: Any) -> Any: ... + def fuse_with_topology_delta(self, body: Any | None, solid: Any) -> tuple[Any, TopologyDelta | None]: ... + def combine(self, body: Any | None, solid: Any) -> Any: ... + def cut(self, body: Any, tool: Any) -> Any: ... + def cut_with_topology_delta(self, body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]: ... + def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ... + def cylinder_with_topology_delta(self, radius_mm: float, height_mm: float, axis: AxisSpec | None = None) -> tuple[Any, TopologyDelta]: ... + def thread_solid(self, spec: ThreadSpec) -> Any: ... + def bend_solid(self, spec: BendSpec) -> Any: ... + def gear_solid(self, spec: GearSpec) -> Any: ... + def rack_solid(self, spec: RackSpec) -> Any: ... + def hole_tool(self, spec: HoleSpec, starts: list[Vector3], inward: Vector3, through_depth_mm: float) -> Any: ... + def body_center(self, body: Any) -> Vector3: ... + def body_span(self, body: Any, direction: Vector3) -> float: ... + def vertex_coordinates(self, vertex: Any) -> Vector3: ... + def intersection_vertex(self, body: Any, face_sets: list[list[Any]]) -> Any: ... + def profile_sample_points(self, face: Any) -> list[Any]: ... + def profile_touches_target(self, target: Any, faces: list[Any]) -> bool: ... + def next_body_face_after(self, body: Any, faces: list[Any], direction: Vector3, *, excluded_face: Any) -> Any: ... + def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ... + def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ... + def fillet_with_topology_delta(self, body: Any, radius_mm: float, edges: list[Any]) -> tuple[Any, TopologyDelta | None]: ... + def tangent_edges(self, body: Any, seeds: list[Any]) -> list[Any]: ... + def chamfer(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> Any: ... + def chamfer_with_topology_delta(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> tuple[Any, TopologyDelta | None]: ... + def surface_limited_chamfer(self, body: Any, distance_mm: float, edges: list[Any], surfaces: list[Any]) -> Any: ... + def shell(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> Any: ... + def shell_with_topology_delta(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> tuple[Any, TopologyDelta]: ... + def export(self, body: Any, path: str) -> None: ... + + +@dataclass +class ExecutionSession: + sketches: dict[str, dict[str, Any]] + nodes: dict[str, FeaturePlanNode] + adapter: GeometryAdapter = field(default_factory=Build123dGeometryAdapter) + topology: TopologyRegistry = field(default_factory=TopologyRegistry) + body: Any | None = None + body_id: str | None = None + results: dict[str, FeatureResult] = field(default_factory=dict) + replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict) + body_members: dict[str, Any] = field(default_factory=dict) + surface_members: dict[str, Any] = field(default_factory=dict) + selector_resolutions: list[dict[str, Any]] = field(default_factory=list) + active_feature_id: str = "" + + def register_body( + self, + feature_id: str, + body: Any, + *, + replay_node: FeaturePlanNode | None = None, + body_members: dict[str, Any] | None = None, + topology_delta: TopologyDelta | None = None, + topology_predecessors: list[TopologyRecord] | None = None, + ) -> None: + # #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的 + # 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id": + # 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标 + # 实体;单体保持 body:{feature}(与历史行为完全一致)。 + self.body = body + self.body_id = f"body:{feature_id}" + self.body_members = dict(body_members) if body_members is not None else {feature_id: body} + solids = self.adapter.body_solids(body) + if len(solids) <= 1: + self.topology.replace_body_topology( + feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id), + topology_delta=topology_delta, + additional_predecessors=topology_predecessors or (), + ) + else: + # 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让 + # 已登记的本轮成员成为下一个成员的 predecessor,进而把 pattern copy + # 的 owner 错误转移到相邻实例。必须原子替换整个多 body 拓扑快照。 + members = [ + (member_id, self.adapter.topology_records(solid, feature_id, member_id)) + for index, solid in enumerate(solids) + for member_id in [f"{self.body_id}:{index}"] + ] + self.topology.replace_body_topologies( + feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta, + additional_predecessors=topology_predecessors or (), + ) + self.topology.register(TopologyRecord( + record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id, + geometry=self.adapter.body_geometry(body), value=body, owner_feature_ids=(feature_id,), + )) + if replay_node is not None: + self.replay_definitions[feature_id] = replay_node + + def register_surface(self, feature_id: str, surface: Any) -> str: + # 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body, + # 否则 surface 会覆盖 active solid 并改变最终 STEP 的实体结果。 + surface_id = f"surface:{feature_id}" + self.surface_members[feature_id] = surface + for record in self.adapter.topology_records(surface, feature_id, surface_id): + self.topology.register(record) + self.topology.register(TopologyRecord( + record_id=surface_id, kind="surface", feature_id=feature_id, body_id=surface_id, + geometry=self.adapter.surface_geometry(surface), value=surface, owner_feature_ids=(feature_id,), + )) + return surface_id + + def clear_body(self) -> None: + """Clear the active solid after an explicit deleteBodies result.""" + self.body = None + self.body_id = None + self.body_members = {} + + def _record_selector_resolution(self, resolution: SelectorResolution) -> SelectorResolution: + evidence = resolution.as_dict() + evidence["feature_id"] = self.active_feature_id + self.selector_resolutions.append(evidence) + return resolution + + def _intersection_component_records(self, selector: dict[str, Any]) -> list[TopologyRecord]: + matched = selector.get("matched_selectors") if selector.get("match_mode") == "all" else None + if matched is not None: + if not isinstance(matched, list) or not matched: + raise FeatureExecutionError("intersection_selector_unbound", "Intersection selector has no bound face matches") + resolved = [self._record_selector_resolution(self.topology.resolve(item, active_body_id=self.body_id)) for item in matched] + else: + binding_feature_id = selector.get("binding_feature_id") + active_body_id = None if binding_feature_id and self.body_id != f"body:{binding_feature_id}" else self.body_id + resolved = [self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))] + failures = [item for item in resolved if item.status != "resolved" or (item.record is None and not item.records)] + if failures: + detail = failures[0].diagnostic.message if failures[0].diagnostic else "intersection selector component was not resolved" + raise FeatureExecutionError("intersection_selector_component_unresolved", detail) + return [ + record + for item in resolved + for record in (item.records or ((item.record,) if item.record is not None else ())) + ] + + def _resolve_intersection_vertex(self, selector: dict[str, Any]) -> SelectorResolution: + components = selector.get("intersection_of") + if self.body is None: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic("missing_extent_body", "Intersection selector requires an existing body"), + ) + if not isinstance(components, list) or len(components) < 2: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic("intersection_selector_incomplete", "Intersection selector requires at least two face components"), + ) + try: + face_sets = [self._intersection_component_records(component) for component in components] + if any(record.kind != "face" for records in face_sets for record in records): + raise FeatureExecutionError("intersection_selector_kind", "Intersection selector components must resolve to faces") + vertex = self.adapter.intersection_vertex(self.body, [[record.value for record in records] for records in face_sets]) + except FeatureExecutionError as error: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic(error.code, str(error), detail=error.detail), + ) + except ValueError as error: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic("intersection_vertex_unresolved", str(error)), + ) + point = self.adapter.vertex_coordinates(vertex) + record = TopologyRecord( + record_id=str(selector.get("stable_id") or f"intersection:{id(vertex)}"), + kind="vertex", feature_id=self.active_feature_id, body_id=self.body_id, + geometry={"center_mm": list(point)}, value=vertex, + owner_feature_ids=tuple(filter(None, [str(selector.get("owner_feature_id") or "")])), + ) + return SelectorResolution( + selector=selector, status="resolved", record=record, + candidates=({"score": 1.0, **record.public_dict()},), + ) + + def resolve(self, selector: dict[str, Any]) -> SelectorResolution: + if selector.get("intersection_of") is not None: + return self._record_selector_resolution(self._resolve_intersection_vertex(selector)) + owner = str(selector.get("owner_feature_id") or "") + active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id + return self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id)) + + def result( + self, + node: FeaturePlanNode, + *, + context: PlaneSpec | AxisSpec | None = None, + diagnostics: list[RuntimeDiagnostic] | None = None, + include_body: bool = True, + surface_id: str | None = None, + ) -> FeatureResult: + result = FeatureResult( + feature_id=node.feature_id, atomic_id=node.atomic_id, status="executed", + body_id=self.body_id if include_body else None, surface_id=surface_id, + context=context, replay_definition={"atomic_id": node.atomic_id, "params": deepcopy(node.params), "sketch_id": node.sketch_id}, + diagnostics=diagnostics or [], + ) + self.results[node.feature_id] = result + return result + + def replay_sources(self, source_feature_ids: list[Any]) -> list[FeaturePlanNode]: + """Return selected source features in their original history order. + + A pattern's exported selection order is not an execution order. In + particular, a boolean cut may appear before its parent boss in the + raw selection array. The CDSL feature list is dependency-ordered by + semantic validation, so it is the stable order for replay. + """ + requested = {str(feature_id) for feature_id in source_feature_ids} + sources = [ + feature + for feature_id, feature in self.nodes.items() + if feature_id in requested and feature_id in self.replay_definitions + ] + if len(sources) != len(requested): + missing = sorted(requested - {source.feature_id for source in sources}) + raise ValueError(f"pattern source features have no replay definitions: {', '.join(missing)}") + return sources diff --git a/backend/engine/cdsl_engine/specs.py b/backend/engine/cdsl_engine/specs.py new file mode 100644 index 00000000..30a9a29d --- /dev/null +++ b/backend/engine/cdsl_engine/specs.py @@ -0,0 +1,619 @@ +"""Runtime-neutral CDSL vector math, frames, and parametric feature specs. + +This module deliberately has no build123d dependency. The spec dataclasses +and canonical vector/plane helpers can therefore be used by validation, batch +reporting, and any geometry adapter without importing OCC objects. +""" + +from __future__ import annotations + +import warnings +from dataclasses import dataclass +from math import cos, pi, radians, sqrt +from typing import Any + + +Vector3 = tuple[float, float, float] + + +def pattern_instance_member_id(pattern_feature_id: str, source_feature_id: str, instance_index: int) -> str: + """Return the runtime-only body-member key for one proven pattern copy. + + CDSL keeps the three source fields separately, so callers never need to + manufacture this internal key. The body graph uses the same derivation in + runtime and capability preflight. + """ + return f"pattern:{pattern_feature_id}:{source_feature_id}:copy:{instance_index}" + + +def transform_copy_member_id(transform_feature_id: str, source_member_id: str) -> str: + """Return the runtime-only key for one source of a multi-body COPY. + + A multi-source ``transform_bodies`` COPY has several independently + addressable outputs. CDSL records the transform and its selected source as + separate fields; the opaque key stays internal to the body graph. + """ + return f"transform:{transform_feature_id}:{source_member_id}:copy" + +# y_dir 与 x_dir / normal 点积的绝对值不超过该值时,认为 y_dir 是正交的, +# 予以保留;否则视为偏斜数据,正交化并显式警告。 +_Y_DIR_ORTHOGONALITY_TOL = 1e-6 + + +def _vector3(value: Any, *, field_name: str) -> Vector3: + if not isinstance(value, (list, tuple)) or len(value) != 3: + raise ValueError(f"{field_name} must contain three coordinates") + try: + return (float(value[0]), float(value[1]), float(value[2])) + except (TypeError, ValueError) as error: + raise ValueError(f"{field_name} must contain numeric coordinates") from error + + +def _length(value: Vector3) -> float: + return sqrt(sum(component * component for component in value)) + + +def _unit(value: Vector3, *, field_name: str) -> Vector3: + magnitude = _length(value) + if magnitude <= 1e-12: + raise ValueError(f"{field_name} must be non-zero") + return tuple(component / magnitude for component in value) # type: ignore[return-value] + + +def _dot(left: Vector3, right: Vector3) -> float: + return sum(a * b for a, b in zip(left, right)) + + +def _cross(left: Vector3, right: Vector3) -> Vector3: + return ( + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ) + + +def vector_add(left: Vector3, right: Vector3) -> Vector3: + return tuple(a + b for a, b in zip(left, right)) # type: ignore[return-value] + + +def vector_subtract(left: Vector3, right: Vector3) -> Vector3: + return tuple(a - b for a, b in zip(left, right)) # type: ignore[return-value] + + +def vector_scale(value: Vector3, factor: float) -> Vector3: + return tuple(component * factor for component in value) # type: ignore[return-value] + + +def vector_dot(left: Vector3, right: Vector3) -> float: + return _dot(left, right) + + +def vector_cross(left: Vector3, right: Vector3) -> Vector3: + return _cross(left, right) + + +def vector_unit(value: Vector3, *, field_name: str = "vector") -> Vector3: + return _unit(value, field_name=field_name) + + +def canonical_plane_signature(normal: Vector3, point_mm: Vector3) -> tuple[Vector3, float]: + """Normalize a plane sign so source and OCC face orientations compare.""" + unit_normal = _unit(normal, field_name="plane.normal") + offset = _dot(unit_normal, point_mm) + for component in unit_normal: + if abs(component) <= 1e-12: + continue + if component < 0: + unit_normal = tuple(-value for value in unit_normal) # type: ignore[assignment] + offset = -offset + break + return unit_normal, offset + + +def normalize_selector_geometry(geometry: Any) -> dict[str, Any]: + """Convert legacy SolidWorks selector evidence into runtime-neutral units. + + Current CDSL records may already carry ``*_mm`` fields. Older exported + evidence instead stores SolidWorks surface parameters, boxes, and areas in + SI units. The selector remains the source of truth; this function only + makes its geometric signature comparable to an OCC topology snapshot. + """ + if not isinstance(geometry, dict): + return {} + result = dict(geometry) + surface = geometry.get("surface") + if isinstance(surface, dict): + surface_type = str(surface.get("type") or "").lower() + if surface_type: + result.setdefault("surface_type", surface_type) + parameters = surface.get("parameters") + if surface_type == "plane" and isinstance(parameters, list) and len(parameters) >= 6: + try: + raw_normal = _vector3(parameters[:3], field_name="selector surface normal") + # SolidWorks evidence uses metres for surface locations. + point_mm = tuple(float(value) * 1000.0 for value in parameters[3:6]) + plane_normal, plane_offset = canonical_plane_signature(raw_normal, point_mm) # type: ignore[arg-type] + result.setdefault("plane_normal", list(plane_normal)) + result.setdefault("plane_offset_mm", plane_offset) + except (TypeError, ValueError): + pass + curve = geometry.get("curve") + if isinstance(curve, dict) and curve.get("type"): + result.setdefault("curve_type", str(curve["type"]).lower()) + raw_box = geometry.get("box") + if isinstance(raw_box, list) and len(raw_box) == 6: + try: + result.setdefault("bbox_mm", [float(value) * 1000.0 for value in raw_box]) + except (TypeError, ValueError): + pass + raw_area = geometry.get("area") + if raw_area is not None: + try: + result.setdefault("area_mm2", float(raw_area) * 1_000_000.0) + except (TypeError, ValueError): + pass + for raw_key, normalized_key in (("start", "start_mm"), ("end", "end_mm")): + value = geometry.get(raw_key) + if isinstance(value, list) and len(value) == 3: + try: + result.setdefault(normalized_key, [float(component) * 1000.0 for component in value]) + except (TypeError, ValueError): + pass + return result + + +@dataclass(frozen=True) +class AxisSpec: + """Canonical axis with a normalized direction.""" + + origin_mm: Vector3 + direction: Vector3 + + @classmethod + def from_mapping(cls, value: dict[str, Any]) -> "AxisSpec": + return cls( + origin_mm=_vector3(value.get("origin_mm"), field_name="axis.origin_mm"), + direction=_unit(_vector3(value.get("direction"), field_name="axis.direction"), field_name="axis.direction"), + ) + + def as_dict(self) -> dict[str, list[float]]: + return {"origin_mm": list(self.origin_mm), "direction": list(self.direction)} + + +@dataclass(frozen=True) +class PlaneSpec: + """Canonical right-handed plane frame. + + SolidWorks exports may contain a redundant or non-orthogonal y direction. + The runtime persists the orthonormalized frame so later features all use + the same coordinate system. + """ + + origin_mm: Vector3 + x_dir: Vector3 + y_dir: Vector3 + normal: Vector3 + + @classmethod + def from_mapping(cls, value: dict[str, Any]) -> "PlaneSpec": + origin = _vector3(value.get("origin_mm"), field_name="plane.origin_mm") + normal = _unit(_vector3(value.get("normal"), field_name="plane.normal"), field_name="plane.normal") + x_raw = _vector3(value.get("x_dir"), field_name="plane.x_dir") + projected_x = tuple(x_raw[index] - _dot(x_raw, normal) * normal[index] for index in range(3)) + x_dir = _unit(projected_x, field_name="plane.x_dir") + generated = _unit(_cross(normal, x_dir), field_name="plane.y_dir") + y_raw = value.get("y_dir") + if y_raw is None: + # y_dir 缺失:用 normal × x_dir 补全右手系(默认行为)。 + y_dir = generated + else: + y_vec = _unit(_vector3(y_raw, field_name="plane.y_dir"), field_name="plane.y_dir") + if abs(_dot(y_vec, x_dir)) <= _Y_DIR_ORTHOGONALITY_TOL and abs(_dot(y_vec, normal)) <= _Y_DIR_ORTHOGONALITY_TOL: + # 输入 y_dir 与 x_dir / normal 正交:尊重文档作者给的坐标方向, + # 不再静默丢弃(SolidWorks 导出的非标准 y_dir 得以保留)。 + y_dir = y_vec + else: + # 偏斜 y_dir:正交化并显式警告,避免"静默丢语义"。 + warnings.warn( + f"plane y_dir {list(y_vec)} is not orthogonal to x_dir/normal; re-orthogonalized to {list(generated)}", + UserWarning, + stacklevel=2, + ) + y_dir = generated + return cls(origin_mm=origin, x_dir=x_dir, y_dir=y_dir, normal=normal) + + def as_dict(self) -> dict[str, list[float]]: + return { + "origin_mm": list(self.origin_mm), + "x_dir": list(self.x_dir), + "y_dir": list(self.y_dir), + "normal": list(self.normal), + } + + +@dataclass(frozen=True) +class HoleSpec: + """Runtime-neutral definition of a cylindrical Hole Wizard operation. + + The spec deliberately contains no OCC planes or shapes. The runtime + resolves the host frame and the adapter turns this definition into a + cutting tool, keeping source-contract parsing separate from B-rep work. + """ + + diameter_mm: float + depth_mm: float + end_condition: str + positions_mm: tuple[Vector3, ...] + countersink: tuple[float, float] | None = None + counterbore: tuple[float, float] | None = None + + @classmethod + def from_feature(cls, atomic_id: str, params: dict[str, Any], *, wizard: bool) -> "HoleSpec": + try: + diameter = float(params.get("diameter_mm") or 0.0) + depth = float(params.get("depth_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError("hole dimensions must be numeric") from error + if diameter <= 0 or depth <= 0: + raise ValueError("hole requires positive diameter_mm and depth_mm") + condition = str((params.get("end_condition") or {"type": "blind"}).get("type") or "blind") + if condition not in {"blind", "through_all", "through_all_both"}: + raise ValueError(f"unsupported hole extent {condition!r}") + raw_positions = params.get("positions") or [] + positions = tuple(_vector3(item.get("mm"), field_name="hole position") for item in raw_positions if isinstance(item, dict)) + if len(positions) != len(raw_positions) or not positions: + raise ValueError("hole requires non-empty positions with three-dimensional mm coordinates") + + raw_sink: dict[str, Any] | None = params.get("countersink") if wizard else None + raw_bore: dict[str, Any] | None = params.get("counterbore") if wizard else None + if atomic_id == "hole_countersink": + raw_sink = {"diameter_mm": params.get("countersink_diameter_mm"), "angle_rad": params.get("countersink_angle_rad")} + if atomic_id == "hole_counterbore": + raw_bore = {"diameter_mm": params.get("counterbore_diameter_mm"), "depth_mm": params.get("counterbore_depth_mm")} + + def dimensions(value: dict[str, Any] | None, second: str, label: str) -> tuple[float, float] | None: + if value is None: + return None + try: + first_value = float(value.get("diameter_mm") or 0.0) + second_value = float(value.get(second) or 0.0) + except (AttributeError, TypeError, ValueError) as error: + raise ValueError(f"{label} dimensions must be numeric") from error + if first_value <= diameter or second_value <= 0: + raise ValueError(f"{label} requires a diameter larger than the main hole and a positive {second}") + return first_value, second_value + + return cls( + diameter_mm=diameter, + depth_mm=depth, + end_condition=condition, + positions_mm=positions, + countersink=dimensions(raw_sink, "angle_rad", "countersink"), + counterbore=dimensions(raw_bore, "depth_mm", "counterbore"), + ) + + +@dataclass(frozen=True) +class ThreadSpec: + """Runtime-neutral definition of a parametric screw thread. + + The spec deliberately contains no OCC planes or shapes. The adapter turns + this definition into a threaded solid segment keeping source-contract + parsing separate from B-rep work. + + ``axis.origin_mm`` anchors the leading end face of the threaded segment and + ``axis.direction`` is the outward thread axis; ``angle_deg`` is the full + flank angle (60 for ISO metric V threads, 30 for trapezoidal leadscrews). + ``internal`` selects the cutting form used by later thread_cut support. + """ + + major_diameter_mm: float + minor_diameter_mm: float + pitch_mm: float + length_mm: float + axis: AxisSpec + angle_deg: float = 60.0 + internal: bool = False + lefthand: bool = False + relief_length_mm: float = 0.0 + crest_radius_mm: float = 0.0 + root_radius_mm: float = 0.0 + + @classmethod + def from_feature(cls, atomic_id: str, params: dict[str, Any]) -> "ThreadSpec": + try: + major = float(params.get("major_diameter_mm") or 0.0) + minor = float(params.get("minor_diameter_mm") or 0.0) + pitch = float(params.get("pitch_mm") or 0.0) + length = float(params.get("length_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError("thread dimensions must be numeric") from error + if major <= 0 or minor <= 0 or pitch <= 0 or length <= 0: + raise ValueError("thread requires positive major/minor/pitch/length in millimetres") + if minor >= major: + raise ValueError("thread minor diameter must be smaller than the major diameter") + raw_axis = params.get("axis") + if not isinstance(raw_axis, dict): + raise ValueError("thread requires an axis definition") + angle = 60.0 + raw_angle = params.get("angle_deg") + if raw_angle is not None: + try: + angle = float(raw_angle) + except (TypeError, ValueError) as error: + raise ValueError("thread angle_deg must be numeric") from error + if not 0 < angle < 180: + raise ValueError("thread angle_deg must be between 0 and 180") + relief = float(params.get("relief_length_mm") or 0.0) + crest_r = float(params.get("crest_radius_mm") or 0.0) + root_r = float(params.get("root_radius_mm") or 0.0) + if relief < 0: + raise ValueError("thread relief_length_mm must be non-negative") + if crest_r < 0 or root_r < 0: + raise ValueError("thread crest/root radius must be non-negative") + # 清根退化保护:crest + root 圆角半径之和不能超过可用牙高的一半, + # 否则梯形牙截面会被 fillet 完全吃掉,无法形成封闭截面。 + depth_radius = (major - minor) / 2.0 + if crest_r + root_r > 0.5 * depth_radius: + raise ValueError("thread crest_radius + root_radius exceeds half the thread depth") + # thread_cut 语义上恒为内螺纹:CDSL 输入无需显式传 internal,即使传 + # internal=False 也强制为 True(外螺纹切槽没有任何物理意义,且外轮廓 + # 刀具做布尔差会在 coincident faces 上退化)。 + internal = True if atomic_id == "thread_cut" else bool(params.get("internal", False)) + return cls( + major_diameter_mm=major, + minor_diameter_mm=minor, + pitch_mm=pitch, + length_mm=length, + axis=AxisSpec.from_mapping(raw_axis), + angle_deg=angle, + internal=internal, + lefthand=bool(params.get("lefthand", False)), + relief_length_mm=relief, + crest_radius_mm=crest_r, + root_radius_mm=root_r, + ) + + +@dataclass(frozen=True) +class BendLeg: + """One straight wing of a bent sheet-metal chain (runtime-neutral). + + ``leg_mm`` is the straight mid-plane distance from the previous fold vertex + to the fold vertex leaving this wing (the unfolded chord length between the + two surrounding fold vertices). When the wing has a following wing, + ``bend_angle_deg`` is the required *interior* angle between the two wings + (0 < angle < 180, 90 = a right-angle bend), ``inner_radius_mm`` the inner + bend-surface fillet radius (>= 0; the outer radius is always + ``inner_radius_mm + thickness_mm``) and ``side`` the fold direction + (+1 / -1). A trailing wing carries no fold; stray fold fields on the last + wing are ignored for tolerance. + """ + + leg_mm: float + bend_angle_deg: float | None = None + inner_radius_mm: float = 0.0 + side: int = 1 + + +@dataclass(frozen=True) +class BendSpec: + """Runtime-neutral definition of a sheet-metal bend (``bend_add``). + + The part is described by its mid-plane centreline: ``chain`` lists the + straight wings joined by equal-thickness bend corners. ``thickness_mm`` + and ``width_mm`` are the sheet thickness and the full length along the fold + (width) axis. ``frame.origin_mm`` anchors the start of the first wing's + mid-plane path, ``frame.x_dir`` is the fold/width axis and + ``frame.normal`` is the mid-plane normal of the first wing; the first wing + extends along ``normal x x_dir``. + + The spec deliberately contains no OCC planes or shapes. The adapter turns + this definition into a bent solid keeping source-contract parsing separate + from B-rep work (same pattern as :class:`ThreadSpec`). + """ + + thickness_mm: float + width_mm: float + frame: PlaneSpec + chain: tuple[BendLeg, ...] + + @classmethod + def from_feature(cls, params: dict[str, Any]) -> "BendSpec": + try: + thickness = float(params.get("thickness_mm") or 0.0) + width = float(params.get("width_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError("bend thickness/width must be numeric") from error + if thickness <= 0 or width <= 0: + raise ValueError("bend requires positive thickness_mm and width_mm in millimetres") + raw_chain = params.get("chain") + if not isinstance(raw_chain, (list, tuple)) or not raw_chain: + raise ValueError("bend requires a non-empty chain of wing segments") + chain: list[BendLeg] = [] + for index, raw_leg in enumerate(raw_chain): + if not isinstance(raw_leg, dict): + raise ValueError(f"bend chain[{index}] must be an object with leg_mm") + try: + leg = float(raw_leg.get("leg_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError(f"bend chain[{index}].leg_mm must be numeric") from error + if leg <= 0: + raise ValueError(f"bend chain[{index}].leg_mm must be positive") + bend_angle: float | None = None + if index < len(raw_chain) - 1: + raw_angle = raw_leg.get("bend_angle_deg") + if raw_angle is None: + raise ValueError( + f"bend chain[{index}] needs bend_angle_deg for the fold towards the next wing" + ) + try: + bend_angle = float(raw_angle) + except (TypeError, ValueError) as error: + raise ValueError(f"bend chain[{index}].bend_angle_deg must be numeric") from error + if not 0 < bend_angle < 180: + raise ValueError("bend interior angle must be between 0 and 180 degrees") + radius = float(raw_leg.get("inner_radius_mm") or 0.0) + raw_side = raw_leg.get("side") + side = 1 if raw_side is None else int(raw_side) + if radius < 0: + raise ValueError(f"bend chain[{index}].inner_radius_mm must be non-negative") + if side not in (1, -1): + raise ValueError(f"bend chain[{index}].side must be +1 or -1") + chain.append(BendLeg( + leg_mm=leg, + bend_angle_deg=bend_angle, + inner_radius_mm=radius, + side=side, + )) + raw_frame = params.get("frame") + if isinstance(raw_frame, dict): + frame = PlaneSpec.from_mapping(raw_frame) + else: + # frame 缺省:首翼沿世界 +X 延伸、厚度沿 +Y、折痕沿 +Z(贴 XY 平面)。 + frame = PlaneSpec( + origin_mm=(0.0, 0.0, 0.0), + x_dir=(0.0, 0.0, 1.0), + y_dir=(1.0, 0.0, 0.0), + normal=(0.0, 1.0, 0.0), + ) + return cls(thickness_mm=thickness, width_mm=width, frame=frame, chain=tuple(chain)) + + +@dataclass(frozen=True) +class GearSpec: + """Runtime-neutral definition of an involute spur/helical/herringbone gear. + + ``module_mm``/``teeth_count``/``pressure_angle_rad`` follow the ISO + involute convention: pitch radius ``m*z/2``, base radius + ``m*z/2*cos(alpha)``, addendum ``m`` and dedendum ``1.25*m``. ``width_mm`` + is the axial face width. ``helix_angle_rad`` is the pitch-cylinder helix + angle (0 = spur); ``herringbone=True`` builds a symmetric double-helical + gear whose helix reverses at mid-width (the mid-plane seam is shared + phase, no V-notch). ``axis.origin_mm`` is the centre of the ``z = 0`` end + face and ``axis.direction`` the outward gear axis. + """ + + module_mm: float + teeth_count: int + width_mm: float + axis: AxisSpec + helix_angle_rad: float = 0.0 + pressure_angle_rad: float = pi / 9.0 # 20 degrees + herringbone: bool = False + + @classmethod + def from_feature(cls, params: dict[str, Any]) -> "GearSpec": + try: + module = float(params.get("module_mm") or 0.0) + width = float(params.get("width_mm") or 0.0) + teeth = int(params.get("teeth_count") or 0) + helix_raw = params.get("helix_angle_rad") + helix = float(helix_raw) if helix_raw is not None else 0.0 + pressure_raw = params.get("pressure_angle_rad") + pressure = float(pressure_raw) if pressure_raw is not None else pi / 9.0 + except (TypeError, ValueError) as error: + raise ValueError("gear dimensions must be numeric") from error + if module <= 0 or width <= 0: + raise ValueError("gear requires positive module_mm and width_mm") + if not 8 <= teeth <= 200: + raise ValueError("gear teeth_count must be between 8 and 200") + if not 0.0 <= helix < radians(45.0): + raise ValueError("gear helix_angle_rad must be within [0, 45) degrees") + if not 0.0 < pressure <= radians(35.0): + raise ValueError("gear pressure_angle_rad must be within (0, 35] degrees") + herringbone = bool(params.get("herringbone", False)) + if herringbone and helix <= 0.0: + raise ValueError("herringbone gear requires helix_angle_rad > 0") + raw_axis = params.get("axis") + if not isinstance(raw_axis, dict): + raise ValueError("gear requires an axis definition") + return cls( + module_mm=module, + teeth_count=teeth, + width_mm=width, + axis=AxisSpec.from_mapping(raw_axis), + helix_angle_rad=helix, + pressure_angle_rad=pressure, + herringbone=herringbone, + ) + + @property + def pitch_radius_mm(self) -> float: + return self.module_mm * self.teeth_count / 2.0 + + @property + def base_radius_mm(self) -> float: + return self.pitch_radius_mm * cos(self.pressure_angle_rad) + + @property + def tip_radius_mm(self) -> float: + return self.pitch_radius_mm + self.module_mm + + @property + def root_radius_mm(self) -> float: + return self.pitch_radius_mm - 1.25 * self.module_mm + + +@dataclass(frozen=True) +class RackSpec: + """Runtime-neutral definition of a straight-sided rack (``rack_add``). + + The rack is the linear counterpart of a gear: tooth pitch ``p = pi*m``, + addendum ``m`` above the reference line and dedendum ``1.25*m`` below it, + flanks inclined by ``pressure_angle_rad``. ``teeth_count`` sets the exact + overall length ``teeth_count * pi * m`` (both ends land in tooth valley + centres). ``thickness_mm`` is the width along the tooth crest direction. + ``axis.origin_mm`` is the corner point where the length-start end face, + the thickness-start face and the tooth-valley plane intersect, and + ``axis.direction`` is the direction the teeth point (local +Z). + """ + + module_mm: float + teeth_count: int + thickness_mm: float + axis: AxisSpec + pressure_angle_rad: float = pi / 9.0 # 20 degrees + + @classmethod + def from_feature(cls, params: dict[str, Any]) -> "RackSpec": + try: + module = float(params.get("module_mm") or 0.0) + thickness = float(params.get("thickness_mm") or 0.0) + teeth = int(params.get("teeth_count") or 0) + pressure_raw = params.get("pressure_angle_rad") + pressure = float(pressure_raw) if pressure_raw is not None else pi / 9.0 + except (TypeError, ValueError) as error: + raise ValueError("rack dimensions must be numeric") from error + if module <= 0 or thickness <= 0: + raise ValueError("rack requires positive module_mm and thickness_mm") + if not 1 <= teeth <= 2000: + raise ValueError("rack teeth_count must be between 1 and 2000") + if not 0.0 < pressure <= radians(35.0): + raise ValueError("rack pressure_angle_rad must be within (0, 35] degrees") + raw_axis = params.get("axis") + if not isinstance(raw_axis, dict): + raise ValueError("rack requires an axis definition") + return cls( + module_mm=module, + teeth_count=teeth, + thickness_mm=thickness, + axis=AxisSpec.from_mapping(raw_axis), + pressure_angle_rad=pressure, + ) + + @property + def pitch_mm(self) -> float: + return pi * self.module_mm + + @property + def length_mm(self) -> float: + return self.pitch_mm * self.teeth_count + + @property + def addendum_mm(self) -> float: + return self.module_mm + + @property + def dedendum_mm(self) -> float: + return 1.25 * self.module_mm diff --git a/backend/engine/cdsl_engine/topology.py b/backend/engine/cdsl_engine/topology.py new file mode 100644 index 00000000..a3e39147 --- /dev/null +++ b/backend/engine/cdsl_engine/topology.py @@ -0,0 +1,1235 @@ +"""Runtime-neutral CDSL diagnostics, planning, and topology contracts. + +This module deliberately has no build123d dependency. The planner and +selector resolver can therefore be used by validation, batch reporting, and +any geometry adapter without importing OCC objects. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from math import sqrt +from typing import Any, Iterable + +from .specs import AxisSpec, PlaneSpec, Vector3, _length, _vector3, normalize_selector_geometry + + +@dataclass(frozen=True) +class RuntimeDiagnostic: + code: str + message: str + feature_id: str | None = None + detail: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + output: dict[str, Any] = {"code": self.code, "message": self.message} + if self.feature_id is not None: + output["feature_id"] = self.feature_id + if self.detail: + output["detail"] = self.detail + return output + + +@dataclass(frozen=True) +class CapabilityResult: + feature_id: str + atomic_id: str + resolved_status: str + required_capabilities: tuple[str, ...] = () + blockers: tuple[RuntimeDiagnostic, ...] = () + + @property + def executable(self) -> bool: + return self.resolved_status == "executable" + + def as_dict(self) -> dict[str, Any]: + return { + "feature_id": self.feature_id, + "atomic_id": self.atomic_id, + "resolved_status": self.resolved_status, + "required_capabilities": list(self.required_capabilities), + "blockers": [blocker.as_dict() for blocker in self.blockers], + } + + +@dataclass(frozen=True) +class FeaturePlanNode: + feature_id: str + atomic_id: str + name: str | None + depends_on: tuple[str, ...] + params: dict[str, Any] + selectors: tuple[dict[str, Any], ...] + sketch_id: str | None + declared_status: str | None + source_feature: dict[str, Any] + + +@dataclass +class FeatureResult: + feature_id: str + atomic_id: str + status: str + body_id: str | None = None + surface_id: str | None = None + context: PlaneSpec | AxisSpec | None = None + replay_definition: dict[str, Any] | None = None + diagnostics: list[RuntimeDiagnostic] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + output: dict[str, Any] = { + "feature_id": self.feature_id, + "atomic_id": self.atomic_id, + "status": self.status, + "diagnostics": [diagnostic.as_dict() for diagnostic in self.diagnostics], + } + if self.body_id is not None: + output["body_id"] = self.body_id + if self.surface_id is not None: + output["surface_id"] = self.surface_id + if self.context is not None: + output["context"] = self.context.as_dict() + if self.replay_definition is not None: + output["replay_definition"] = self.replay_definition + return output + + +@dataclass(frozen=True) +class TopologyRecord: + """Runtime-side signature of a topology item or context object. + + ``feature_id`` identifies the feature that produced this *snapshot*. + ``owner_feature_ids`` is durable semantic provenance for an unchanged + current B-rep item. Boolean and dress-up operations replace OCC objects, + so keeping these concepts separate prevents a later mutation from making + every surviving face appear to be owned by that mutation. + """ + + record_id: str + kind: str + feature_id: str + body_id: str | None = None + geometry: dict[str, Any] = field(default_factory=dict) + value: Any = None + owner_feature_ids: tuple[str, ...] = () + # Builder-produced roles describe a particular result subshape. They are + # deliberately separate from the geometric signature: equal geometry does + # not prove that two faces have the same feature-output meaning. + output_roles: tuple[str, ...] = () + # Generated roles may carry the direct feature-output role that the kernel + # operation transformed. This is semantic provenance, not a stable-id + # shortcut: the resolver still requires the exact active result snapshot. + output_role_sources: tuple[tuple[str, str, str], ...] = () + + @property + def owners(self) -> tuple[str, ...]: + """Return durable provenance, retaining compatibility for contexts.""" + return self.owner_feature_ids or (self.feature_id,) + + def public_dict(self) -> dict[str, Any]: + output: dict[str, Any] = { + "record_id": self.record_id, + "kind": self.kind, + "feature_id": self.feature_id, + "geometry": self.geometry, + } + if self.body_id is not None: + output["body_id"] = self.body_id + if self.owner_feature_ids: + output["owner_feature_ids"] = list(self.owner_feature_ids) + if self.output_roles: + output["output_roles"] = list(self.output_roles) + if self.output_role_sources: + output["output_role_sources"] = [ + {"output_role": role, "owner_feature_id": owner, "source_output_role": source_role} + for role, owner, source_role in self.output_role_sources + ] + return output + + +@dataclass(frozen=True) +class TopologyDeltaRelation: + """One opaque kernel-history relationship for a topology subshape. + + Geometry adapters retain ownership of the values in this structure. They + are intentionally opaque to the runtime: a build123d/OCC adapter may use + ``TopoDS_Shape`` values while another adapter can use its native handles. + The registry only asks whether a handle is exactly the same topology item; + it never uses this contract to score nearby geometry. + """ + + event: str + kind: str + source_value: Any + result_values: tuple[Any, ...] = () + output_role: str | None = None + derivation: str | None = None + source_role: str | None = None + result_role: str | None = None + source_slot: str | None = None + result_slot: str | None = None + coverage: str = "complete" + status: str = "proven" + + def __post_init__(self) -> None: + if self.event not in {"preserved", "modified", "generated", "deleted"}: + raise ValueError(f"unsupported topology delta event {self.event!r}") + if self.kind not in {"face", "edge", "vertex"}: + raise ValueError(f"unsupported topology delta kind {self.kind!r}") + if self.event == "deleted" and (self.result_values or self.output_role is not None): + raise ValueError("deleted topology delta relations cannot have result values or an output role") + if self.output_role is not None and (not isinstance(self.output_role, str) or not self.output_role): + raise ValueError("topology delta output_role must be a non-empty string when provided") + derivation = self.derivation + if derivation is None: + derivation = { + "preserved": "continuation", + "modified": "fragment" if len(self.result_values) > 1 else "continuation", + "generated": "boundary", + "deleted": "replacement", + }[self.event] + object.__setattr__(self, "derivation", derivation) + if derivation not in {"continuation", "fragment", "merge", "intersection", "boundary", "replacement"}: + raise ValueError(f"unsupported topology derivation {derivation!r}") + if self.coverage not in {"complete", "partial", "none"}: + raise ValueError(f"unsupported topology coverage {self.coverage!r}") + if self.status not in {"proven", "unknown", "rejected"}: + raise ValueError(f"unsupported topology relation status {self.status!r}") + + +@dataclass(frozen=True) +class TopologyDelta: + """Kernel-backed topology history for one adapter operation. + + ``operation`` is evidence only. The runtime transfers durable provenance + solely from a unique exact relationship, never from an operation name or a + geometric resemblance. + """ + + operation: str + relations: tuple[TopologyDeltaRelation, ...] = () + # Boolean section edges are distinct from source continuations. INTERSECT + # selectors may only rely on this exact builder evidence. + section_values: tuple[Any, ...] = () + + +@dataclass(frozen=True) +class TopologyLineage: + """An N:M semantic edge backed by an adapter history relation.""" + + source_record_ids: tuple[str, ...] + result_record_ids: tuple[str, ...] + derivation: str + evidence: str + coverage: str + status: str + operation: str + output_role: str | None = None + + def as_dict(self) -> dict[str, Any]: + result = { + "source_record_ids": list(self.source_record_ids), + "result_record_ids": list(self.result_record_ids), + "derivation": self.derivation, + "evidence": self.evidence, + "coverage": self.coverage, + "status": self.status, + "operation": self.operation, + } + if self.output_role is not None: + result["output_role"] = self.output_role + return result + + +@dataclass(frozen=True) +class SelectorResolution: + selector: dict[str, Any] + status: str + record: TopologyRecord | None = None + # ``records`` carries policy-authorized 1:N selector results. ``record`` + # remains the compatibility field for a unique selection and context use. + 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" + + def as_dict(self) -> dict[str, Any]: + output = { + "selector": self.selector, + "status": self.status, + "resolution_mode": self.resolution_mode, + "candidates": list(self.candidates), + } + if self.record is not None: + output["record"] = self.record.public_dict() + output["selected"] = self.record.public_dict() + score = next( + (candidate.get("score") for candidate in self.candidates if candidate.get("record_id") == self.record.record_id), + None, + ) + if score is not None: + output["score"] = score + if self.records: + 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 ()))], + } + return output + + +class TopologyRegistry: + """Feature-scoped context/topology registry with explainable matching.""" + + def __init__(self) -> None: + self._records: list[TopologyRecord] = [] + self._by_feature: dict[str, list[TopologyRecord]] = {} + self._active_body_id: str | None = None + self._topology_deltas: list[dict[str, Any]] = [] + self._lineage: list[TopologyLineage] = [] + # #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer + # 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何 + # 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的 + # stable_id 引用可以解析到 active body 内的新形态。 + self._successors: dict[str, list[str]] = {} + + def register(self, record: TopologyRecord) -> None: + self._records.append(record) + self._by_feature.setdefault(record.feature_id, []).append(record) + + def records_for_feature(self, feature_id: str) -> tuple[TopologyRecord, ...]: + return tuple(self._by_feature.get(feature_id, ())) + + def records(self) -> tuple[TopologyRecord, ...]: + return tuple(self._records) + + def topology_deltas(self) -> tuple[dict[str, Any], ...]: + """Return serializable evidence derived from exact adapter history.""" + return tuple(self._topology_deltas) + + def lineage(self) -> tuple[TopologyLineage, ...]: + """Return kernel-backed N:M lineage without heuristic successors.""" + return tuple(self._lineage) + + def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord: + kind = "plane" if isinstance(context, PlaneSpec) else "axis" + record = TopologyRecord( + record_id=f"{feature_id}:{kind}", + kind=kind, + feature_id=feature_id, + geometry=context.as_dict(), + value=context, + ) + self.register(record) + return record + + 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, + additional_predecessors: Iterable[TopologyRecord] = (), + ) -> None: + self.replace_body_topologies( + feature_id, [(body_id, records)], active_body_id=active_body_id, topology_delta=topology_delta, + additional_predecessors=additional_predecessors, + ) + + def replace_body_topologies( + self, feature_id: str, bodies: Iterable[tuple[str, Iterable[TopologyRecord]]], + *, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None, + additional_predecessors: Iterable[TopologyRecord] = (), + ) -> None: + """Record a fresh B-rep snapshot after a feature mutates the body. + + OCC topology object identity is invalidated by most body mutations. + We therefore keep old objects out of active selector resolution but + carry their semantic owners forward when, and only when, one current + object has one geometrically equivalent predecessor. A changed or + split object intentionally becomes owned by this feature instead of + being guessed as belonging to an older one. + + ``active_body_id`` names the whole-body group when ``body_id`` is a + member of a multi-solid body (issue #7): the group id keeps the next + mutation's predecessor lookup scoped to every solid of the previous + body, while each member keeps its own ``body:{feature}:{index}`` id. + """ + active_previous = [ + record for record in self._records + if self._active_body_id is not None and record.body_id is not None + and ( + record.body_id == self._active_body_id + or record.body_id.startswith(f"{self._active_body_id}:") + ) + ] + # A pattern COPY can carry a chain of exact transform/boolean builder + # histories before its final aggregate snapshot is registered. These + # temporary records are valid predecessors only for that documented + # kernel-history bridge. They are deliberately excluded from geometric + # fallback matching: an equal-looking final face never proves that it + # belongs to one particular copy instance. + transient_previous = list(additional_predecessors) + previous = [*active_previous, *transient_previous] + # 同一 source feature 的 pattern copy 可以产生完全相同的几何面。它们 + # 必须保留为多个实例,不能在跨 body 的全局 predecessor 匹配中互相消费。 + # pattern 的 Compound 成员顺序是稳定的:已有实例以同一 member index + # 延续,新增实例只会出现在末尾。按该 index 限定后继匹配。 + current = [(body_id, list(records)) for body_id, records in bodies] + current_records = [record for _body_id, records in current for record in records] + ( + exact_predecessors, + exact_successors, + kernel_covered_predecessors, + exact_output_roles, + exact_output_role_sources, + delta_evidence, + ) = self._exact_delta_links( + topology_delta, previous, current_records, + ) + previous_member_ids = { + suffix for record in active_previous + for suffix in [str(record.body_id).rsplit(":", 1)[-1]] + if suffix.isdigit() + } + use_member_indexes = len(current) > 1 and previous_member_ids + consumed_predecessors: set[str] = set() + registered: list[TopologyRecord] = [] + for body_id, records in current: + member_id = str(body_id).rsplit(":", 1)[-1] + local_predecessors = [ + record for record in active_previous + if not use_member_indexes or str(record.body_id).rsplit(":", 1)[-1] == member_id + ] + for record in records: + exact_predecessor_id = exact_predecessors.get(record.record_id) + # Member order is a useful isolation boundary for geometric + # fallback matching, especially for coincident pattern + # copies. It is not a provenance boundary when a kernel + # builder explicitly relates one source subshape to one + # result subshape: boolean/delete lifecycle can remove an + # earlier member and shift a surviving source to another + # member index. A unique OCC continuation remains exact + # evidence across that index change. + predecessor = next( + (prior for prior in previous if prior.record_id == exact_predecessor_id), + None, + ) + if predecessor is None and record.record_id not in exact_predecessors: + predecessor = self._unique_equivalent_predecessor(record, local_predecessors, consumed_predecessors) + owners = predecessor.owners if predecessor is not None else (feature_id,) + output_roles = set(record.output_roles) + output_roles.update(exact_output_roles.get(record.record_id, ())) + output_role_sources = set(record.output_role_sources) + output_role_sources.update(exact_output_role_sources.get(record.record_id, ())) + # A feature-output role can survive a later operation only + # through the same unique kernel continuation used for owner + # provenance. Geometry equivalence alone never carries it. + if predecessor is not None and record.record_id in exact_predecessors: + output_roles.update(predecessor.output_roles) + if predecessor is not None and record.record_id not in exact_predecessors: + consumed_predecessors.add(predecessor.record_id) + registered.append(TopologyRecord( + record_id=record.record_id, + kind=record.kind, + feature_id=feature_id, + body_id=body_id, + geometry=dict(record.geometry), + value=record.value, + owner_feature_ids=owners, + output_roles=tuple(sorted(output_roles)), + output_role_sources=tuple(sorted(output_role_sources)), + )) + for record in registered: + self.register(record) + for predecessor_id, successor_ids in exact_successors.items(): + known = self._successors.setdefault(predecessor_id, []) + for successor_id in successor_ids: + if successor_id not in known: + known.append(successor_id) + if delta_evidence is not None: + 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], + }) + # #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续 + # selector 的 stable_id 引用解析到 active body 内的新形态。多条演化 + # 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段 + # 也有圆角过渡带的新边,前者的端点与原边重合、漂移更小);漂移并列 + # (如竖直边被完整消费成两条等距直段)属于本质歧义,保守不登记。 + for prior in previous: + if ( + prior.record_id in consumed_predecessors + or prior.record_id in exact_successors + or prior.record_id in kernel_covered_predecessors + ): + continue + candidates = sorted( + ( + (self._evolved_drift(prior, record), record.record_id) + for record in registered if self._evolved_equivalent(prior, record) + ), + key=lambda item: item[0], + ) + if not candidates: + continue + best, second = candidates[0], (candidates[1] if len(candidates) > 1 else None) + if second is None or (second[0] - best[0]) > max(0.5, 0.2 * best[0]): + self._successors[prior.record_id] = [best[1]] + self._active_body_id = active_body_id or body_id + + @staticmethod + def _same_topology_value(left: Any, right: Any) -> bool: + """Compare adapter handles only through their exact topology identity.""" + left_value = getattr(left, "wrapped", left) + right_value = getattr(right, "wrapped", right) + if left_value is right_value: + return True + for candidate, other in ((left_value, right_value), (right_value, left_value)): + for method_name in ("IsSame", "is_same"): + method = getattr(candidate, method_name, None) + if callable(method): + try: + return bool(method(other)) + except (AttributeError, TypeError, ValueError): + continue + return False + + @classmethod + def _exact_delta_links( + cls, + topology_delta: TopologyDelta | None, + previous: list[TopologyRecord], + current: list[TopologyRecord], + ) -> tuple[ + dict[str, str], + dict[str, list[str]], + set[str], + dict[str, tuple[str, ...]], + dict[str, tuple[tuple[str, str, str], ...]], + list[dict[str, Any]] | None, + ]: + """Bind opaque kernel history to snapshots without geometric guessing. + + Ownership transfer is intentionally limited to a single source item and + a single output item. Split/merge history remains useful evidence, but + has no unique owner continuation until a later operation-specific + contract can express it. + """ + if topology_delta is None: + return {}, {}, set(), {}, {}, None + candidate_sources: dict[str, set[str]] = {} + kernel_covered_predecessors: set[str] = set() + output_roles: dict[str, set[str]] = {} + output_role_sources: dict[str, set[tuple[str, str, str]]] = {} + relation_links: list[tuple[str, str] | None] = [] + evidence: list[dict[str, Any]] = [] + for relation in topology_delta.relations: + sources = [ + record for record in previous + if record.kind == relation.kind and cls._same_topology_value(record.value, relation.source_value) + ] + outputs = [ + record for record in current + if record.kind == relation.kind + and any(cls._same_topology_value(record.value, value) for value in relation.result_values) + ] + item = { + "event": relation.event, + "kind": relation.kind, + "source_record_ids": [record.record_id for record in sources], + "result_record_ids": [record.record_id for record in outputs], + "proof": "kernel_history", + "derivation": relation.derivation, + "coverage": relation.coverage if len(outputs) == len(relation.result_values) else "partial", + "lineage_status": relation.status, + } + for field_name in ("source_role", "result_role", "source_slot", "result_slot"): + value = getattr(relation, field_name) + if value is not None: + item[field_name] = value + if relation.output_role is not None: + item["output_role"] = relation.output_role + role_is_unique = len(relation.result_values) == 1 and len(outputs) == 1 + item["output_role_status"] = ( + "unique_result_snapshot" if role_is_unique else "non_unique_or_missing_result_snapshot" + ) + if role_is_unique: + output_roles.setdefault(outputs[0].record_id, set()).add(relation.output_role) + for source in sources: + for source_role in source.output_roles: + for source_owner in source.owners: + output_role_sources.setdefault(outputs[0].record_id, set()).add( + (relation.output_role, source_owner, source_role) + ) + if len(sources) == 1: + kernel_covered_predecessors.add(sources[0].record_id) + can_transfer = ( + relation.event in {"preserved", "modified"} + and len(relation.result_values) == 1 + and len(sources) == 1 + and len(outputs) == 1 + ) + if can_transfer: + source_id, result_id = sources[0].record_id, outputs[0].record_id + candidate_sources.setdefault(result_id, set()).add(source_id) + relation_links.append((source_id, result_id)) + else: + relation_links.append(None) + evidence.append(item) + if topology_delta.section_values: + section_outputs = [ + record for record in current + if record.kind == "edge" + and any(cls._same_topology_value(record.value, value) for value in topology_delta.section_values) + ] + evidence.append({ + "event": "generated", + "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", + "section_edge": True, + "status": "recorded_section_edge", + }) + predecessors = { + result_id: next(iter(source_ids)) + for result_id, source_ids in candidate_sources.items() + if len(source_ids) == 1 + } + successors: dict[str, list[str]] = {} + for result_id, source_id in predecessors.items(): + successors.setdefault(source_id, []).append(result_id) + for item, relation, link in zip(evidence, topology_delta.relations, relation_links): + if link is not None: + _source_id, result_id = link + item["status"] = ( + "unique_exact_continuation" + if len(candidate_sources[result_id]) == 1 else "ambiguous_exact_continuation" + ) + elif relation.event in {"preserved", "modified"}: + item["status"] = "non_unique_or_incomplete" + else: + item["status"] = "recorded_without_owner_transfer" + if item["coverage"] != "complete" or relation.status != "proven": + item["lineage_status"] = "unknown" + return ( + predecessors, + successors, + kernel_covered_predecessors, + {record_id: tuple(sorted(roles)) for record_id, roles in output_roles.items()}, + {record_id: tuple(sorted(sources)) for record_id, sources in output_role_sources.items()}, + evidence, + ) + + 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.""" + pending = [source_record_id] + visited = {source_record_id} + result_ids: set[str] = set() + 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 + for record_id in edge.result_record_ids: + 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 + + @staticmethod + def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: + try: + return abs(float(left) - float(right)) <= tolerance + except (TypeError, ValueError): + return False + + @classmethod + def _vectors_equal(cls, left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: + try: + first = _vector3(left, field_name="prior topology geometry") + second = _vector3(right, field_name="current topology geometry") + except ValueError: + return False + return all(abs(a - b) <= tolerance for a, b in zip(first, second)) + + @classmethod + def _geometry_equivalent(cls, prior: TopologyRecord, current: TopologyRecord) -> bool: + """Check a complete, orientation-aware snapshot signature. + + This is intentionally much stricter than selector scoring. Selector + scoring may compare partial source evidence; provenance transfer must + never manufacture ownership from a merely similar candidate. + """ + if prior.kind != current.kind: + return False + left, right = prior.geometry, current.geometry + for key in ("surface_type", "curve_type"): + if left.get(key) != right.get(key): + return False + for key in ("bbox_mm", "center_mm", "normal", "plane_normal"): + if key in left or key in right: + if key not in left or key not in right: + return False + left_value, right_value = left[key], right[key] + if key == "bbox_mm": + if not isinstance(left_value, (list, tuple)) or not isinstance(right_value, (list, tuple)): + return False + if len(left_value) != 6 or len(right_value) != 6: + return False + if not all(cls._numbers_equal(a, b) for a, b in zip(left_value, right_value)): + return False + elif not cls._vectors_equal(left_value, right_value): + return False + for key in ("area_mm2", "length_mm", "plane_offset_mm"): + if key in left or key in right: + if key not in left or key not in right or not cls._numbers_equal(left[key], right[key]): + return False + for key in ("adjacency_signature", "adjacent_face_count", "incident_edge_count"): + if key in left or key in right: + if key not in left or key not in right or left[key] != right[key]: + return False + left_start, left_end = left.get("start_mm"), left.get("end_mm") + right_start, right_end = right.get("start_mm"), right.get("end_mm") + if any(value is not None for value in (left_start, left_end, right_start, right_end)): + if None in (left_start, left_end, right_start, right_end): + return False + same_direction = cls._vectors_equal(left_start, right_start) and cls._vectors_equal(left_end, right_end) + reverse_direction = cls._vectors_equal(left_start, right_end) and cls._vectors_equal(left_end, right_start) + if not same_direction and not reverse_direction: + return False + return True + + @classmethod + def _unique_equivalent_predecessor( + cls, + current: TopologyRecord, + predecessors: Iterable[TopologyRecord], + consumed_predecessors: set[str], + ) -> TopologyRecord | None: + matches = [ + record for record in predecessors + if record.record_id not in consumed_predecessors and cls._geometry_equivalent(record, current) + ] + return matches[0] if len(matches) == 1 else None + + @staticmethod + def _evolved_drift(prior: TopologyRecord, current: TopologyRecord) -> float | None: + """Endpoint drift between direction-aligned straight edges. + + Returns the minimum total endpoint drift (mm) when the two edges are + collinear straight lines (either orientation), otherwise ``None``. + """ + if prior.kind != current.kind: + return None + left, right = prior.geometry, current.geometry + if left.get("curve_type") != "line" or right.get("curve_type") != "line": + return None + if None in (left.get("start_mm"), left.get("end_mm"), right.get("start_mm"), right.get("end_mm")): + return None + + def _delta(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]: + return (b[0] - a[0], b[1] - a[1], b[2] - a[2]) + + def _dist(a: tuple[float, float, float], b: tuple[float, float, float]) -> float: + return sqrt(sum((a[i] - b[i]) ** 2 for i in range(3))) + + left_dir = _delta(left["start_mm"], left["end_mm"]) + right_dir = _delta(right["start_mm"], right["end_mm"]) + if _length(left_dir) <= 1e-9 or _length(right_dir) <= 1e-9: + return None + cross = ( + left_dir[1] * right_dir[2] - left_dir[2] * right_dir[1], + left_dir[2] * right_dir[0] - left_dir[0] * right_dir[2], + left_dir[0] * right_dir[1] - left_dir[1] * right_dir[0], + ) + if _length(cross) / (_length(left_dir) * _length(right_dir)) > 1e-3: + return None + same_order = _dist(left["start_mm"], right["start_mm"]) + _dist(left["end_mm"], right["end_mm"]) + reversed_order = _dist(left["start_mm"], right["end_mm"]) + _dist(left["end_mm"], right["start_mm"]) + return min(same_order, reversed_order) + + @classmethod + def _evolved_equivalent(cls, prior: TopologyRecord, current: TopologyRecord, *, drift_mm: float = 5.0) -> bool: + """Loose "position trajectory" equivalence used for evolved successors. + + Unlike ``_geometry_equivalent`` (strict, anti-false-positive provenance), + this deliberately tolerates small endpoint drift: fillet/chamfer split a + straight edge into segments (a middle straight run plus end arcs). The + straight run keeps the same direction and stays within ``drift_mm`` of the + original edge, so it can serve as the edge's evolved successor. Uniqueness + is enforced by the caller (only a single best candidate is recorded). + """ + drift = cls._evolved_drift(prior, current) + return drift is not None and drift <= drift_mm + + @staticmethod + def _vector_score(expected: Any, actual: Any, tolerance: float = 1e-4) -> float | None: + try: + left = _vector3(expected, field_name="selector geometry") + right = _vector3(actual, field_name="record geometry") + except ValueError: + return None + error = _length(tuple(a - b for a, b in zip(left, right))) + return max(0.0, 1.0 - error / tolerance) + + @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"): + if key in selector_geometry: + score = cls._vector_score(selector_geometry[key], record_geometry.get(key)) + if score is None: + return None + scores.append(score) + for key in ("surface_type", "curve_type"): + if key in selector_geometry: + if record_geometry.get(key) != selector_geometry[key]: + # #8 selector 持久性:fillet/chamfer 会把直线边演化为圆弧、 + # 平面演化为柱面,但被选中拓扑的位置锚定(bbox/center/端点) + # 不变。曲线/曲面类型变化不再一票否决,而是记低分:位置完全 + # 重合的候选(同一条边的形态演化)仍可胜出;位置不重合的 + # 相邻边会被 0 分项拉低,仍被 minimum_score 挡住。 + scores.append(0.5) + else: + scores.append(1.0) + if "bbox_mm" in selector_geometry: + expected = selector_geometry["bbox_mm"] + actual = record_geometry.get("bbox_mm") + if not isinstance(expected, list) or not isinstance(actual, list) or len(expected) != len(actual): + return None + delta = max(abs(float(a) - float(b)) for a, b in zip(expected, actual)) + 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"))) + except (TypeError, ValueError): + return None + scores.append(max(0.0, 1.0 - delta / 1e-4)) + if "radius_mm" in selector_geometry: + try: + delta = abs(float(selector_geometry["radius_mm"]) - float(record_geometry.get("radius_mm"))) + except (TypeError, ValueError): + return None + scores.append(max(0.0, 1.0 - delta / 1e-4)) + if "area_mm2" in selector_geometry: + try: + expected_area = float(selector_geometry["area_mm2"]) + actual_area = float(record_geometry.get("area_mm2")) + except (TypeError, ValueError): + 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)) + return sum(scores) / len(scores) if scores else 0.0 + + def resolve( + self, + selector: dict[str, Any], + *, + minimum_score: float = 0.8, + active_body_id: str | None = None, + ) -> SelectorResolution: + 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] + if active_body_id and kind in {"face", "edge", "vertex", "body"}: + # #7 multi-body:记录 body_id 可能是 body:{feature}:{index}(多体 + # 成员),用前缀匹配把整个主体的记录纳入候选,同时保证旧 body 的 + # 记录(不同 feature 前缀)不会泄漏进来。 + candidates = [ + record for record in candidates + 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: + 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)时, + # mirror_plane 的 plane 引用由运行时随实例变换后内联为显式 frame + # (_translated_node / _mirrored_node),这里直接构造 PlaneSpec, + # 不再走 stable_id / 几何匹配,避免解析到未随实例变换的原始面。 + try: + plane = PlaneSpec.from_mapping(selector.get("frame") or {}) + except (TypeError, ValueError): + plane = None + if plane is not None: + record = TopologyRecord( + record_id=f"inline:{id(plane)}", + kind="plane", + feature_id=str(owner or "inline"), + geometry=plane.as_dict(), + value=plane, + ) + return SelectorResolution( + selector=selector, + status="resolved", + record=record, + candidates=({"score": 1.0, **record.public_dict()},), + ) + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_frame_incomplete", + message="An inline plane frame requires origin_mm, x_dir and normal", + detail={"frame": selector.get("frame")}, + ), + ) + geometry = normalize_selector_geometry(selector.get("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}, + ), + ) + output_role = str(selector.get("output_role") or "").strip() + if output_role: + if not owner: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_owner_required", + message="A feature output role selector requires owner_feature_id", + detail={"output_role": output_role}, + ), + ) + if active_body_id is None: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_active_body_required", + message="A feature output role selector requires an active body snapshot", + detail={"output_role": output_role}, + ), + ) + if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")): + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_mixed_evidence", + message="A feature output role selector cannot mix stable or geometry evidence", + detail={"output_role": output_role}, + ), + ) + role_candidates = [record for record in candidates if output_role in record.output_roles] + role_source = selector.get("output_role_source") + if role_source is not None: + source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None + source_role = role_source.get("output_role") if isinstance(role_source, dict) else None + if not isinstance(source_owner, str) or not isinstance(source_role, str): + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_source_invalid", + message="An output role selector source requires owner_feature_id and output_role", + ), + ) + if output_role != "shell.offset_face" or source_role not in {"extrude.start", "extrude.end"}: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_source_unsupported", + message="Output role sources are currently supported only for shell.offset_face from an extrusion cap", + ), + ) + role_candidates = [ + record for record in role_candidates + if (output_role, source_owner, source_role) in record.output_role_sources + ] + public_candidates = tuple( + {"score": 1.0, **record.public_dict()} for record in role_candidates + ) + if len(role_candidates) == 1: + return SelectorResolution( + selector=selector, + status="resolved", + record=role_candidates[0], + candidates=public_candidates, + ) + if len(role_candidates) > 1: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=public_candidates, + diagnostic=RuntimeDiagnostic( + code="selector_output_role_ambiguous", + message="More than one active topology record has the requested output role", + detail={"output_role": output_role, "candidate_count": len(role_candidates)}, + ), + ) + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_not_found", + message="No active topology record has the requested output role", + detail={"output_role": output_role, "candidate_count": 0}, + ), + ) + stable_id = str(selector.get("stable_id") or "").strip() + if stable_id: + # #8 selector 持久性:stable_id 是跨 body 演化的持久标识符,精确 + # 匹配在 active body 过滤之前对整个记录集(kind + owner 过滤)执行。 + # 命中已过期(旧 body)的记录时,经演化后继映射解析到 active body + # 内的新形态(fillet/chamfer 拆段后的直段后继);无后继则回落到 + # 几何打分流程。 + stable_records = [ + record for record in self._records + if record.kind == kind 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] + 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}:")) + ) + if not is_active and provenance_intent: + policy = intent.get("derivation_policy") or {} + allowed = { + value for value in policy.get("allowed") or () + if value in {"continuation", "fragment", "merge", "intersection", "boundary", "replacement"} + } + successors = self._intent_lineage_successors( + record.record_id, + allowed=allowed, + active_body_id=active_body_id, + ) + if len(successors) == 1: + record = successors[0] + is_active = True + elif len(successors) > 1: + if policy.get("multiplicity") == "all_fragments" and "fragment" in allowed: + return SelectorResolution( + selector=selector, + status="resolved", + records=tuple(successors), + candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), + ) + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), + diagnostic=RuntimeDiagnostic( + code="selector_relation_non_unique", + message="The proven topology lineage has more than one active result", + detail={"stable_id": stable_id, "candidate_count": len(successors), "multiplicity": policy.get("multiplicity")}, + ), + ) + else: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_kernel_history_missing", + message="No complete proven lineage reaches an active topology record", + detail={"stable_id": stable_id, "allowed": sorted(allowed)}, + ), + ) + if not is_active: + successors = [ + candidate for candidate in stable_records + if candidate.record_id in self._successors.get(record.record_id, ()) + and ( + candidate.body_id == active_body_id + or (candidate.body_id is not None and active_body_id and candidate.body_id.startswith(f"{active_body_id}:")) + ) + ] + if len(successors) == 1: + record = successors[0] + is_active = True + elif len(successors) > 1: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), + diagnostic=RuntimeDiagnostic( + code="selector_ambiguous", + message="More than one evolved successor record satisfies the stable_id", + detail={"stable_id": stable_id, "candidate_count": len(successors)}, + ), + ) + if is_active: + # A stable ID is only a lookup accelerator for snapshot-aware + # selectors. It cannot revive a B-rep entity whose geometric + # signature changed after an upstream rebuild. + if selector.get("snapshot_id"): + score = self._geometry_score(geometry, record.geometry) if geometry else None + if score is None or score < minimum_score: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=({"score": round(float(score or 0), 6), **record.public_dict()},), + diagnostic=RuntimeDiagnostic( + code="selector_geometry_mismatch", + message="The stable selector record no longer matches its geometry signature", + detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score}, + ), + ) + return SelectorResolution( + selector=selector, + status="resolved", + record=record, + candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},), + ) + if not geometry: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_stable_id_inactive", + message="The stable selector record is not active and has no geometry signature for rebinding", + detail={"stable_id": stable_id}, + ), + ) + if len(exact) > 1: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=tuple({"score": 1.0, **record.public_dict()} for record in exact), + diagnostic=RuntimeDiagnostic( + code="selector_ambiguous", + message="More than one runtime topology record has the requested stable_id", + detail={"stable_id": stable_id, "candidate_count": len(exact)}, + ), + ) + if selector.get("snapshot_id") and not geometry: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_geometry_mismatch", + message="A snapshot selector requires a geometry signature", + detail={"minimum_score": minimum_score}, + ), + ) + 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")}, + ), + ) + scored: list[tuple[float, TopologyRecord]] = [] + for candidate in candidates: + # An owner-qualified context selector is deterministic when it has + # a single runtime candidate even if its source stable_id cannot + # survive the SolidWorks -> OCC boundary. + score = 1.0 if not geometry else self._geometry_score(geometry, candidate.geometry) + if score is not None: + scored.append((score, candidate)) + scored.sort(key=lambda item: (-item[0], item[1].record_id)) + public_candidates = tuple({"score": round(score, 6), **record.public_dict()} for score, record in scored) + if not scored or scored[0][0] < minimum_score: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=public_candidates, + diagnostic=RuntimeDiagnostic( + code="selector_not_found", + message="No runtime topology record satisfies the selector", + detail={"candidate_count": len(scored), "minimum_score": minimum_score}, + ), + ) + best_score, best_record = scored[0] + if len(scored) > 1 and abs(scored[1][0] - best_score) <= 1e-9: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=public_candidates, + diagnostic=RuntimeDiagnostic( + code="selector_ambiguous", + message="More than one runtime topology record has the best selector score", + detail={"best_score": best_score, "candidate_count": len(scored)}, + ), + ) + return SelectorResolution(selector=selector, status="resolved", record=best_record, candidates=public_candidates) diff --git a/backend/engine/cdsl_engine/topology_export.py b/backend/engine/cdsl_engine/topology_export.py new file mode 100644 index 00000000..8b6301d3 --- /dev/null +++ b/backend/engine/cdsl_engine/topology_export.py @@ -0,0 +1,311 @@ +"""B-rep topology evidence export for the build123d adapter. + +These module-level functions produce the face/edge/vertex snapshots that +``TopologyRegistry`` stores for selector resolution and that rebuild reports +expose as ``topology_records``. They are pure queries over the current +B-rep: no construction, no mutation, no OCP imports beyond what the shape +objects themselves expose. ``Build123dGeometryAdapter`` forwards to them, +keeping kernel construction and evidence export in separate files. +""" + +from __future__ import annotations + +import math +from typing import Any + +from .specs import canonical_plane_signature +from .topology import TopologyRecord + + +def body_geometry(body: Any) -> dict[str, Any]: + # 汇总主体基本几何信息:包围盒与体积。 + bbox = body.bounding_box() + # A feature history can contain several body IDs while still ending in + # one connected solid (for example, a base extrusion followed by hole + # cuts). Count the current OCC result, never feature history entries. + solids = list(body.solids()) if hasattr(body, "solids") else [body] + return { + "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], + "volume_mm3": float(body.volume), + "solid_count": len(solids), + } + + +def surface_geometry(surface: Any) -> dict[str, Any]: + # 曲面结果不参与实体 body 聚合;只保存后续 selector 所需的独立拓扑摘要。 + bbox = surface.bounding_box() + return { + "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], + "area_mm2": float(surface.area), + "face_count": len(surface.faces()), + } + + +def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: + # 从主体导出全部面/边/顶点拓扑记录,供后续特征选择与引用。 + records: list[TopologyRecord] = [] + faces = list(body.faces()) + edges = list(body.edges()) + vertices = list(body.vertices()) + + def index_for(shape: Any, candidates: list[Any]) -> int | None: + """Map a subshape returned by a face/edge back to body topology.""" + # 用 is_same 把面/边的子形状映射回主体拓扑列表的下标。 + for index, candidate in enumerate(candidates): + if shape.is_same(candidate): + return index + return None + + # 1. 建立邻接索引:每条边关联的面集合(edge_faces)。 + edge_faces: list[set[int]] = [set() for _edge in edges] + for face_index, face in enumerate(faces): + for edge in face.edges(): + edge_index = index_for(edge, edges) + if edge_index is not None: + edge_faces[edge_index].add(face_index) + # 2. 建立邻接索引:每个顶点关联的边集合(vertex_edges)。 + vertex_edges: list[set[int]] = [set() for _vertex in vertices] + for edge_index, edge in enumerate(edges): + for vertex in edge.vertices(): + vertex_index = index_for(vertex, vertices) + if vertex_index is not None: + vertex_edges[vertex_index].add(edge_index) + + def edge_signature(edge_index: int) -> str: + # 边的特征签名:几何类型 + 长度 + 相邻面数,用作面邻接指纹。 + edge = edges[edge_index] + return ":".join(( + str(edge.geom_type).split(".")[-1].lower(), + f"{float(edge.length):.6f}", + str(len(edge_faces[edge_index])), + )) + + # 3. 导出面记录:含包围盒、中心、法向、面积、曲面类型与邻接签名; + # 平面面额外写入规范化法向与平面偏移,便于后续按平面匹配。 + # 圆柱面还保存轴、半径和共享边关联的平面面。这使 verifier 能从 + # 实际 B-rep 证明孔是否连接两个方向相反的外部平面,而不是根据 + # author 传入的 blind-depth 文字猜测“贯穿”。 + face_edge_indexes: list[set[int]] = [] + face_geometries: list[dict[str, Any]] = [] + for index, face in enumerate(faces): + bbox = face.bounding_box() + center = face.center() + normal = face.normal_at() + boundary_edge_indexes = [ + edge_index + for edge in face.edges() + if (edge_index := index_for(edge, edges)) is not None + ] + geometry = { + "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], + "center_mm": [center.X, center.Y, center.Z], "normal": [normal.X, normal.Y, normal.Z], + "area_mm2": float(face.area), "surface_type": str(face.geom_type).split(".")[-1].lower(), + "adjacency_signature": sorted(edge_signature(edge_index) for edge_index in boundary_edge_indexes), + } + if geometry["surface_type"] == "plane": + plane_normal, plane_offset = canonical_plane_signature( + (normal.X, normal.Y, normal.Z), (center.X, center.Y, center.Z), + ) + geometry["plane_normal"] = list(plane_normal) + geometry["plane_offset_mm"] = plane_offset + boundary_loops: list[list[list[float]]] = [] + for wire in face.wires(): + samples: list[list[float]] = [] + for edge in wire.edges(): + curve_type = str(edge.geom_type).split(".")[-1].lower() + fractions = [step / 16 for step in range(16)] if curve_type in {"circle", "ellipse"} else [0.0] + for fraction in fractions: + point = edge.position_at(fraction) + value = [float(point.X), float(point.Y), float(point.Z)] + if not samples or sum((value[axis] - samples[-1][axis]) ** 2 for axis in range(3)) > 1e-12: + samples.append(value) + if len(samples) >= 3: + boundary_loops.append(samples) + if boundary_loops: + geometry["boundary_loops_mm"] = boundary_loops + elif geometry["surface_type"] in {"cylinder", "cone"}: + axis = face.axis_of_rotation + if axis is None: + # Build123d can omit this optional OCC property for valid + # swept rotational faces. Keep their generic B-rep record + # so a selector-free workflow remains executable; do not + # invent axis/radius evidence for an axis-based selector. + pass + else: + direction = axis.direction + origin = axis.position + geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z] + geometry["axis_direction"] = [direction.X, direction.Y, direction.Z] + raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None + if geometry["surface_type"] == "cone": + boundary_radii: list[float] = [] + for edge in face.edges(): + if str(edge.geom_type).split(".")[-1].lower() != "circle": + continue + try: + boundary_radii.append(float(edge.radius)) + except ValueError: + continue + geometry["boundary_radii_mm"] = sorted(boundary_radii) + geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None + # ``through`` alone describes a cylinder spanning two opposed + # planar faces. That applies to both a through bore and the + # outside wall of a cylindrical extrusion. Classify the B-rep + # face by its oriented normal so downstream acceptance claims + # can prove holes without mistaking an exterior wall for one. + unit_axis = (direction.X, direction.Y, direction.Z) + radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z) + axial_projection = sum(radial[component] * unit_axis[component] for component in range(3)) + radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3)) + radial_length = sum(component * component for component in radial) ** 0.5 + if geometry["surface_type"] == "cylinder": + # OCC can report a cylinder surface with ``radius=None`` + # after a non-planar-side Boolean cut. The face centre is + # still on that cylinder, so its perpendicular distance to + # the rotation axis is an equivalent measured radius. Do + # not fail an otherwise valid build merely because that + # optional OCC convenience property is absent. + if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)): + geometry["radius_mm"] = float(raw_cylinder_radius) + elif radial_length > 1e-9: + geometry["radius_mm"] = radial_length + if radial_length > 1e-9: + normal_components = (normal.X, normal.Y, normal.Z) + alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length + geometry["radial_normal_alignment"] = alignment + geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown" + else: + geometry["cylinder_role"] = "unknown" + face_edge_indexes.append(set(boundary_edge_indexes)) + face_geometries.append(geometry) + records.append(TopologyRecord( + record_id=f"{body_id}:face:{index}", kind="face", feature_id=feature_id, body_id=body_id, value=face, + geometry=geometry, + )) + plane_indexes = [index for index, geometry in enumerate(face_geometries) if geometry["surface_type"] == "plane"] + + def directly_linked_planes(face_index: int) -> list[int]: + return [ + plane_index + for plane_index in plane_indexes + if face_edge_indexes[face_index].intersection(face_edge_indexes[plane_index]) + ] + + def same_inner_rotational_channel(first: int, second: int) -> bool: + """Whether two inner rotational faces share one B-rep bore channel.""" + if not face_edge_indexes[first].intersection(face_edge_indexes[second]): + return False + left, right = face_geometries[first], face_geometries[second] + if left.get("cylinder_role") != "inner" or right.get("cylinder_role") != "inner": + return False + left_axis, right_axis = left.get("axis_direction"), right.get("axis_direction") + left_origin, right_origin = left.get("axis_origin_mm"), right.get("axis_origin_mm") + if not all(isinstance(value, list) and len(value) == 3 for value in (left_axis, right_axis, left_origin, right_origin)): + return False + try: + left_direction = tuple(float(value) for value in left_axis) + right_direction = tuple(float(value) for value in right_axis) + offset = tuple(float(left_origin[index]) - float(right_origin[index]) for index in range(3)) + except (TypeError, ValueError): + return False + alignment = sum(left_direction[index] * right_direction[index] for index in range(3)) + if abs(alignment) < 1.0 - 1e-6: + return False + axial_offset = sum(offset[index] * left_direction[index] for index in range(3)) + radial_offset = tuple(offset[index] - axial_offset * left_direction[index] for index in range(3)) + return sum(value * value for value in radial_offset) ** 0.5 <= 1e-5 + + inner_rotational_indexes = [ + index + for index, geometry in enumerate(face_geometries) + if geometry["surface_type"] in {"cylinder", "cone"} and geometry.get("cylinder_role") == "inner" + ] + + def channel_plane_indexes(start: int) -> list[int]: + """Collect endpoint planes through joined, co-axial inner faces. + + A countersink or counterbore splits a physical bore into a cone and + a cylinder. The cylinder has only one direct planar neighbour, so + direct adjacency alone cannot prove that the complete channel exits + the part. Traverse shared B-rep edges only across co-axial inner + rotational faces, then inspect the channel's actual plane ends. + """ + pending = [start] + visited: set[int] = set() + endpoints: set[int] = set() + while pending: + index = pending.pop() + if index in visited: + continue + visited.add(index) + endpoints.update(directly_linked_planes(index)) + pending.extend( + candidate + for candidate in inner_rotational_indexes + if candidate not in visited and same_inner_rotational_channel(index, candidate) + ) + return sorted(endpoints) + + def spans_opposed_planes(linked: list[int], axis: Any) -> bool: + if not isinstance(axis, list) or len(axis) != 3: + return False + try: + direction = tuple(float(value) for value in axis) + except (TypeError, ValueError): + return False + return any( + sum(float(face_geometries[first]["normal"][component]) * float(face_geometries[second]["normal"][component]) for component in range(3)) <= -0.99 + and all(abs(sum(float(face_geometries[position]["normal"][component]) * direction[component] for component in range(3))) >= 0.99 for position in (first, second)) + for first in linked + for second in linked + if first < second + ) + + for index, geometry in enumerate(face_geometries): + if geometry["surface_type"] != "cylinder": + continue + linked = directly_linked_planes(index) + geometry["connected_plane_ids"] = [records[plane_index].record_id for plane_index in linked] + channel_linked = channel_plane_indexes(index) if geometry.get("cylinder_role") == "inner" else linked + geometry["channel_connected_plane_ids"] = [records[plane_index].record_id for plane_index in channel_linked] + geometry["through"] = spans_opposed_planes(channel_linked, geometry.get("axis_direction")) + # 4. 导出边记录:含包围盒、中心、长度、曲线类型与相邻面数;端点坐标可用时附加。 + for index, edge in enumerate(edges): + bbox = edge.bounding_box() + center = edge.center() + vertices = edge.vertices() + geometry = { + "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], + "center_mm": [center.X, center.Y, center.Z], "length_mm": float(edge.length), + "curve_type": str(edge.geom_type).split(".")[-1].lower(), + "adjacent_face_count": len(edge_faces[index]), + } + if geometry["curve_type"] == "circle": + # ``Edge.center()`` is a point on a periodic circle, not its + # geometric centre. Preserve the OCC circle data separately + # so a provenance-backed rotational selector can distinguish + # concentric full circles at different axial locations. + try: + circle_center = edge.arc_center + radius = float(edge.radius) + values = (circle_center.X, circle_center.Y, circle_center.Z, radius) + except (AttributeError, TypeError, ValueError): + values = () + if values and all(math.isfinite(float(value)) for value in values) and radius > 0: + geometry["circle_center_mm"] = [circle_center.X, circle_center.Y, circle_center.Z] + geometry["radius_mm"] = radius + if vertices: + geometry["start_mm"] = list(vertices[0]) + geometry["end_mm"] = list(vertices[-1]) + records.append(TopologyRecord( + record_id=f"{body_id}:edge:{index}", kind="edge", feature_id=feature_id, body_id=body_id, value=edge, + geometry=geometry, + )) + # 5. 导出顶点记录:含坐标与关联边数。 + for index, vertex in enumerate(vertices): + point = [vertex.X, vertex.Y, vertex.Z] + records.append(TopologyRecord( + record_id=f"{body_id}:vertex:{index}", kind="vertex", feature_id=feature_id, body_id=body_id, value=vertex, + geometry={"center_mm": point, "incident_edge_count": len(vertex_edges[index])}, + )) + return records diff --git a/backend/engine/cdsl_engine/translator.py b/backend/engine/cdsl_engine/translator.py deleted file mode 100644 index 74a7439d..00000000 --- a/backend/engine/cdsl_engine/translator.py +++ /dev/null @@ -1,5184 +0,0 @@ -"""Generic SolidWorks JSON/IR to build123d code translator.""" - -from __future__ import annotations - -import json -import math -import os -import re -from copy import deepcopy -from typing import Any, Dict, Optional - - -SW_END_CONDITIONS = { - 0: "Blind", - 1: "ThroughAll", - 2: "ThroughAllBoth", - 3: "UpToVertex", - 4: "UpToSurface", - 5: "OffsetFromSurface", - 6: "ThroughAllAndBlind", - 7: "UpToBody", - 8: "MidPlane", - 9: "ThroughNext", -} - -THROUGH_CUT_AMOUNT_MM = 200 - - -def normalize_to_ir(data: Dict[str, Any]) -> Dict[str, Any]: - """Normalize supported input formats to the backend internal IR.""" - if "operations" in data and "sketches" in data: - return enrich_rebuild_parameters(data) - - if "features" in data: - return enrich_rebuild_parameters(convert_sw_plugin_json_to_ir(data)) - - raise ValueError("Unsupported JSON format: expected internal IR or SW plugin features JSON") - - -def enrich_rebuild_parameters(data: Dict[str, Any]) -> Dict[str, Any]: - """Add a generic editable-parameter index without changing feature history. - - The returned rebuild JSON remains the source of truth for execution. The - `editable_parameters` section is an index of JSON paths that a UI or caller - can modify safely while preserving the original feature order and links. - """ - enriched = dict(data) - enriched["editable_parameters"] = extract_editable_parameters(enriched) - enriched["parameterization_status"] = analyze_parameterization_status(enriched) - return enriched - - -def analyze_parameterization_status(data: Dict[str, Any]) -> Dict[str, Any]: - issues = [] - - for sketch in data.get("sketches", []): - host_reference = sketch.get("host_reference", {}) - reference = host_reference.get("reference") or {} - if reference.get("kind") == "face" and not reference.get("owner_feature"): - issues.append({ - "kind": "missing_stable_face_owner", - "sketch": {"id": sketch.get("id"), "name": sketch.get("name")}, - "message": ( - "Sketch is attached to a face geometry, but the JSON does not identify " - "the owning feature/face id. Parameter edits may require updating this " - "sketch workplane manually unless the plugin exports stable face ownership." - ), - }) - - for op in data.get("operations", []): - if op.get("type") in ("unsupported", "unknown"): - sw_type = op.get("parameters", {}).get("sw_type") or op.get("type") - issues.append({ - "kind": "unsupported_geometry_feature", - "feature": {"id": op.get("id"), "name": op.get("name"), "type": sw_type}, - "message": ( - f"SolidWorks feature '{sw_type}' is present in the history, but the " - "core build123d translator has no generic implementation for it. " - "The feature is retained in IR and must not be treated as a complete rebuild." - ), - }) - - if op.get("type") == "hole": - host_face = op.get("parameters", {}).get("host_face") or {} - if host_face and not host_face.get("frame"): - issues.append({ - "kind": "missing_hole_host_frame", - "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, - "message": ( - "Hole feature has a host face, but the JSON does not include the " - "face-local x/y axes. The translator can infer common axis-aligned " - "cases, but the plugin should export the sketch/face frame for exact " - "generic hole placement." - ), - }) - - if op.get("type") == "extrude_cut": - end_code = op.get("parameters", {}).get("end_condition_code") - if end_code in (3, 4, 5, 7, 9): - params = op.get("parameters", {}) - has_termination_reference = any( - params.get(key) - for key in ( - "end_condition_reference", - "reverse_end_condition_reference", - "termination_reference", - ) - ) - kind = ( - "sw_end_condition_requires_exact_translator" - if has_termination_reference - else "missing_extrude_termination_reference" - ) - issues.append({ - "kind": kind, - "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, - "end_condition_code": end_code, - "end_condition": SW_END_CONDITIONS.get(end_code), - "message": ( - "This SW cut uses a non-blind end condition. ThroughAll can be " - "replayed generically, but ThroughNext/UpTo-style rebuilds need the " - "selected terminating face/body/reference from the plugin for exact 1:1." - ), - }) - - if op.get("type") in ("revolve_cut", "revolve_add"): - axis_reference = op.get("parameters", {}).get("axis_reference") - if not axis_reference or not ( - isinstance(axis_reference, dict) - and axis_reference.get("origin_mm") - and axis_reference.get("direction") - ): - axis_candidates = op.get("parameters", {}).get("axis_candidates") or [] - if axis_candidates: - issues.append({ - "kind": "revolve_axis_inferred_from_candidate", - "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, - "message": ( - "Revolve feature lacks the original SolidWorks selected axis, but " - "the translator can use a construction-line candidate. For exact " - "auditability the plugin should still export the selected axis " - "reference and selection mark." - ), - }) - continue - issues.append({ - "kind": "missing_revolve_axis_reference", - "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, - "message": ( - "Revolve feature does not include the SolidWorks selected axis. " - "The translator can only infer an axis from the sketch workplane, " - "which is not reliable enough for exact 1:1 rebuild." - ), - }) - - if op.get("type") in ("linear_pattern", "pattern_linear"): - params = op.get("parameters", {}) - if not params.get("source_features") or not _linear_pattern_offsets(op): - issues.append({ - "kind": "linear_pattern_missing_source_or_direction", - "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, - "message": ( - "This SW linear pattern lacks source-feature selection or direction data. " - "The translator can replay patterns when source features and offsets are " - "available; otherwise the plugin should export the selected feature list " - "and pattern direction references." - ), - }) - - if op.get("type") in ("fillet", "chamfer"): - selectors = op.get("selectors") or [] - if selectors and not any(_selector_has_persistent_reference(selector) for selector in selectors): - issues.append({ - "kind": "missing_original_feature_selection", - "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, - "message": ( - "This feature only has final-geometry edge signatures. For exact replay " - "the plugin should export the original SolidWorks feature selections " - "including persistent references and selection marks." - ), - }) - - return { - "safe_to_edit": not issues, - "issues": issues, - } - - -def _selector_has_persistent_reference(selector: Dict[str, Any]) -> bool: - stack = [selector] - while stack: - value = stack.pop() - if isinstance(value, dict): - if value.get("persistent_reference"): - return True - stack.extend(value.values()) - elif isinstance(value, list): - stack.extend(value) - return False - - -def extract_editable_parameters(data: Dict[str, Any]) -> list[Dict[str, Any]]: - parameters: list[Dict[str, Any]] = [] - sketches = {sketch.get("id"): sketch for sketch in data.get("sketches", [])} - - for op_index, op in enumerate(data.get("operations", [])): - op_type = op.get("type", "") - op_name = op.get("name", op.get("id", f"operation_{op_index}")) - op_path = f"/operations/{op_index}" - params = op.get("parameters", {}) - - if op_type in ("extrude_add", "extrude_cut") and "distance_mm" in params: - semantic = "body_length" if op_type == "extrude_add" else "cut_depth" - parameters.append(_editable_param( - id=f"{op.get('id', op_index)}.distance_mm", - label=f"{op_name} distance", - semantic=semantic, - unit="mm", - value=params.get("distance_mm"), - path=f"{op_path}/parameters/distance_mm", - feature=op, - )) - - if "reverse_distance_mm" in params: - parameters.append(_editable_param( - id=f"{op.get('id', op_index)}.reverse_distance_mm", - label=f"{op_name} reverse distance", - semantic="reverse_depth", - unit="mm", - value=params.get("reverse_distance_mm"), - path=f"{op_path}/parameters/reverse_distance_mm", - feature=op, - )) - - if op_type in ("fillet", "chamfer"): - key = "radius_mm" if op_type == "fillet" else "distance_mm" - if key in params: - parameters.append(_editable_param( - id=f"{op.get('id', op_index)}.{key}", - label=f"{op_name} {key}", - semantic="fillet_radius" if op_type == "fillet" else "chamfer_distance", - unit="mm", - value=params.get(key), - path=f"{op_path}/parameters/{key}", - feature=op, - )) - - sketch_id = op.get("sketch") - sketch = sketches.get(sketch_id) - if sketch: - parameters.extend(_extract_sketch_parameters(sketch, sketch_id, op, op_index, data)) - - return parameters - - -def _extract_sketch_parameters( - sketch: Dict[str, Any], - sketch_id: str, - op: Dict[str, Any], - op_index: int, - data: Dict[str, Any], -) -> list[Dict[str, Any]]: - parameters: list[Dict[str, Any]] = [] - sketch_index = next((i for i, item in enumerate(data.get("sketches", [])) if item.get("id") == sketch_id), None) - if sketch_index is None: - return parameters - - op_type = op.get("type", "") - entities = sketch.get("entities", []) - drawable = [entity for entity in entities if not entity.get("construction", False)] - - for entity_index, entity in enumerate(entities): - entity_type = entity.get("type") - entity_path = f"/sketches/{sketch_index}/entities/{entity_index}" - - if entity_type in ("circle", "arc") and entity.get("is_circle", entity_type == "circle"): - center = entity.get("center", [0, 0, 0]) - radius = entity.get("radius_mm") - semantic = "hole" if op_type == "extrude_cut" else "circle_profile" - if radius is not None: - parameters.append(_editable_param( - id=f"{sketch_id}.entity{entity_index}.radius_mm", - label=f"{sketch.get('name', sketch_id)} circle radius", - semantic=f"{semantic}_radius", - unit="mm", - value=radius, - path=f"{entity_path}/radius_mm", - feature=op, - )) - for axis, value in zip(("x", "y"), center[:2]): - parameters.append(_editable_param( - id=f"{sketch_id}.entity{entity_index}.center_{axis}", - label=f"{sketch.get('name', sketch_id)} {semantic} center {axis}", - semantic=f"{semantic}_center_{axis}", - unit="mm", - value=value, - path=f"{entity_path}/center/{0 if axis == 'x' else 1}", - feature=op, - )) - - bounds = _sketch_bounds(drawable) - if bounds: - min_x, min_y, max_x, max_y = bounds - center_x = (min_x + max_x) / 2 - center_y = (min_y + max_y) / 2 - width = max_x - min_x - height = max_y - min_y - semantic_prefix = "slot" if op_type == "extrude_cut" else "profile" - for suffix, value, semantic in ( - ("center_x", center_x, f"{semantic_prefix}_center_x"), - ("center_y", center_y, f"{semantic_prefix}_center_y"), - ("width", width, f"{semantic_prefix}_width"), - ("height", height, f"{semantic_prefix}_height"), - ): - parameters.append(_editable_param( - id=f"{sketch_id}.{suffix}", - label=f"{sketch.get('name', sketch_id)} {suffix}", - semantic=semantic, - unit="mm", - value=value, - path=f"/sketches/{sketch_index}", - feature=op, - editable=False, - note="Derived from sketch entity bounds; edit underlying entities to change this safely.", - )) - - workplane = sketch.get("workplane", {}) - origin = workplane.get("origin_mm") - if origin: - for axis, value in zip(("x", "y", "z"), origin[:3]): - parameters.append(_editable_param( - id=f"{sketch_id}.workplane_origin_{axis}", - label=f"{sketch.get('name', sketch_id)} workplane origin {axis}", - semantic=f"sketch_plane_origin_{axis}", - unit="mm", - value=value, - path=f"/sketches/{sketch_index}/workplane/origin_mm/{'xyz'.index(axis)}", - feature=op, - )) - - return parameters - - -def _sketch_bounds(entities: list[Dict[str, Any]]) -> Optional[tuple[float, float, float, float]]: - points: list[tuple[float, float]] = [] - for entity in entities: - for key in ("start", "end", "center"): - point = entity.get(key) - if point and len(point) >= 2: - points.append((float(point[0]), float(point[1]))) - radius = entity.get("radius_mm") - center = entity.get("center") - if radius is not None and center and len(center) >= 2: - cx, cy = float(center[0]), float(center[1]) - r = float(radius) - points.extend([(cx - r, cy - r), (cx + r, cy + r)]) - if not points: - return None - xs = [point[0] for point in points] - ys = [point[1] for point in points] - return min(xs), min(ys), max(xs), max(ys) - - -def _editable_param( - *, - id: str, - label: str, - semantic: str, - unit: str, - value: Any, - path: str, - feature: Dict[str, Any], - editable: bool = True, - note: Optional[str] = None, -) -> Dict[str, Any]: - result = { - "id": id, - "label": label, - "semantic": semantic, - "unit": unit, - "value": value, - "path": path, - "editable": editable, - "feature": { - "id": feature.get("id"), - "name": feature.get("name"), - "type": feature.get("type"), - "source_index": feature.get("source_feature", {}).get("index"), - }, - } - if note: - result["note"] = note - return result - - -def convert_sw_plugin_json_to_ir(data: Dict[str, Any]) -> Dict[str, Any]: - """Convert the current SW plugin feature dump into the backend IR.""" - features = data.get("features", []) - sketches = [] - operations = [] - last_sketch_id = None - last_build_op = None - references = [] - source_bbox = _source_bbox_from_plugin_json(data) - - if data.get("document_kind") == "assembly" and isinstance(data.get("assembly_data"), dict): - operations.append(_convert_sw_assembly(data)) - part_name = data.get("part_name", "part") - return { - "version": "ir-0.1", - "metadata": { - "source": { - "format": "sw-plugin-json", - "file_name": f"{part_name}.sldasm", - "sw_version": data.get("sw_version"), - } - }, - "sketches": sketches, - "operations": operations, - "references": references, - "validation_hints": data.get("validation_hints", {}), - "geometry_inventory": data.get("geometry_inventory", {}), - "rebuild_contract": data.get("rebuild_contract", {}), - } - - for index, feature in enumerate(features): - if feature.get("is_suppressed"): - continue - - feature_type = feature.get("type", "") - type_name = feature.get("type_name", "") - feature_id = feature.get("id") or f"feat_{index:03d}" - feature_name = feature.get("name", feature_id) - - if feature_type in ("refplane", "refaxis"): - references.append(_convert_sw_reference(feature, index)) - elif feature_type == "sketch": - sketch_id = f"sketch_{len(sketches):03d}" - sketches.append(_convert_sw_sketch(feature, sketch_id, index)) - last_sketch_id = sketch_id - elif feature_type in ("extrude", "ice", "cut") and isinstance(feature.get("extrude_data"), dict): - sketch_ref = _append_feature_source_sketches(feature, sketches, index) or last_sketch_id - op = _convert_sw_extrude(feature, type_name, sketch_ref, index) - operations.append(op) - last_build_op = op - elif feature_type == "revolve": - sketch_ref = _append_feature_source_sketches(feature, sketches, index) or last_sketch_id - op = _convert_sw_revolve(feature, type_name, sketch_ref, index) - operations.append(op) - last_build_op = op - elif feature_type == "hole": - op = _convert_sw_hole(feature, index) - operations.append(op) - last_build_op = op - elif feature_type == "pattern_linear": - data_block = feature.get("linear_pattern_data", {}) - source_op = _find_source_operation_for_pattern(operations, data_block.get("source_features") or []) - source_frame = _source_pattern_frame(source_op or last_build_op, sketches) - operations.append(_convert_sw_linear_pattern(feature, index, source_op or last_build_op, source_frame, sketches, source_bbox)) - elif feature_type == "pattern_mirror": - data_block = feature.get("mirror_data") or {} - src_features = data_block.get("source_features") or [] - mirror_origin = data_block.get("mirror_plane_origin") - mirror_normal = data_block.get("mirror_plane_normal") - operations.append({ - "id": feature_id, - "name": feature_name, - "type": "pattern_mirror", - "parameters": {"source_features": src_features}, - "raw_parameters": { - "mirror_plane_origin": mirror_origin, - "mirror_plane_normal": mirror_normal, - }, - "source_feature": _source_feature(feature, index), - }) - elif feature_type == "fillet": - data_block = feature.get("fillet_data", {}) - radius_mm = data_block.get("radius") or _feature_length_dimension_mm(feature) - operations.append({ - "id": feature_id, - "name": feature_name, - "type": "fillet", - "parameters": {"radius_mm": radius_mm}, - "selectors": _feature_selection_selectors(feature, data_block), - "selection_source": _feature_selection_source(feature, data_block), - "source_feature": _source_feature(feature, index), - "source_owned_faces": _source_owned_faces(feature), - }) - elif feature_type == "chamfer": - data_block = feature.get("chamfer_data", {}) - distance_mm = data_block.get("distance") or _feature_length_dimension_mm(feature) - operations.append({ - "id": feature_id, - "name": feature_name, - "type": "chamfer", - "parameters": {"distance_mm": distance_mm}, - "selectors": _feature_selection_selectors(feature, data_block), - "selection_source": _feature_selection_source(feature, data_block), - "source_feature": _source_feature(feature, index), - "source_owned_faces": _source_owned_faces(feature), - }) - elif _is_imported_body_feature(feature): - op = _convert_sw_imported_body(feature, index) - operations.append(op) - last_build_op = op - elif feature_type == "moveface": - data_block = feature.get("move_face_data") if isinstance(feature.get("move_face_data"), dict) else {} - op = { - "id": feature_id, - "name": feature_name, - "type": "move_face", - "parameters": {"sw_type": type_name or feature_type, "move_face_data": data_block}, - "source_feature": _source_feature(feature, index), - } - operations.append(op) - last_build_op = op - elif feature_type not in _SW_METADATA_FEATURE_TYPES: - operations.append({ - "id": feature_id, - "name": feature_name, - "type": "unsupported", - "parameters": {"sw_type": type_name or feature_type}, - "source_feature": _source_feature(feature, index), - }) - - part_name = data.get("part_name", "part") - return { - "version": "ir-0.1", - "metadata": { - "source": { - "format": "sw-plugin-json", - "file_name": f"{part_name}.sldprt", - "sw_version": data.get("sw_version"), - } - }, - "sketches": sketches, - "operations": operations, - "references": references, - "validation_hints": data.get("validation_hints", {}), - "geometry_inventory": data.get("geometry_inventory", {}), - "rebuild_contract": data.get("rebuild_contract", {}), - } - - -def _is_imported_body_feature(feature: Dict[str, Any]) -> bool: - feature_type = str(feature.get("type") or "").lower() - type_name = str(feature.get("type_name") or "").lower() - return bool(feature.get("imported_body_data")) or feature_type in { - "mbimport", - "savedextbody", - "importedbody", - "imported", - "stock", - } or type_name in {"mbimport", "savedextbody", "importedbody"} - - -def _convert_sw_imported_body(feature: Dict[str, Any], index: int) -> Dict[str, Any]: - data_block = feature.get("imported_body_data") if isinstance(feature.get("imported_body_data"), dict) else {} - solid_bodies = data_block.get("solid_bodies") or [] - solid_body_stats = data_block.get("solid_body_stats") or [] - source_name = feature.get("name") - parameters = { - "sw_type": feature.get("type_name") or feature.get("type"), - "source_name": source_name, - "history_status": data_block.get("history_status"), - "body_count": len(solid_bodies) if isinstance(solid_bodies, list) else len(solid_body_stats), - "solid_body_stats": solid_body_stats, - "solid_bodies": solid_bodies, - } - return { - "id": feature.get("id") or f"feat_{index:03d}", - "name": feature.get("name") or f"imported_body_{index:03d}", - "type": "imported_body", - "parameters": parameters, - "source_feature": _source_feature(feature, index), - "source_imported_body": data_block, - } - - -def _convert_sw_assembly(data: Dict[str, Any]) -> Dict[str, Any]: - assembly_data = data.get("assembly_data") or {} - components = [] - for index, component in enumerate(assembly_data.get("components") or []): - if component.get("is_suppressed") or component.get("is_hidden"): - continue - path = component.get("path") or "" - component_name = component.get("name") or f"component_{index:03d}" - base_name = os.path.splitext(os.path.basename(str(path).replace("\\", "/")))[0] or component_name - components.append({ - "index": index, - "name": component_name, - "component_id": base_name, - "source_path": path, - "component_json": f"{base_name}.solidworks_rebuild_extract.json", - "transform": component.get("transform") or {}, - }) - return { - "id": "assembly_000", - "name": data.get("part_name") or "assembly", - "type": "assembly_compose", - "parameters": { - "components": components, - }, - "source_feature": {"index": 0, "name": data.get("part_name"), "type": "assembly"}, - } - - -def _append_feature_source_sketches(feature: Dict[str, Any], sketches: list[Dict[str, Any]], index: int) -> Optional[str]: - """Promote feature-owned SW sketches into the rebuild sketch table.""" - source_sketches = [] - for block_name in ("extrude_data", "revolve_data"): - block = feature.get(block_name) - if isinstance(block, dict): - source_sketches.extend(sketch for sketch in (block.get("source_sketches") or []) if isinstance(sketch, dict)) - - if not source_sketches: - return None - - last_id = None - for sketch_data in source_sketches: - sketch_id = f"sketch_{len(sketches):03d}" - sketch_feature = dict(feature) - sketch_feature["sketch_data"] = sketch_data - if sketch_data.get("name"): - sketch_feature["name"] = sketch_data.get("name") - sketches.append(_convert_sw_sketch(sketch_feature, sketch_id, index)) - last_id = sketch_id - return last_id - - -def get_part_name(data: Dict[str, Any]) -> str: - part_name = data.get("part_name") or data.get("metadata", {}).get("source", {}).get("file_name", "part") - part_name = str(part_name) - for suffix in (".sldprt", ".sldasm", ".step", ".stp", ".json"): - if part_name.lower().endswith(suffix): - part_name = part_name[:-len(suffix)] - break - return re.sub(r"[^0-9A-Za-z_\u4e00-\u9fff]+", "_", part_name).strip("_") or "part" - - -def generate_build123d_code(data: Dict[str, Any], gold_volume_mm3: float | None = None) -> str: - """Generate build123d Python code from generic SW/build123d IR.""" - rebuild_contract = data.get("rebuild_contract") if isinstance(data.get("rebuild_contract"), dict) else {} - if rebuild_contract and rebuild_contract.get("ready") is False: - blockers = rebuild_contract.get("blockers") or [] - raise ValueError(f"Pure-JSON rebuild contract is not ready: {blockers}") - source_volume_mm3 = None - source_area_mm2 = None - mass_props = data.get("validation_hints", {}).get("mass_properties_raw") - if mass_props and len(mass_props) >= 5: - source_volume_mm3 = float(mass_props[3]) * 1_000_000_000 - source_area_mm2 = float(mass_props[4]) * 1_000_000 - lines = [ - "from build123d import *", - "import math", - f"SOURCE_VOLUME_MM3 = {source_volume_mm3!r}", - f"SOURCE_AREA_MM2 = {source_area_mm2!r}", - "", - "def _dist(a, b):", - " return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))", - "", - "def _owned_face_match_score(shape, expected_faces):", - " if not expected_faces:", - " return 0.0", - " try:", - " available = list(shape.faces())", - " except Exception:", - " return 1e99", - " total = 0.0", - " for expected in expected_faces:", - " bbox_m = expected.get('box_m')", - " if not bbox_m or len(bbox_m) < 6 or not available:", - " total += 1e6", - " continue", - " target_box = [float(v) * 1000 for v in bbox_m[:6]]", - " surface = expected.get('surface') or {}", - " target_type = next((name for name in ('plane', 'cylinder', 'cone', 'sphere', 'torus') if surface.get('is_' + name)), '')", - " target_area = float(expected.get('area_m2') or 0) * 1_000_000", - " ranked = []", - " for index, face in enumerate(available):", - " try:", - " fb = face.bounding_box()", - " face_box = [fb.min.X, fb.min.Y, fb.min.Z, fb.max.X, fb.max.Y, fb.max.Z]", - " geom = face.geom_type() if callable(face.geom_type) else face.geom_type", - " geom_name = getattr(geom, 'name', str(geom)).lower()", - " type_penalty = 0.0 if not target_type or target_type in geom_name else 1000.0", - " bbox_penalty = sum(abs(face_box[i] - target_box[i]) for i in range(6))", - " area_penalty = abs(float(face.area) - target_area) / max(math.sqrt(abs(target_area)), 1.0) if target_area else 0.0", - " ranked.append((type_penalty + bbox_penalty + area_penalty, index))", - " except Exception:", - " continue", - " if not ranked:", - " total += 1e6", - " continue", - " best, index = min(ranked, key=lambda item: item[0])", - " total += best", - " available.pop(index)", - " return total / max(len(expected_faces), 1)", - "", - "def _candidate_score(shape, expected_faces=None):", - " # Owned faces describe this exact SW history step. Final-part mass properties", - " # must not be used to choose an intermediate feature candidate.", - " if expected_faces:", - " return _owned_face_match_score(shape, expected_faces)", - " score = 0", - " if SOURCE_VOLUME_MM3 is not None:", - " try:", - " score += abs(float(shape.volume) - SOURCE_VOLUME_MM3)", - " except Exception:", - " score += 1e99", - " if SOURCE_AREA_MM2 is not None:", - " try:", - " score += abs(float(shape.area) - SOURCE_AREA_MM2) * 0.01", - " except Exception:", - " score += 1e99", - " score += _owned_face_match_score(shape, expected_faces)", - " return score", - "", - "def _edge_endpoints(edge):", - " vertices = [v.to_tuple() for v in edge.vertices()]", - " if len(vertices) != 2:", - " center = edge.center().to_tuple()", - " return center, center", - " return vertices[0], vertices[1]", - "", - "def _edge_match_score(edge, start, end):", - " a, b = _edge_endpoints(edge)", - " endpoint_score = min(_dist(a, start) + _dist(b, end), _dist(a, end) + _dist(b, start))", - " containment_score = edge.distance_to(start) + edge.distance_to(end)", - " return min(endpoint_score, containment_score)", - "", - "def select_edges_by_endpoints(part, selector_points, tolerance=0.5):", - " edges = list(part.edges())", - " selected = []", - " used = set()", - " for selector in selector_points:", - " start, end = selector", - " ranked = sorted(((_edge_match_score(edge, start, end), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", - " score, index, edge = ranked[0]", - " if score > tolerance:", - " raise ValueError(f\"No edge matched selector {selector}; best score={score:.4f} mm\")", - " if index not in used:", - " selected.append(edge)", - " used.add(index)", - " return selected", - "", - "def _bbox_match_score(edge, bbox_mm):", - " if not bbox_mm or len(bbox_mm) < 6:", - " return float('inf')", - " try:", - " a, b = _edge_endpoints(edge)", - " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", - " mins = tuple(float(bbox_mm[i]) for i in range(3))", - " maxs = tuple(float(bbox_mm[i + 3]) for i in range(3))", - " diag = math.sqrt(sum((maxs[i] - mins[i]) ** 2 for i in range(3)))", - " pad = max(0.25, diag * 0.15)", - " def point_score(point):", - " total = 0.0", - " for axis in range(3):", - " if point[axis] < mins[axis] - pad:", - " total += mins[axis] - pad - point[axis]", - " elif point[axis] > maxs[axis] + pad:", - " total += point[axis] - maxs[axis] - pad", - " return total", - " return min(point_score(mid), (point_score(a) + point_score(b)) / 2)", - " except Exception:", - " return float('inf')", - "", - "def _circle_match_score(edge, circle_params):", - " if not circle_params or len(circle_params) < 7:", - " return float('inf')", - " try:", - " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", - " geom_name = getattr(geom_type, 'name', str(geom_type))", - " if 'CIRCLE' not in geom_name:", - " return float('inf')", - " target_center = tuple(float(v) * 1000 for v in circle_params[:3])", - " target_radius = float(circle_params[6]) * 1000", - " edge_center = edge.arc_center.to_tuple()", - " return _dist(edge_center, target_center) + abs(edge.radius - target_radius)", - " except Exception:", - " return float('inf')", - "", - "def _line_match_score(edge, line_params):", - " if not line_params or len(line_params) < 6:", - " return float('inf')", - " try:", - " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", - " geom_name = getattr(geom_type, 'name', str(geom_type))", - " if 'LINE' not in geom_name:", - " return float('inf')", - " target_point = tuple(float(v) * 1000 for v in line_params[:3])", - " target_dir = tuple(float(v) for v in line_params[3:6])", - " a, b = _edge_endpoints(edge)", - " edge_dir_raw = tuple(b[i] - a[i] for i in range(3))", - " length = math.sqrt(sum(v * v for v in edge_dir_raw))", - " if length <= 0:", - " return float('inf')", - " edge_dir = tuple(v / length for v in edge_dir_raw)", - " parallel = 1 - abs(sum(edge_dir[i] * target_dir[i] for i in range(3)))", - " distance = edge.distance_to(target_point)", - " return distance + parallel * 10", - " except Exception:", - " return float('inf')", - "", - "def select_edges_by_selectors(part, selectors, tolerance=0.5):", - " if part is None:", - " return []", - " edges = list(part.edges())", - " selected = []", - " used = set()", - " for selector in selectors or []:", - " geometry = selector.get('geometry', {})", - " start_vertex = geometry.get('start_vertex')", - " end_vertex = geometry.get('end_vertex')", - " start = start_vertex.get('point_m') if start_vertex else None", - " end = end_vertex.get('point_m') if end_vertex else None", - " bbox_mm = geometry.get('bbox_mm')", - " if start and end:", - " start_mm = tuple(float(v) * 1000 for v in start)", - " end_mm = tuple(float(v) * 1000 for v in end)", - " line_params = geometry.get('curve', {}).get('line_params')", - " if line_params:", - " ranked = sorted(((min(_edge_match_score(edge, start_mm, end_mm), _line_match_score(edge, line_params)) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", - " else:", - " ranked = sorted(((_edge_match_score(edge, start_mm, end_mm) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", - " else:", - " line_params = geometry.get('curve', {}).get('line_params')", - " circle_params = geometry.get('curve', {}).get('circle_params')", - " if line_params:", - " ranked = sorted(((_line_match_score(edge, line_params) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", - " elif bbox_mm:", - " ranked = sorted(((_bbox_match_score(edge, bbox_mm), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", - " else:", - " ranked = sorted(((_circle_match_score(edge, circle_params), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", - " score, index, edge = ranked[0]", - " selector_tolerance = float(selector.get('tolerance_mm') or tolerance)", - " if score > selector_tolerance:", - " # Skip edges that don't match well enough", - " continue", - " if index not in used:", - " selected.append(edge)", - " used.add(index)", - " return selected", - "", - "def _point_inside_bbox(point, bbox_mm, pad=0.25):", - " return all(float(bbox_mm[i]) - pad <= point[i] <= float(bbox_mm[i + 3]) + pad for i in range(3))", - "", - "def fillet_edges_from_owned_surface_bbox(part, selectors):", - " if part is None:", - " return []", - " boxes = []", - " seen_boxes = set()", - " for selector in selectors or []:", - " if selector.get('source') not in ('owned_cylindrical_face_axis', 'owned_face_bbox'):", - " continue", - " bbox = (selector.get('geometry') or {}).get('bbox_mm')", - " if bbox and len(bbox) >= 6:", - " normalized = [float(v) for v in bbox[:6]]", - " key = tuple(round(v, 6) for v in normalized)", - " if key not in seen_boxes:", - " seen_boxes.add(key)", - " boxes.append(normalized)", - " if len(boxes) < 2:", - " return []", - " selected = []", - " used_keys = set()", - " for box in boxes:", - " diag = math.sqrt(sum((box[i + 3] - box[i]) ** 2 for i in range(3)))", - " pad = max(0.25, diag * 0.08)", - " sizes = [abs(box[i + 3] - box[i]) for i in range(3)]", - " thin_axes = [i for i, size in enumerate(sizes) if size <= max(1.5, diag * 0.08)]", - " circle_candidates = []", - " if thin_axes:", - " thin_axis = thin_axes[0]", - " for edge in part.edges():", - " try:", - " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", - " geom_name = getattr(geom_type, 'name', str(geom_type))", - " if 'CIRCLE' not in geom_name:", - " continue", - " eb = edge.bounding_box()", - " edge_box = [eb.min.X, eb.min.Y, eb.min.Z, eb.max.X, eb.max.Y, eb.max.Z]", - " ok = True", - " score = 0.0", - " for axis in range(3):", - " if axis == thin_axis:", - " plane_delta = min(abs(edge_box[axis] - box[axis]), abs(edge_box[axis] - box[axis + 3]), abs(edge_box[axis + 3] - box[axis]), abs(edge_box[axis + 3] - box[axis + 3]))", - " if plane_delta > pad:", - " ok = False", - " break", - " score += plane_delta", - " else:", - " if edge_box[axis] < box[axis] - pad or edge_box[axis + 3] > box[axis + 3] + pad:", - " ok = False", - " break", - " score += abs(edge_box[axis] - box[axis]) + abs(edge_box[axis + 3] - box[axis + 3])", - " if not ok:", - " continue", - " key = tuple(round(v, 5) for v in edge_box)", - " circle_candidates.append((score, key, edge))", - " except Exception:", - " continue", - " if circle_candidates:", - " circle_candidates.sort(key=lambda item: item[0])", - " for _, key, edge in circle_candidates:", - " if key in used_keys:", - " continue", - " used_keys.add(key)", - " selected.append(edge)", - " break", - " continue", - " box_candidates = []", - " for edge in part.edges():", - " try:", - " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", - " geom_name = getattr(geom_type, 'name', str(geom_type))", - " if 'LINE' not in geom_name:", - " continue", - " a, b = _edge_endpoints(edge)", - " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", - " if not (_point_inside_bbox(a, box, pad) and _point_inside_bbox(b, box, pad) and _point_inside_bbox(mid, box, pad)):", - " continue", - " key = tuple(round(v, 5) for point in (a, b) for v in point)", - " box_candidates.append((float(edge.length), key, edge))", - " except Exception:", - " continue", - " if not box_candidates:", - " continue", - " box_candidates.sort(key=lambda item: item[0], reverse=True)", - " for _, key, edge in box_candidates:", - " reverse_key = key[3:] + key[:3]", - " if key in used_keys or reverse_key in used_keys:", - " continue", - " used_keys.add(key)", - " selected.append(edge)", - " break", - " if selected:", - " return selected", - " union_bbox = [", - " min(box[i] for box in boxes) if i < 3 else max(box[i] for box in boxes)", - " for i in range(6)", - " ]", - " diag = math.sqrt(sum((union_bbox[i + 3] - union_bbox[i]) ** 2 for i in range(3)))", - " pad = max(0.25, diag * 0.05)", - " candidates = []", - " for edge in part.edges():", - " try:", - " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", - " geom_name = getattr(geom_type, 'name', str(geom_type))", - " if 'LINE' not in geom_name:", - " continue", - " a, b = _edge_endpoints(edge)", - " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", - " if not (_point_inside_bbox(a, union_bbox, pad) and _point_inside_bbox(b, union_bbox, pad) and _point_inside_bbox(mid, union_bbox, pad)):", - " continue", - " candidates.append((float(edge.length), edge))", - " except Exception:", - " continue", - " if not candidates:", - " return []", - " candidates.sort(key=lambda item: item[0], reverse=True)", - " return [candidates[0][1]]", - "", - "def fillet_with_tolerance(edges, radius):", - " radii = [float(radius)]", - " shrink = max(0.001, abs(float(radius)) * 0.001)", - " if float(radius) > shrink:", - " radii.append(float(radius) - shrink)", - " radii.append(float(radius) * 0.99)", - " last_error = None", - " for candidate_radius in radii:", - " if candidate_radius <= 0:", - " continue", - " try:", - " return fillet(edges, radius=candidate_radius)", - " except Exception as exc:", - " last_error = exc", - " continue", - " if last_error:", - " raise last_error", - " return fillet(edges, radius=radius)", - "", - "def fillet_selected(part, radius, selectors, owned_faces=None):", - " if part is None:", - " return part", - " if not selectors:", - " # No edge selectors - skip fillet to avoid failing on all edges", - " return part", - " candidates = []", - " owned_edges = fillet_edges_from_owned_surface_bbox(part, selectors)", - " if owned_edges:", - " try:", - " candidates.append(fillet_with_tolerance(owned_edges, radius))", - " except Exception:", - " pass", - " try:", - " target_edges = select_edges_by_selectors(part, selectors)", - " if target_edges:", - " candidates.append(fillet_with_tolerance(target_edges, radius))", - " except Exception:", - " pass", - " result = part", - " applied_any = False", - " for selector in selectors:", - " edges = select_edges_by_selectors(result, [selector])", - " if not edges:", - " continue # Skip selectors that don't match any edge", - " try:", - " result = fillet_with_tolerance([edges[0]], radius)", - " applied_any = True", - " except Exception:", - " # OCC fillets are fragile: one invalid edge/radius should not abort the whole rebuild.", - " continue", - " if applied_any:", - " candidates.append(result)", - " variants = []", - " for selector in selectors:", - " edges = select_edges_by_selectors(part, [selector])", - " if not edges:", - " continue", - " try:", - " variants.append(fillet_with_tolerance([edges[0]], radius))", - " except Exception:", - " continue", - " if variants:", - " try:", - " union_result = part", - " for variant in variants:", - " union_result = union_result + variant", - " candidates.append(union_result)", - " except Exception:", - " pass", - " try:", - " intersection_result = part", - " for variant in variants:", - " intersection_result = intersection_result & variant", - " candidates.append(intersection_result)", - " except Exception:", - " pass", - " if candidates:", - " return sorted(candidates, key=lambda shape: _candidate_score(shape, owned_faces))[0]", - " return part", - "", - "def chamfer_selected(part, distance, selectors, owned_faces=None):", - " if part is None:", - " return part", - " if not selectors:", - " # No edge selectors available - chamfer would fail on all edges", - " return part", - " candidates = []", - " owned_edges = fillet_edges_from_owned_surface_bbox(part, selectors)", - " if owned_edges:", - " try:", - " candidates.append(chamfer(owned_edges, length=distance))", - " except Exception:", - " pass", - " target_edges = select_edges_by_selectors(part, selectors)", - " if target_edges:", - " try:", - " candidates.append(chamfer(target_edges, length=distance))", - " except Exception:", - " pass", - " result = part", - " applied_any = False", - " for selector in selectors:", - " edges = select_edges_by_selectors(result, [selector])", - " if not edges:", - " continue", - " try:", - " result = chamfer([edges[0]], length=distance)", - " applied_any = True", - " except Exception:", - " continue", - " if applied_any:", - " candidates.append(result)", - " if candidates:", - " return sorted(candidates, key=lambda shape: _candidate_score(shape, owned_faces))[0]", - " return part", - "", - "def is_internal_cone_face(face, part):", - " try:", - " bbox_m = face.get('box_m')", - " surface = face.get('surface') or {}", - " if not (bbox_m and len(bbox_m) >= 6 and surface.get('is_cone')):", - " return False", - " params = surface.get('cone_params')", - " if not params or len(params) < 6:", - " return False", - " direction = tuple(float(v) for v in params[3:6])", - " axis = max(range(3), key=lambda i: abs(direction[i]))", - " radial_axes = tuple(i for i in range(3) if i != axis)", - " part_bbox = part.bounding_box()", - " part_min = part_bbox.min.to_tuple()", - " part_max = part_bbox.max.to_tuple()", - " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", - " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", - " tol = 0.25", - " touches_outer = any(", - " abs(mins[i] - part_min[i]) <= tol or abs(maxs[i] - part_max[i]) <= tol", - " for i in radial_axes", - " )", - " return not touches_outer", - " except Exception:", - " return False", - "", - "def is_external_cone_face(face, part):", - " try:", - " bbox_m = face.get('box_m')", - " surface = face.get('surface') or {}", - " if not (bbox_m and len(bbox_m) >= 6 and surface.get('is_cone')):", - " return False", - " params = surface.get('cone_params')", - " if not params or len(params) < 6:", - " return False", - " direction = tuple(float(v) for v in params[3:6])", - " axis = max(range(3), key=lambda i: abs(direction[i]))", - " radial_axes = tuple(i for i in range(3) if i != axis)", - " part_bbox = part.bounding_box()", - " part_min = part_bbox.min.to_tuple()", - " part_max = part_bbox.max.to_tuple()", - " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", - " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", - " tol = 0.25", - " return any(", - " abs(mins[i] - part_min[i]) <= tol or abs(maxs[i] - part_max[i]) <= tol", - " for i in radial_axes", - " )", - " except Exception:", - " return False", - "", - "def make_owned_external_cone_chamfer_cutter(face):", - " surface = face.get('surface') or {}", - " params = surface.get('cone_params')", - " bbox_m = face.get('box_m')", - " if not params or len(params) < 8 or not bbox_m or len(bbox_m) < 6:", - " return None", - " origin = tuple(float(v) * 1000 for v in params[:3])", - " direction = tuple(float(v) for v in params[3:6])", - " norm = math.sqrt(sum(v * v for v in direction))", - " base_radius = abs(float(params[6]) * 1000)", - " half_angle = abs(float(params[7]))", - " if norm <= 1e-9 or base_radius <= 1e-9 or half_angle <= 1e-9:", - " return None", - " direction = tuple(v / norm for v in direction)", - " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", - " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", - " projections = []", - " for x in (mins[0], maxs[0]):", - " for y in (mins[1], maxs[1]):", - " for z in (mins[2], maxs[2]):", - " delta = (x - origin[0], y - origin[1], z - origin[2])", - " axial = sum(delta[i] * direction[i] for i in range(3))", - " projections.append(axial)", - " start = min(projections)", - " end = max(projections)", - " height = max(0.001, end - start)", - " r1 = max(0.0, base_radius - math.tan(half_angle) * start)", - " r2 = max(0.0, base_radius - math.tan(half_angle) * end)", - " outer_radius = max(r1, r2) + 0.001", - " center_offset = (start + end) / 2", - " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", - " if r1 <= 1e-9:", - " r1 = 1e-6", - " if r2 <= 1e-9:", - " r2 = 1e-6", - " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", - " Cylinder(outer_radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", - " Cone(r1, r2, height + 0.002, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT)", - " return cutter_part.part", - "", - "def chamfer_owned_external_cones(part, faces):", - " if part is None:", - " return part, False", - " result = part", - " applied = False", - " for face in faces or []:", - " if not is_external_cone_face(face, result):", - " continue", - " cutter = make_owned_external_cone_chamfer_cutter(face)", - " new_result = safe_subtract(result, cutter)", - " if new_result is not result:", - " result = new_result", - " applied = True", - " return result, applied", - "", - "def chamfer_owned_internal_cones(part, faces):", - " if part is None:", - " return part, False", - " result = part", - " applied = False", - " for face in faces or []:", - " if not is_internal_cone_face(face, result):", - " continue", - " cutter = make_owned_cone_cutter(face)", - " new_result = safe_subtract(result, cutter)", - " if new_result is not result:", - " result = new_result", - " applied = True", - " return result, applied", - "", - "def chamfer_selected_with_owned_faces(part, distance, selectors, owned_faces):", - " cone_faces = [face for face in (owned_faces or []) if (face.get('surface') or {}).get('is_cone')]", - " if len(cone_faces) == 1 and is_internal_cone_face(cone_faces[0], part):", - " result, applied = chamfer_owned_internal_cones(part, cone_faces)", - " if applied:", - " return result", - " if len(cone_faces) == 1 and is_external_cone_face(cone_faces[0], part):", - " result, applied = chamfer_owned_external_cones(part, cone_faces)", - " if applied:", - " return result", - " return chamfer_selected(part, distance, selectors, owned_faces)", - "", - "def safe_subtract(part, cutter):", - " if part is None or cutter is None:", - " return part", - " try:", - " vol_before = float(part.volume)", - " except Exception:", - " vol_before = -1", - " try:", - " cut = part - cutter", - " if cut is None:", - " print(f' SUBTRACT: cutter resulted in None, keeping original (vol={vol_before:.0f})')", - " return part", - " # Accept the cut even when solids() reports 0 – can happen", - " # for valid boolean results with non-standard structures.", - " try:", - " nb_solids = len(list(cut.solids()))", - " if nb_solids == 0:", - " print(f' SUBTRACT: cut produced 0 solids (still accepting) vol={vol_before:.0f}')", - " except Exception:", - " pass", - " return cut", - " except Exception as e:", - " print(f' SUBTRACT: exception {type(e).__name__}: {e}, keeping original (vol={vol_before:.0f})')", - " return part", - "", - "def _project_bbox_along_direction(bbox, origin, direction):", - " mins = tuple(float(bbox[i]) for i in range(3))", - " maxs = tuple(float(bbox[i + 3]) for i in range(3))", - " projections = []", - " for x in (mins[0], maxs[0]):", - " for y in (mins[1], maxs[1]):", - " for z in (mins[2], maxs[2]):", - " projections.append(sum(((x, y, z)[i] - origin[i]) * direction[i] for i in range(3)))", - " return min(projections), max(projections)", - "", - "def make_owned_cylinder_cutter(face, target_part=None):", - " surface = face.get('surface') or {}", - " params = surface.get('cylinder_params')", - " bbox_m = face.get('box_m')", - " if not params or len(params) < 7 or not bbox_m or len(bbox_m) < 6:", - " return None", - " origin = tuple(float(v) * 1000 for v in params[:3])", - " direction = tuple(float(v) for v in params[3:6])", - " norm = math.sqrt(sum(v * v for v in direction))", - " radius = abs(float(params[6]) * 1000)", - " if norm <= 1e-9 or radius <= 1e-9:", - " return None", - " direction = tuple(v / norm for v in direction)", - " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", - " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", - " start, end = _project_bbox_along_direction((*mins, *maxs), origin, direction)", - " if target_part is not None:", - " try:", - " part_bbox = target_part.bounding_box()", - " part_box = (*part_bbox.min.to_tuple(), *part_bbox.max.to_tuple())", - " part_start, part_end = _project_bbox_along_direction(part_box, origin, direction)", - " through_tolerance = max(1.0, radius * 0.12)", - " if abs(start - part_start) <= through_tolerance:", - " start = part_start", - " if abs(end - part_end) <= through_tolerance:", - " end = part_end", - " except Exception:", - " pass", - " height = max(0.001, end - start)", - " center_offset = (start + end) / 2", - " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", - " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", - " Cylinder(radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", - " return cutter_part.part", - "", - "def make_owned_cone_cutter(face):", - " surface = face.get('surface') or {}", - " params = surface.get('cone_params')", - " bbox_m = face.get('box_m')", - " if not params or len(params) < 8 or not bbox_m or len(bbox_m) < 6:", - " return None", - " origin = tuple(float(v) * 1000 for v in params[:3])", - " direction = tuple(float(v) for v in params[3:6])", - " norm = math.sqrt(sum(v * v for v in direction))", - " base_radius = abs(float(params[6]) * 1000)", - " half_angle = abs(float(params[7]))", - " if norm <= 1e-9 or base_radius <= 1e-9 or half_angle <= 1e-9:", - " return None", - " direction = tuple(v / norm for v in direction)", - " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", - " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", - " projections = []", - " for x in (mins[0], maxs[0]):", - " for y in (mins[1], maxs[1]):", - " for z in (mins[2], maxs[2]):", - " projections.append(sum(((x, y, z)[i] - origin[i]) * direction[i] for i in range(3)))", - " start = min(projections)", - " end = max(projections)", - " # Keep a tiny overlap for the boolean while preserving blind-hole depth.", - " height = max(0.001, end - start) + 0.001", - " # SolidWorks ConeParams stores the radius at the cone origin; along the axis", - " # direction the radius tapers rather than expands for hole drill tips.", - " r1 = max(0.0, base_radius - math.tan(half_angle) * start)", - " r2 = max(0.0, base_radius - math.tan(half_angle) * end)", - " if max(r1, r2) <= 1e-9:", - " return None", - " if r1 <= 1e-9:", - " r1 = 1e-6", - " if r2 <= 1e-9:", - " r2 = 1e-6", - " center_offset = (start + end) / 2", - " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", - " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", - " Cone(r1, r2, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", - " return cutter_part.part", - "", - "def make_owned_face_cutter(face, target_part=None):", - " surface = face.get('surface') or {}", - " if surface.get('is_cylinder'):", - " return make_owned_cylinder_cutter(face, target_part)", - " if surface.get('is_cone'):", - " return make_owned_cone_cutter(face)", - " return None", - "", - "def cut_owned_cylindrical_faces(part, faces):", - " result = part", - " for face in faces or []:", - " cutter = make_owned_face_cutter(face, result)", - " result = safe_subtract(result, cutter)", - " return result", - "", - "def make_owned_flip_side_ring_cutter(face, target_part):", - " surface = face.get('surface') or {}", - " params = surface.get('cylinder_params')", - " bbox_m = face.get('box_m')", - " if target_part is None or not params or len(params) < 7 or not bbox_m or len(bbox_m) < 6:", - " return None", - " origin = tuple(float(v) * 1000 for v in params[:3])", - " direction = tuple(float(v) for v in params[3:6])", - " norm = math.sqrt(sum(v * v for v in direction))", - " inner_radius = abs(float(params[6]) * 1000)", - " if norm <= 1e-9 or inner_radius <= 1e-9:", - " return None", - " direction = tuple(v / norm for v in direction)", - " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", - " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", - " start, end = _project_bbox_along_direction((*mins, *maxs), origin, direction)", - " height = max(0.001, end - start)", - " center_offset = (start + end) / 2", - " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", - " try:", - " part_bbox = target_part.bounding_box()", - " part_min = part_bbox.min.to_tuple()", - " part_max = part_bbox.max.to_tuple()", - " radial = []", - " for x in (part_min[0], part_max[0]):", - " for y in (part_min[1], part_max[1]):", - " for z in (part_min[2], part_max[2]):", - " delta = (x - origin[0], y - origin[1], z - origin[2])", - " axial = sum(delta[i] * direction[i] for i in range(3))", - " perp = tuple(delta[i] - axial * direction[i] for i in range(3))", - " radial.append(math.sqrt(sum(v * v for v in perp)))", - " outer_radius = max(radial) + max(1.0, inner_radius * 0.05)", - " except Exception:", - " outer_radius = inner_radius + 100.0", - " if outer_radius <= inner_radius + 1e-6:", - " return None", - " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", - " Cylinder(outer_radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", - " Cylinder(inner_radius, height + 0.002, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT)", - " return cutter_part.part", - "", - "def cut_owned_flip_side_cylindrical_faces(part, faces):", - " result = part", - " for face in faces or []:", - " cutter = make_owned_flip_side_ring_cutter(face, result)", - " result = safe_subtract(result, cutter)", - " return result", - "", - "def cut_owned_bbox(part, bbox_mm):", - " if part is None or not bbox_mm or len(bbox_mm) < 6:", - " return part", - " mins = tuple(float(bbox_mm[i]) for i in range(3))", - " maxs = tuple(float(bbox_mm[i + 3]) for i in range(3))", - " size = tuple(max(0.001, maxs[i] - mins[i]) for i in range(3))", - " center = tuple((mins[i] + maxs[i]) / 2 for i in range(3))", - " cutter = Pos(center) * Box(size[0], size[1], size[2])", - " return safe_subtract(part, cutter)", - "", - "def shape_face_count(shape):", - " if shape is None:", - " return 0", - " try:", - " return len(list(shape.faces()))", - " except Exception:", - " return 0", - "", - "def safe_union(part, solid, preserve_visible=False):", - " if part is None:", - " return solid", - " if solid is None:", - " return part", - " try:", - " fused = part + solid", - " # OCCT fuse succeeded; always return the fused result.", - " # is_valid() can return False for edge cases where the geometry", - " # is actually correct (e.g. touching-at-faces). Accept it.", - " return fused", - " except Exception as e:", - " print(f' UNION: fuse threw {type(e).__name__}: {e}')", - " pass", - " try:", - " compound = Compound.make_composite([part, solid])", - " fused = compound.fuse()", - " try:", - " if len(list(fused.solids())) > 0:", - " print(f' UNION: compound.fuse() worked, {len(list(fused.solids()))} solids')", - " return fused", - " except Exception:", - " pass", - " except Exception as e:", - " print(f' UNION: compound.fuse() threw {type(e).__name__}: {e}')", - " pass", - " shapes = []", - " try:", - " shapes.extend(list(part.solids()))", - " except Exception:", - " shapes.append(part)", - " try:", - " shapes.extend(list(solid.solids()))", - " except Exception:", - " shapes.append(solid)", - " return Compound.make_composite(shapes)", - "", - "def sw_inverted_profile_cut(part, profile_solid, normal):", - " if part is None or profile_solid is None:", - " return part", - " try:", - " part_bbox = part.bounding_box()", - " profile_bbox = profile_solid.bounding_box()", - " n = tuple(float(v) for v in normal)", - " axis = max(range(3), key=lambda i: abs(n[i]))", - " part_min = part_bbox.min.to_tuple()", - " part_max = part_bbox.max.to_tuple()", - " prof_min = profile_bbox.min.to_tuple()", - " prof_max = profile_bbox.max.to_tuple()", - " margin = 5.0", - " mins = [part_min[i] - margin for i in range(3)]", - " maxs = [part_max[i] + margin for i in range(3)]", - " mins[axis] = prof_min[axis] - margin * 0.05", - " maxs[axis] = prof_max[axis] + margin * 0.05", - " center = tuple((mins[i] + maxs[i]) / 2 for i in range(3))", - " size = tuple(max(0.001, maxs[i] - mins[i]) for i in range(3))", - " envelope = Pos(center) * Box(size[0], size[1], size[2])", - " outside_profile = safe_subtract(envelope, profile_solid)", - " return safe_subtract(part, outside_profile)", - " except Exception:", - " return part", - "", - "def sw_flip_side_step_cut(part, profile_solid, normal, outer_radius_mm, inner_radius_mm):", - " part = sw_inverted_profile_cut(part, profile_solid, normal)", - " if part is None or profile_solid is None:", - " return part", - " try:", - " outer_radius = abs(float(outer_radius_mm))", - " inner_radius = abs(float(inner_radius_mm))", - " except Exception:", - " return part", - " if outer_radius <= inner_radius + 1e-6:", - " return part", - " try:", - " profile_bbox = profile_solid.bounding_box()", - " prof_min = profile_bbox.min.to_tuple()", - " prof_max = profile_bbox.max.to_tuple()", - " center = tuple((prof_min[i] + prof_max[i]) / 2 for i in range(3))", - " n = tuple(float(v) for v in normal)", - " axis = max(range(3), key=lambda i: abs(n[i]))", - " span_xy = max(prof_max[0] - prof_min[0], prof_max[1] - prof_min[1])", - " margin_xy = max(2.0, span_xy * 0.05)", - " margin_z = 0.1", - " size = tuple(", - " max(0.001, prof_max[i] - prof_min[i] + (margin_xy if i < 2 else margin_z))", - " for i in range(3)", - " )", - " plane = Plane(", - " origin=center,", - " x_dir=(1.0, 0.0, 0.0) if axis != 0 else (0.0, 1.0, 0.0),", - " z_dir=n,", - " )", - " cut_extent = prof_max[axis] - prof_min[axis]", - " cut_amount = -abs(cut_extent) if n[axis] < 0 else abs(cut_extent)", - " with BuildSketch(plane) as ring_sketch:", - " Circle(outer_radius)", - " Circle(inner_radius, mode=Mode.SUBTRACT)", - " ring = extrude(ring_sketch.sketch, amount=cut_amount)", - " return safe_union(part, ring)", - " except Exception:", - " return part", - "", - "def sw_cut_holes(part, positions, host_face, diameter, depth, drill_angle=0, include_drill_tip=False, countersink_diameter=0, countersink_angle=0, counterbore_diameter=0, counterbore_depth=0):", - " if part is None:", - " return part", - " if not positions or diameter <= 0 or depth <= 0:", - " return part", - " plane = host_face.get('surface', {}).get('plane_params') or [0, 0, 1, 0, 0, 0]", - " frame = host_face.get('frame') or {}", - " normal = tuple(float(v) for v in plane[:3])", - " plane_point = tuple(float(v) * 1000 for v in plane[3:6])", - " origin = tuple(float(v) for v in frame.get('origin_mm', plane_point))", - " x_dir = tuple(float(v) for v in frame.get('x_dir', (0, 0, 0)))", - " y_dir = tuple(float(v) for v in frame.get('y_dir', (0, 0, 0)))", - " has_frame = sum(abs(v) for v in x_dir) > 0 and sum(abs(v) for v in y_dir) > 0", - " bbox = part.bounding_box()", - " part_center = tuple((bbox.min.to_tuple()[i] + bbox.max.to_tuple()[i]) / 2 for i in range(3))", - " toward_center = tuple(part_center[i] - plane_point[i] for i in range(3))", - " dot = sum(toward_center[i] * normal[i] for i in range(3))", - " inward = normal if dot >= 0 else tuple(-v for v in normal)", - " axis = max(range(3), key=lambda i: abs(inward[i]))", - " rotation = (0, 0, 0)", - " if axis == 0:", - " rotation = (0, 90, 0) if inward[0] >= 0 else (0, -90, 0)", - " elif axis == 1:", - " rotation = (-90, 0, 0) if inward[1] >= 0 else (90, 0, 0)", - " elif inward[2] < 0:", - " rotation = (180, 0, 0)", - " tip_depth = 0", - " if include_drill_tip and drill_angle > 0:", - " tip_depth = (diameter / 2) / math.tan(drill_angle / 2)", - " countersink_depth = 0", - " if countersink_diameter > diameter and countersink_angle > 0:", - " countersink_depth = ((countersink_diameter - diameter) / 2) / math.tan(countersink_angle / 2)", - " result = part", - " for pos in positions:", - " x, y = float(pos[0]), float(pos[1])", - " if has_frame:", - " start = tuple(origin[i] + x_dir[i] * x + y_dir[i] * y for i in range(3))", - " elif axis == 0:", - " start = (plane_point[0], x, y)", - " elif axis == 1:", - " start = (x, plane_point[1], -y)", - " else:", - " start = (x, y, plane_point[2])", - " cut_depth = depth", - " if depth >= 199:", - " part_min = bbox.min.to_tuple()", - " part_max = bbox.max.to_tuple()", - " corners = []", - " for ci in range(2):", - " for cj in range(2):", - " for ck in range(2):", - " corners.append((", - " part_min[0] if ci else part_max[0],", - " part_min[1] if cj else part_max[1],", - " part_min[2] if ck else part_max[2],", - " ))", - " cut_depth = max(", - " sum((corner[i] - start[i]) * inward[i] for i in range(3))", - " for corner in corners", - " ) + 2.0", - " cutters = []", - " cb_depth = counterbore_depth if counterbore_diameter > diameter and counterbore_depth > 0 else 0", - " cs_depth = countersink_depth if countersink_depth > 0 else 0", - " hole_start = cs_depth", - " hole_depth = max(0.001, cut_depth - hole_start - cb_depth)", - " if hole_depth > 0:", - " hole_center = tuple(start[i] + inward[i] * (hole_start + cb_depth + hole_depth / 2) for i in range(3))", - " cutters.append(Pos(hole_center) * Cylinder(diameter / 2, hole_depth, rotation=rotation))", - " if cb_depth > 0:", - " cb_center = tuple(start[i] + inward[i] * (hole_start + cb_depth / 2) for i in range(3))", - " cutters.append(Pos(cb_center) * Cylinder(counterbore_diameter / 2, cb_depth, rotation=rotation))", - " if cs_depth > 0:", - " cs_center = tuple(start[i] + inward[i] * cs_depth / 2 for i in range(3))", - " cs = Pos(cs_center) * Cone(countersink_diameter / 2, diameter / 2, cs_depth, rotation=rotation)", - " cutters.append(cs)", - " if tip_depth > 0:", - " base = tuple(start[i] + inward[i] * cut_depth for i in range(3))", - " tip_center = tuple(base[i] + inward[i] * tip_depth / 2 for i in range(3))", - " tip = Pos(tip_center) * Cone(diameter / 2, 0, tip_depth, rotation=rotation)", - " cutters.append(tip)", - " if len(cutters) == 1:", - " cutter = cutters[0]", - " else:", - " cutter = Compound.make_composite(cutters)", - " result = safe_subtract(result, cutter)", - " return result", - "", - ] - - part_name_clean = get_part_name(data) - lines.append(f"def build_{part_name_clean}():") - lines.append(' """Auto-generated build123d code from SolidWorks IR."""') - lines.append("") - - sketches = {s["id"]: s for s in data.get("sketches", [])} - operations = data.get("operations", []) - references = {r["id"]: r for r in data.get("references", [])} - generated_sketches = set() - - lines.append(" result = None") - lines.append("") - - for op in sort_operations_for_history(operations): - op_type = op.get("type", "") - op_name = op.get("name", "") - if op_type in ["unsupported", "unknown"]: - lines.append(f" # Skipping unsupported metadata feature: {op_name}") - lines.append("") - continue - - if op_type == "imported_body": - lines.extend(_generate_imported_body_pending(op)) - elif op_type == "assembly_compose": - lines.extend(_generate_assembly_compose(op)) - elif op_type == "move_face": - lines.extend(_generate_move_face(op)) - elif op_type == "fillet": - lines.extend(_generate_fillet(op)) - elif op_type == "chamfer": - lines.extend(_generate_chamfer(op)) - elif op_type == "hole": - lines.extend(_generate_hole(op)) - elif op_type in ("extrude_cut", "extrude_add"): - build_op = _resolve_extrude_owned_termination(op, sketches.get(op.get("sketch") or "")) - sketch_id = op.get("sketch") - if sketch_id and sketch_id in sketches and not _sketch_has_buildable_profile(sketches[sketch_id]): - lines.append(f" # Skip: sketch has no buildable closed/profile geometry for {op_name}") - continue - if sketch_id and sketch_id in sketches and sketch_id not in generated_sketches: - lines.extend(_generate_sketch(sketches[sketch_id], references, build_op)) - generated_sketches.add(sketch_id) - lines.extend(_generate_extrude(build_op, sketches.get(sketch_id, {}), operations, sketches)) - elif op_type in ("revolve_cut", "revolve_add"): - sketch_id = op.get("sketch") - if sketch_id and sketch_id in sketches and not _sketch_has_buildable_profile(sketches[sketch_id]): - lines.append(f" # Skip: sketch has no buildable closed/profile geometry for {op_name}") - continue - if sketch_id and sketch_id in sketches and sketch_id not in generated_sketches: - lines.extend(_generate_sketch(sketches[sketch_id], references, op)) - generated_sketches.add(sketch_id) - lines.extend(_generate_revolve(op, sketches.get(sketch_id, {}))) - elif op_type in ("linear_pattern", "pattern_linear"): - lines.extend(_generate_linear_pattern(op, operations, sketches, references)) - elif op_type == "pattern_mirror": - lines.extend(_generate_mirror_pattern(op, operations, sketches, references)) - else: - lines.append(f" # TODO: {op_type} - {op_name}") - - lines.append("") - - lines.append(" if result is None:") - lines.append(' raise Exception("No solid was created")') - lines.append("") - lines.append(" # Clean up small inaccuracies from Boolean operations") - lines.append(" try:") - lines.append(" result = result.clean()") - lines.append(" except Exception:") - lines.append(" pass") - lines.append(f' export_step(result, "{part_name_clean}.step")') - lines.append(" return result") - lines.append("") - lines.append("# Run the function") - lines.append('if __name__ == "__main__":') - lines.append(f" build_{part_name_clean}()") - - return "\n".join(lines) - - -def _generate_imported_body_pending(op: Dict[str, Any]) -> list[str]: - return [ - f" # Imported body requires generic JSON B-Rep reconstruction: {op.get('name', '')}", - " raise NotImplementedError(", - " 'Pure-JSON imported-body reconstruction is not implemented yet; '", - " 'the plugin captured solid_bodies topology and the part is marked not ready.'", - " )", - ] - - -def _generate_assembly_compose(op: Dict[str, Any]) -> list[str]: - params = op.get("parameters") or {} - components = params.get("components") or [] - component_ids = [component.get("component_id") for component in components] - message = f"Assembly requires rebuilt component JSON registry: {component_ids!r}" - return [ - f" # Pure-JSON assembly composition: {op.get('name', '')}", - " raise NotImplementedError(", - f" {message!r}", - " )", - ] - - -def _sw_math_transform_matrix(array_data: Any, component_name: str) -> list[list[float]]: - if not isinstance(array_data, list) or len(array_data) < 13: - raise ValueError(f"Assembly component {component_name} has no complete 16-value transform") - values = [float(value or 0) for value in array_data] - scale = values[12] - if abs(scale) <= 1e-12: - raise ValueError(f"Assembly component {component_name} has an invalid zero scale") - # SOLIDWORKS stores row-vector axes and translation in elements 9..11. - # build123d/OpenCascade uses a column-vector 3x4 matrix, hence transpose. - return [ - [values[0] * scale, values[3] * scale, values[6] * scale, values[9] * 1000.0], - [values[1] * scale, values[4] * scale, values[7] * scale, values[10] * 1000.0], - [values[2] * scale, values[5] * scale, values[8] * scale, values[11] * 1000.0], - [0.0, 0.0, 0.0, 1.0], - ] - - -def sort_operations_for_history(operations: list[Dict[str, Any]]) -> list[Dict[str, Any]]: - """Return operations in SW rebuild order.""" - if _looks_like_reverse_history(operations): - return list(reversed(operations)) - if all(op.get("source_feature", {}).get("index") is not None for op in operations): - return sorted(operations, key=lambda op: op.get("source_feature", {}).get("index", 0)) - return sorted(operations, key=_operation_priority) - - -def _looks_like_reverse_history(operations: list[Dict[str, Any]]) -> bool: - build_ops = [ - op - for op in operations - if op.get("type") not in ("unsupported", "unknown") - ] - if len(build_ops) < 2: - return False - additive = {"extrude_add", "revolve_add", "sweep", "loft"} - downstream = {"extrude_cut", "revolve_cut", "fillet", "chamfer", "hole", "linear_pattern", "pattern_linear"} - return build_ops[0].get("type") in downstream and build_ops[-1].get("type") in additive - - -def _operation_priority(op: Dict[str, Any]) -> int: - op_type = op.get("type", "") - if op_type == "extrude_add": - return 0 - if op_type in ("extrude_cut", "revolve_cut"): - return 1 - if op_type == "revolve_add": - return 2 - if op_type in ("fillet", "chamfer"): - return 3 - if op_type in ("sweep", "loft"): - return 4 - return 99 - - -def _sketch_has_buildable_profile(sketch: Dict[str, Any]) -> bool: - for entity in sketch.get("entities", []) or []: - if entity.get("construction"): - continue - if entity.get("type") == "circle" and float(entity.get("radius_mm") or 0) > 0: - return True - if entity.get("type") == "arc" and float(entity.get("radius_mm") or 0) > 0: - return True - valid_lines = 0 - for entity in sketch.get("entities", []) or []: - if entity.get("construction") or entity.get("type") != "line": - continue - start = entity.get("start") or [0, 0] - end = entity.get("end") or [0, 0] - if math.hypot(float(start[0]) - float(end[0]), float(start[1]) - float(end[1])) > 1e-6: - valid_lines += 1 - return valid_lines >= 2 - - -def _point_key(point: Any, places: int = 5) -> tuple[float, float] | None: - if not isinstance(point, list) or len(point) < 2: - return None - return (round(float(point[0]), places), round(float(point[1]), places)) - - -def _reverse_curve_entity(ent: Dict[str, Any]) -> Dict[str, Any]: - """Reverse a sketch segment while preserving its geometric traversal.""" - reversed_ent = dict(ent) - reversed_ent["start"], reversed_ent["end"] = ent.get("end"), ent.get("start") - reversed_ent["reversed"] = not bool(ent.get("reversed", False)) - if ent.get("type") == "arc": - raw = ent.get("raw") if isinstance(ent.get("raw"), dict) else {} - axis = ent.get("curve_axis") or raw.get("curve_axis") - if isinstance(axis, list) and len(axis) >= 3: - # The arc's endpoints and orientation are a pair. Keep the - # source `raw` untouched, but provide a flipped top-level axis for - # code generation so a reversed minor arc remains a minor arc. - reversed_ent["curve_axis"] = [-float(value) for value in axis[:3]] - # 必须删除预置的角度字段,否则代码生成会使用旧的(start,end未翻转时的)角度, - # 导致弧段遍历方向与连接顺序相反(如对外弧CW而对内弧也CW而非CCW)。 - reversed_ent.pop("start_angle_deg", None) - reversed_ent.pop("end_angle_deg", None) - reversed_ent.pop("arc_sweep_deg", None) - return reversed_ent - - -def _ordered_wire_entities(entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: - """Order sketch line/arc entities into connected loops when SW did not export contours.""" - drawable = [ - ent for ent in entities - if ent.get("type") in ("line", "arc") - and _point_key(ent.get("start")) is not None - and _point_key(ent.get("end")) is not None - ] - if len(drawable) < 3: - return entities - - by_node: dict[tuple[float, float], list[tuple[int, str]]] = {} - for idx, ent in enumerate(drawable): - by_node.setdefault(_point_key(ent.get("start")), []).append((idx, "start")) - by_node.setdefault(_point_key(ent.get("end")), []).append((idx, "end")) - - if not by_node or any(len(touches) != 2 for touches in by_node.values()): - return entities - - remaining = set(range(len(drawable))) - ordered: list[Dict[str, Any]] = [] - - while remaining: - first_idx = min(remaining) - remaining.remove(first_idx) - first = drawable[first_idx] - loop = [first] - loop_start = _point_key(first.get("start")) - cursor = _point_key(first.get("end")) - - while cursor != loop_start: - next_idx = None - next_side = None - for candidate_idx, side in by_node.get(cursor, []): - if candidate_idx in remaining: - next_idx = candidate_idx - next_side = side - break - if next_idx is None: - return entities - - remaining.remove(next_idx) - next_ent = drawable[next_idx] - if next_side == "end": - next_ent = _reverse_curve_entity(next_ent) - loop.append(next_ent) - cursor = _point_key(next_ent.get("end")) - - ordered.extend(loop) - - return ordered - - -def _infer_closed_wire_loops(entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: - drawable = [ - (idx, ent) for idx, ent in enumerate(entities) - if not ent.get("construction", False) - and ent.get("type") in ("line", "arc") - and _point_key(ent.get("start")) is not None - and _point_key(ent.get("end")) is not None - ] - if len(drawable) < 3: - return [] - - by_node: dict[tuple[float, float], list[tuple[int, str]]] = {} - for local_idx, (_, ent) in enumerate(drawable): - by_node.setdefault(_point_key(ent.get("start")), []).append((local_idx, "start")) - by_node.setdefault(_point_key(ent.get("end")), []).append((local_idx, "end")) - - remaining = set(range(len(drawable))) - loops: list[Dict[str, Any]] = [] - while remaining: - first_idx = min(remaining) - remaining.remove(first_idx) - _, first = drawable[first_idx] - loop_indices = [first_idx] - loop_start = _point_key(first.get("start")) - cursor = _point_key(first.get("end")) - - while cursor != loop_start: - matches = [(idx, side) for idx, side in by_node.get(cursor, []) if idx in remaining] - if not matches: - loop_indices = [] - break - next_idx, next_side = matches[0] - remaining.remove(next_idx) - _, next_ent = drawable[next_idx] - loop_indices.append(next_idx) - cursor = _point_key(next_ent.get("start") if next_side == "end" else next_ent.get("end")) - - if not loop_indices: - continue - entity_indices = [drawable[idx][0] for idx in loop_indices] - bbox = _loop_bbox([entities[idx] for idx in entity_indices]) - loops.append({ - "entity_indices": entity_indices, - "is_closed": True, - "bbox_mm": bbox, - "bbox_area_mm2": _bbox_area_2d(bbox), - "source": "inferred_connected_loop", - }) - return loops - - -def _loop_bbox(entities: list[Dict[str, Any]]) -> Optional[list[float]]: - points = [] - for ent in entities: - if not isinstance(ent, dict): - continue - if ent.get("type") == "circle": - center = ent.get("center") - radius = ent.get("radius_mm") - if isinstance(center, list) and len(center) >= 2 and radius is not None: - radius_value = abs(float(radius)) - points.append([float(center[0]) - radius_value, float(center[1]) - radius_value]) - points.append([float(center[0]) + radius_value, float(center[1]) + radius_value]) - continue - for key in ("start", "end", "center"): - point = ent.get(key) - if isinstance(point, list) and len(point) >= 2: - points.append(point) - if not points: - return None - return [ - min(float(point[0]) for point in points), - min(float(point[1]) for point in points), - max(float(point[0]) for point in points), - max(float(point[1]) for point in points), - ] - - -def _bbox_area_2d(bbox: Optional[list[float]]) -> float: - if not isinstance(bbox, list) or len(bbox) < 4: - return 0.0 - return max(0.0, float(bbox[2]) - float(bbox[0])) * max(0.0, float(bbox[3]) - float(bbox[1])) - - -def _bbox_contains_2d(outer: Optional[list[float]], inner: Optional[list[float]], tolerance: float = 1e-6) -> bool: - if not isinstance(outer, list) or not isinstance(inner, list) or len(outer) < 4 or len(inner) < 4: - return False - return ( - float(outer[0]) <= float(inner[0]) + tolerance - and float(outer[1]) <= float(inner[1]) + tolerance - and float(outer[2]) >= float(inner[2]) - tolerance - and float(outer[3]) >= float(inner[3]) - tolerance - ) - - -def _bbox_overlap_ratio_2d(a: Optional[list[float]], b: Optional[list[float]]) -> float: - if not isinstance(a, list) or not isinstance(b, list) or len(a) < 4 or len(b) < 4: - return 0.0 - ix0 = max(float(a[0]), float(b[0])) - iy0 = max(float(a[1]), float(b[1])) - ix1 = min(float(a[2]), float(b[2])) - iy1 = min(float(a[3]), float(b[3])) - intersection = max(0.0, ix1 - ix0) * max(0.0, iy1 - iy0) - smaller = min(_bbox_area_2d(a), _bbox_area_2d(b)) - if smaller <= 1e-9: - return 0.0 - return intersection / smaller - - -def _loop_radius_candidates(loop: Dict[str, Any], entities: list[Dict[str, Any]]) -> list[float]: - radii: list[float] = [] - for idx in loop.get("entity_indices", []) or []: - if not isinstance(idx, int) or idx < 0 or idx >= len(entities): - continue - ent = entities[idx] - radius = ent.get("radius_mm") - if radius is not None: - radii.append(abs(float(radius))) - bbox = loop.get("bbox_mm") - if isinstance(bbox, list) and len(bbox) >= 4: - radii.append(abs(float(bbox[2]) - float(bbox[0])) / 2) - radii.append(abs(float(bbox[3]) - float(bbox[1])) / 2) - return [radius for radius in radii if radius > 1e-6 and math.isfinite(radius)] - - -def _owned_profile_radii_mm(operation: Optional[Dict[str, Any]], sketch: Dict[str, Any]) -> list[float]: - if not isinstance(operation, dict): - return [] - radii: list[float] = [] - loop_radii: list[float] = [] - entities = sketch.get("entities") if isinstance(sketch, dict) else [] - sketch_loops = (sketch.get("profile_loops") or sketch.get("loops") or []) if isinstance(sketch, dict) else [] - for loop in sketch_loops: - loop_radii.extend(_loop_radius_candidates(loop, entities if isinstance(entities, list) else [])) - - def _matches_sketch_radius(value: float) -> bool: - return any(abs(value - radius) <= max(0.1, radius * 0.01) for radius in loop_radii) - - for face in operation.get("source_owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - params = surface.get("cylinder_params") - if surface.get("is_cylinder") and isinstance(params, list) and len(params) >= 7: - radii.append(abs(float(params[6]) * 1000)) - continue - box = face.get("box_m") - area = face.get("area_m2") - if surface.get("is_plane") and isinstance(box, list) and len(box) >= 6 and area is not None: - sizes = [abs(float(box[i + 3]) - float(box[i])) * 1000 for i in range(3)] - non_zero_sizes = [size for size in sizes if size > 1e-4] - if len(non_zero_sizes) >= 2: - outer_radius = max(non_zero_sizes) / 2 - area_mm2 = abs(float(area)) * 1_000_000 - inner_sq = outer_radius * outer_radius - area_mm2 / math.pi - inner_radius = math.sqrt(inner_sq) if inner_sq > 0 else 0.0 - if _matches_sketch_radius(outer_radius): - radii.append(outer_radius) - if inner_radius > 1e-4 and _matches_sketch_radius(inner_radius): - radii.append(inner_radius) - - unique: list[float] = [] - for radius in sorted(radii): - if radius <= 1e-6 or not math.isfinite(radius): - continue - if not any(abs(radius - existing) <= max(0.05, existing * 0.002) for existing in unique): - unique.append(radius) - return unique - - -def _loops_matching_owned_radii( - loops: list[Dict[str, Any]], - entities: list[Dict[str, Any]], - owned_radii: list[float], -) -> list[Dict[str, Any]]: - if not loops or not owned_radii: - return [] - matched: list[tuple[float, Dict[str, Any]]] = [] - for loop in loops: - candidates = _loop_radius_candidates(loop, entities) - if not candidates: - continue - best_radius = None - best_delta = float("inf") - for candidate in candidates: - for owned_radius in owned_radii: - delta = abs(candidate - owned_radius) - if delta < best_delta: - best_delta = delta - best_radius = candidate - if best_radius is None: - continue - if best_delta <= max(0.1, best_radius * 0.01): - matched.append((best_radius, loop)) - if not matched: - return [] - matched.sort(key=lambda item: item[0], reverse=True) - deduped: list[tuple[float, Dict[str, Any]]] = [] - seen_loop_keys: set[str] = set() - for radius, loop in matched: - bbox = loop.get("bbox_mm") - key = ",".join(f"{float(value):.4f}" for value in bbox[:4]) if isinstance(bbox, list) and len(bbox) >= 4 else str(loop.get("entity_indices")) - key = f"{radius:.4f}:{key}" - if key in seen_loop_keys: - continue - seen_loop_keys.add(key) - deduped.append((radius, loop)) - matched = deduped - annotated = [] - for index, (_, loop) in enumerate(matched): - loop_copy = dict(loop) - loop_copy["profile_mode"] = "add" if index == 0 else "subtract" - annotated.append(loop_copy) - return annotated - - -def _loop_area_from_radii(loops: list[Dict[str, Any]], entities: list[Dict[str, Any]]) -> Optional[float]: - if not loops: - return None - area = 0.0 - for index, loop in enumerate(loops): - radii = _loop_radius_candidates(loop, entities) - if not radii: - return None - radius = max(radii) - mode = loop.get("profile_mode") - sign = -1 if mode == "subtract" or (mode is None and index > 0) else 1 - area += sign * math.pi * radius * radius - return abs(area) if area > 1e-6 else None - - -def _aligned_workplane_for_owned_midplane( - sketch: Dict[str, Any], - operation: Optional[Dict[str, Any]], - loops: list[Dict[str, Any]], -) -> Dict[str, Any]: - workplane = dict(sketch.get("workplane") or {}) - if not isinstance(operation, dict) or operation.get("type") != "extrude_add": - return workplane - params = operation.get("parameters") if isinstance(operation.get("parameters"), dict) else {} - if not params.get("both_directions"): - return workplane - - entities = sketch.get("entities") if isinstance(sketch.get("entities"), list) else [] - profile_area = _loop_area_from_radii(loops, entities) - if profile_area is None: - return workplane - - normal = workplane.get("normal") or [0, 0, 1] - origin = workplane.get("origin_mm") or [0, 0, 0] - if not isinstance(normal, list) or not isinstance(origin, list) or len(normal) < 3 or len(origin) < 3: - return workplane - normal_vec = [float(v) for v in normal[:3]] - norm = math.sqrt(sum(v * v for v in normal_vec)) - if norm <= 1e-9: - return workplane - normal_vec = [v / norm for v in normal_vec] - - candidates: list[tuple[float, list[float]]] = [] - for face in operation.get("source_owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - if not surface.get("is_plane"): - continue - area_m2 = face.get("area_m2") - plane_params = surface.get("plane_params") - if area_m2 is None or not isinstance(plane_params, list) or len(plane_params) < 6: - continue - face_area = abs(float(area_m2)) * 1_000_000 - if abs(face_area - profile_area) > max(0.5, profile_area * 0.02): - continue - plane_normal = [float(v) for v in plane_params[:3]] - plane_norm = math.sqrt(sum(v * v for v in plane_normal)) - if plane_norm <= 1e-9: - continue - plane_normal = [v / plane_norm for v in plane_normal] - alignment = abs(sum(plane_normal[i] * normal_vec[i] for i in range(3))) - if alignment < 0.98: - continue - plane_point = [float(v) * 1000 for v in plane_params[3:6]] - old_offset = sum(float(origin[i]) * normal_vec[i] for i in range(3)) - new_offset = sum(plane_point[i] * normal_vec[i] for i in range(3)) - delta = new_offset - old_offset - if abs(delta) <= 1e-6: - continue - moved_origin = [float(origin[i]) + normal_vec[i] * delta for i in range(3)] - candidates.append((abs(delta), moved_origin)) - if len(candidates) != 1: - return workplane - candidates.sort(key=lambda item: item[0]) - workplane["origin_mm"] = candidates[0][1] - return workplane - - -def _project_owned_faces_to_sketch_bbox( - owned_faces: list[Dict[str, Any]], workplane: Dict[str, Any] -) -> Optional[list[float]]: - origin = workplane.get("origin_mm") or [0, 0, 0] - x_dir = workplane.get("x_dir") or [1, 0, 0] - y_dir = workplane.get("y_dir") or [0, 1, 0] - if len(origin) < 3 or len(x_dir) < 3 or len(y_dir) < 3: - return None - - projected: list[tuple[float, float]] = [] - for face in owned_faces: - box = face.get("box_m") if isinstance(face, dict) else None - if not isinstance(box, list) or len(box) < 6: - continue - mins = [float(box[i]) * 1000 for i in range(3)] - maxs = [float(box[i + 3]) * 1000 for i in range(3)] - for x in (mins[0], maxs[0]): - for y in (mins[1], maxs[1]): - for z in (mins[2], maxs[2]): - point = [x, y, z] - rel = [point[i] - float(origin[i]) for i in range(3)] - projected.append(( - sum(rel[i] * float(x_dir[i]) for i in range(3)), - sum(rel[i] * float(y_dir[i]) for i in range(3)), - )) - if not projected: - return None - return [ - min(point[0] for point in projected), - min(point[1] for point in projected), - max(point[0] for point in projected), - max(point[1] for point in projected), - ] - - -def _active_profile_loops(sketch: Dict[str, Any], operation: Optional[Dict[str, Any]]) -> list[Dict[str, Any]]: - entities = sketch.get("entities", []) or [] - loops = sketch.get("loops", []) or _infer_closed_wire_loops(entities) - if not loops: - return [] - - op_type = operation.get("type") if isinstance(operation, dict) else None - if op_type == "extrude_cut" and len(loops) > 1: - owned_bbox = _project_owned_faces_to_sketch_bbox( - operation.get("source_owned_faces") or [], - sketch.get("workplane") or {}, - ) - if owned_bbox: - for inner in loops: - inner_bbox = inner.get("bbox_mm") - if _bbox_overlap_ratio_2d(inner_bbox, owned_bbox) < 0.85: - continue - containers = [ - outer for outer in loops - if outer is not inner - and _bbox_contains_2d(outer.get("bbox_mm"), inner_bbox, tolerance=1e-4) - and _bbox_area_2d(outer.get("bbox_mm")) > _bbox_area_2d(inner_bbox) * 1.05 - ] - if containers: - outer = min(containers, key=lambda loop: _bbox_area_2d(loop.get("bbox_mm"))) - outer_loop = dict(outer) - inner_loop = dict(inner) - outer_loop["profile_mode"] = "add" - inner_loop["profile_mode"] = "subtract" - return [outer_loop, inner_loop] - - active = [] - for loop in loops: - bbox = loop.get("bbox_mm") - area = float(loop.get("bbox_area_mm2") or _bbox_area_2d(bbox)) - contains_other = any( - other is not loop - and _bbox_contains_2d(bbox, other.get("bbox_mm")) - and area > float(other.get("bbox_area_mm2") or _bbox_area_2d(other.get("bbox_mm"))) * 1.05 - for other in loops - ) - if not contains_other: - active.append(loop) - if active: - return active - if op_type == "extrude_add" and len(loops) > 1: - owned_matched = _loops_matching_owned_radii(loops, entities, _owned_profile_radii_mm(operation, sketch)) - # Owned-face radii are useful for selecting circular profiles, but a - # rounded outer contour also contributes arc radii. Those radii can - # coincide with an inner circle and make the radius ranking label the - # inner loop as ADD and its containing outer loop as SUBTRACT. Such a - # profile is topologically impossible as a first additive sketch, so - # fall back to the complete contour nesting below. - owned_modes_conflict_with_nesting = any( - candidate.get("profile_mode") == "add" - and any( - container is not candidate - and container.get("profile_mode") == "subtract" - and _bbox_contains_2d( - container.get("bbox_mm"), - candidate.get("bbox_mm"), - tolerance=1e-4, - ) - and _bbox_area_2d(container.get("bbox_mm")) - > _bbox_area_2d(candidate.get("bbox_mm")) * 1.05 - for container in owned_matched - ) - for candidate in owned_matched - ) - if owned_modes_conflict_with_nesting: - owned_matched = [] - if owned_matched: - # Radius evidence cannot identify closed slot/polygon contours. - # Keep non-circular closed loops that lie inside an owned additive - # outer loop; they are material-removal islands in the same - # additive sketch. Circular unmatched loops remain excluded - # because they commonly belong to other features sharing a sketch. - matched_entity_keys = { - tuple(loop.get("entity_indices") or []) for loop in owned_matched - } - additive_outers = [ - loop for loop in owned_matched if loop.get("profile_mode") == "add" - ] - for loop in loops: - entity_indices = tuple(loop.get("entity_indices") or []) - if entity_indices in matched_entity_keys: - continue - profile_entities = [ - entities[index] - for index in entity_indices - if isinstance(index, int) and 0 <= index < len(entities) - ] - is_non_circular_profile = bool(profile_entities) and any( - entity.get("type") != "circle" - and not (entity.get("type") == "arc" and entity.get("is_circle")) - for entity in profile_entities - ) - if not is_non_circular_profile: - continue - if not any( - _bbox_contains_2d( - outer.get("bbox_mm"), loop.get("bbox_mm"), tolerance=1e-4 - ) - for outer in additive_outers - ): - continue - loop_copy = dict(loop) - loop_copy["profile_mode"] = "subtract" - owned_matched.append(loop_copy) - if len(owned_matched) == 1 and isinstance(operation, dict): - outer_loop = owned_matched[0] - outer_radii = _loop_radius_candidates(outer_loop, entities) - outer_radius = max(outer_radii) if outer_radii else 0.0 - outer_disk_area = math.pi * outer_radius * outer_radius if outer_radius > 0 else 0.0 - has_partial_cap = False - for face in operation.get("source_owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - area_m2 = face.get("area_m2") - if surface.get("is_plane") and area_m2 is not None and outer_disk_area > 0: - face_area = abs(float(area_m2)) * 1_000_000 - if face_area < outer_disk_area * 0.9: - has_partial_cap = True - break - if has_partial_cap: - inner_candidates = [ - loop for loop in loops - if loop is not outer_loop - and _bbox_contains_2d(outer_loop.get("bbox_mm"), loop.get("bbox_mm"), tolerance=1e-4) - ] - if inner_candidates: - inner = max( - ( - loop - for loop in inner_candidates - if max(_loop_radius_candidates(loop, entities) or [0.0]) < outer_radius - 0.5 - ), - key=lambda loop: max(_loop_radius_candidates(loop, entities) or [0.0]), - default=None, - ) - if inner is None: - return owned_matched - inner_radii = _loop_radius_candidates(inner, entities) - inner_radius = max(inner_radii) if inner_radii else 0.0 - if inner_radius <= 0: - return owned_matched - outer_copy = dict(outer_loop) - inner_copy = dict(inner) - outer_copy["profile_mode"] = "add" - inner_copy["profile_mode"] = "subtract" - return [outer_copy, inner_copy] - return owned_matched - annotated = [] - for loop in loops: - bbox = loop.get("bbox_mm") - area = float(loop.get("bbox_area_mm2") or _bbox_area_2d(bbox)) - containers = [ - outer for outer in loops - if outer is not loop - and _bbox_contains_2d(outer.get("bbox_mm"), bbox, tolerance=1e-4) - and float(outer.get("bbox_area_mm2") or _bbox_area_2d(outer.get("bbox_mm"))) > area * 1.05 - ] - loop_copy = dict(loop) - loop_copy["profile_mode"] = "subtract" if containers else "add" - annotated.append(loop_copy) - return annotated - return loops - - -def _generate_sketch(sketch: Dict[str, Any], references: Dict[str, Any], operation: Optional[Dict[str, Any]] = None) -> list[str]: - import math - - name = sketch.get("name", "Sketch") - op_type = operation.get("type") if isinstance(operation, dict) else None - workplane = sketch.get("workplane", {}) - entities = sketch.get("entities", []) - loops = _active_profile_loops(sketch, operation) - workplane = _aligned_workplane_for_owned_midplane(sketch, operation, loops) - code = [f" # Sketch: {name}"] - - origin = workplane.get("origin_mm", [0, 0, 0]) - x_dir = workplane.get("x_dir", [1, 0, 0]) - normal = workplane.get("normal", [0, 0, 1]) - - if origin != [0, 0, 0] or x_dir != [1, 0, 0] or normal != [0, 0, 1]: - code.append( - f" with BuildSketch(Plane(origin={_tuple3(origin)}, x_dir={_tuple3(x_dir)}, z_dir={_tuple3(normal)})) as sketch:" - ) - else: - code.append(" with BuildSketch() as sketch:") - - loop_entities = [] - processed_indices = set() - for loop in loops: - for idx in loop.get("entity_indices", []): - if idx < len(entities): - loop_entities.append(entities[idx]) - processed_indices.add(idx) - - append_unprocessed = not loops - for i, ent in enumerate(entities): - if append_unprocessed and i not in processed_indices: - loop_entities.append(ent) - - drawable_entities = [ent for ent in loop_entities if not ent.get("construction", False)] - circle_entities = [ - ent for ent in drawable_entities - if ent.get("type") in ("circle", "arc") and ent.get("is_circle", ent.get("type") == "circle") - ] - wire_entities = [ - ent for ent in drawable_entities - if ent not in circle_entities and ent.get("type") in ("line", "arc") - ] - wire_entities = _ordered_wire_entities(wire_entities) - - handled_circle_entities = set() - if not loops and len(circle_entities) > 1: - ranked_circles = sorted( - enumerate(circle_entities), - key=lambda item: float(item[1].get("radius_mm", 0) or 0), - reverse=True, - ) - outer_index, outer = ranked_circles[0] - outer_center = outer.get("center", [0, 0, 0]) - outer_radius = float(outer.get("radius_mm", 0) or 0) - contains_all = outer_radius > 0 - for _, inner in ranked_circles[1:]: - inner_center = inner.get("center", [0, 0, 0]) - inner_radius = float(inner.get("radius_mm", 0) or 0) - center_distance = math.hypot( - float(inner_center[0]) - float(outer_center[0]), - float(inner_center[1]) - float(outer_center[1]), - ) - if center_distance + inner_radius >= outer_radius - 1e-6: - contains_all = False - break - if contains_all: - code.append(f" with Locations(({outer_center[0]}, {outer_center[1]})):") - code.append(f" Circle({outer_radius})") - handled_circle_entities.add(outer_index) - for inner_index, inner in ranked_circles[1:]: - center = inner.get("center", [0, 0, 0]) - radius = inner.get("radius_mm", 1) - code.append(f" with Locations(({center[0]}, {center[1]})):") - code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") - handled_circle_entities.add(inner_index) - - def circle_is_inner_profile(ent: Dict[str, Any]) -> bool: - if op_type != "extrude_add" or not loops: - return False - center = ent.get("center", [0, 0]) - radius = float(ent.get("radius_mm", 0) or 0) - if radius <= 0 or len(center) < 2: - return False - bbox = [ - float(center[0]) - radius, - float(center[1]) - radius, - float(center[0]) + radius, - float(center[1]) + radius, - ] - return any(_bbox_contains_2d(loop.get("bbox_mm"), bbox, tolerance=1e-4) for loop in loops) - - if not loops: - for circle_index, ent in enumerate(circle_entities): - if circle_index in handled_circle_entities: - continue - center = ent.get("center", [0, 0, 0]) - radius = ent.get("radius_mm", 1) - code.append(f" with Locations(({center[0]}, {center[1]})):") - if circle_is_inner_profile(ent): - code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") - else: - code.append(f" Circle({radius})") - - def orient_wire_entities(profile_entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: - """Orient contour segments into a continuous closed wire. - - SolidWorks contour arrays preserve membership but not necessarily each - segment's traversal direction. Reversing an arc must also invert its - curve axis; otherwise a short arc becomes its 270-degree complement. - """ - segments = [deepcopy(entity) for entity in profile_entities] - if len(segments) < 2: - return segments - - def endpoints(entity: Dict[str, Any]) -> tuple[Optional[list[float]], Optional[list[float]]]: - start = entity.get("start") - end = entity.get("end") - if not (isinstance(start, list) and isinstance(end, list) and len(start) >= 2 and len(end) >= 2): - return None, None - return [float(start[0]), float(start[1])], [float(end[0]), float(end[1])] - - def distance(left: list[float], right: list[float]) -> float: - return math.hypot(left[0] - right[0], left[1] - right[1]) - - def reverse(entity: Dict[str, Any]) -> Dict[str, Any]: - reversed_entity = deepcopy(entity) - reversed_entity["start"], reversed_entity["end"] = entity.get("end"), entity.get("start") - axis = reversed_entity.get("curve_axis") or (reversed_entity.get("raw") or {}).get("curve_axis") - if isinstance(axis, list) and len(axis) >= 3: - reversed_entity["curve_axis"] = [-float(value) for value in axis[:3]] - # 删除预置角度,强制代码生成时从翻转后的start/end重新计算 - reversed_entity.pop("start_angle_deg", None) - reversed_entity.pop("end_angle_deg", None) - reversed_entity.pop("arc_sweep_deg", None) - if entity.get("type") == "arc": - center = entity.get("center") or [0.0, 0.0] - start = reversed_entity.get("start") or [0.0, 0.0] - end = reversed_entity.get("end") or [0.0, 0.0] - start_angle = math.degrees(math.atan2(float(start[1]) - float(center[1]), float(start[0]) - float(center[0]))) - end_angle = math.degrees(math.atan2(float(end[1]) - float(center[1]), float(end[0]) - float(center[0]))) - reversed_sweep = end_angle - start_angle - if reversed_sweep <= -180: - reversed_sweep += 360 - elif reversed_sweep > 180: - reversed_sweep -= 360 - reversed_entity["arc_sweep_deg"] = reversed_sweep - return reversed_entity - - ordered = [segments.pop(0)] - while segments: - _, previous_end = endpoints(ordered[-1]) - if previous_end is None: - ordered.extend(segments) - break - candidates = [] - for index, candidate in enumerate(segments): - candidate_start, candidate_end = endpoints(candidate) - if candidate_start is None or candidate_end is None: - continue - candidates.append((distance(previous_end, candidate_start), index, candidate)) - candidates.append((distance(previous_end, candidate_end), index, reverse(candidate))) - if not candidates: - ordered.extend(segments) - break - _, selected_index, selected = min(candidates, key=lambda item: item[0]) - ordered.append(selected) - segments.pop(selected_index) - return ordered - - def append_wire_profile(profile_entities: list[Dict[str, Any]], make_face_mode: Optional[str] = None) -> None: - profile_entities = orient_wire_entities(profile_entities) - code.append(" with BuildLine():") - code.append(" pass") - emitted_wire = False - line_points = [] - for line_ent in profile_entities: - if line_ent.get("type") == "line": - line_points.extend([line_ent.get("start", [0, 0]), line_ent.get("end", [0, 0])]) - line_bbox = None - if line_points: - xs = [float(point[0]) for point in line_points] - ys = [float(point[1]) for point in line_points] - line_bbox = (min(xs), min(ys), max(xs), max(ys)) - for ent in profile_entities: - ent_type = ent.get("type", "") - if ent_type == "line": - start = ent.get("start", [0, 0, 0]) - end = ent.get("end", [0, 0, 0]) - if math.hypot(float(start[0]) - float(end[0]), float(start[1]) - float(end[1])) <= 1e-6: - code.append(" # Skip zero-length line") - continue - code.append(f" Line(({start[0]}, {start[1]}), ({end[0]}, {end[1]}))") - emitted_wire = True - elif ent_type == "arc": - center = ent.get("center", [0, 0, 0]) - radius = ent.get("radius_mm", 1) - if "start_angle_deg" in ent and "end_angle_deg" in ent: - start_angle = ent["start_angle_deg"] - end_angle = ent["end_angle_deg"] - else: - start = ent.get("start", [0, 0]) - end = ent.get("end", [0, 0]) - start_angle = math.degrees(math.atan2(start[1] - center[1], start[0] - center[0])) - end_angle = math.degrees(math.atan2(end[1] - center[1], end[0] - center[0])) - if ent.get("arc_sweep_deg") is not None: - arc_size = float(ent["arc_sweep_deg"]) - else: - curve_axis = ent.get("curve_axis") or ent.get("raw", {}).get("curve_axis") - if isinstance(curve_axis, list) and len(curve_axis) >= 3 and abs(float(curve_axis[2])) > 1e-9: - if float(curve_axis[2]) >= 0: - arc_size = (end_angle - start_angle) % 360 - else: - arc_size = -((start_angle - end_angle) % 360) - else: - arc_size = end_angle - start_angle - if arc_size <= 0: - arc_size += 360 - if arc_size > 180: - arc_size -= 360 - code.append(f" CenterArc(({center[0]}, {center[1]}), {radius}, {start_angle}, {arc_size})") - emitted_wire = True - else: - code.append(f" # TODO: entity type {ent_type}") - if not emitted_wire: - code.append(" # Skip empty wire profile") - return - if make_face_mode: - code.append(f" make_face(mode=Mode.{make_face_mode.upper()})") - else: - code.append(" make_face()") - - if loops: - ordered_loops = sorted( - enumerate(loops), - key=lambda item: (1 if item[1].get("profile_mode") == "subtract" else 0, item[0]), - ) - for loop_order_index, (loop_index, loop) in enumerate(ordered_loops): - profile_entities = [ - entities[idx] - for idx in loop.get("entity_indices", []) - if idx < len(entities) - and not entities[idx].get("construction", False) - and entities[idx].get("type") in ("line", "arc", "circle") - ] - circle_profile_entities = [ - ent for ent in profile_entities - if ent.get("type") == "circle" or (ent.get("type") == "arc" and ent.get("is_circle")) - ] - wire_profile_entities = [ - ent for ent in profile_entities - if ent.get("type") in ("line", "arc") and ent not in circle_profile_entities - ] - wire_profile_entities = _ordered_wire_entities(wire_profile_entities) - if not profile_entities: - continue - mode = loop.get("profile_mode") - if wire_profile_entities: - append_wire_profile(wire_profile_entities, mode if loop_order_index > 0 or mode else None) - else: - for ent in circle_profile_entities: - center = ent.get("center", [0, 0, 0]) - radius = ent.get("radius_mm", 1) - code.append(f" with Locations(({center[0]}, {center[1]})):") - if mode == "subtract": - code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") - else: - code.append(f" Circle({radius})") - elif wire_entities: - append_wire_profile(wire_entities) - - return code - - -def _sketch_circle_radii_mm(sketch: Optional[Dict[str, Any]]) -> list[float]: - if not isinstance(sketch, dict): - return [] - radii = [] - for entity in sketch.get("entities", []) or []: - if entity.get("construction") or entity.get("type") != "circle": - continue - radius = float(entity.get("radius_mm") or 0) - if radius > 0: - radii.append(abs(radius)) - return radii - - -def _flip_side_step_inner_radius_mm( - op: Dict[str, Any], - sketch: Optional[Dict[str, Any]], - operations: list[Dict[str, Any]], - sketches: Dict[str, Dict[str, Any]], -) -> Optional[float]: - outer_radii = _sketch_circle_radii_mm(sketch) - if not outer_radii: - return None - outer = max(outer_radii) - if len(outer_radii) > 1: - return min(outer_radii) - inner = None - try: - op_index = operations.index(op) - except ValueError: - op_index = len(operations) - for prev in operations[:op_index]: - if prev.get("type") != "extrude_cut": - continue - if not (prev.get("parameters") or {}).get("flip_side_to_cut"): - continue - prev_sketch = sketches.get(prev.get("sketch") or "", {}) - for radius in _sketch_circle_radii_mm(prev_sketch): - if radius < outer - 1e-6: - inner = radius if inner is None else max(inner, radius) - return inner - - -def _flip_side_uses_step_ring( - op: Dict[str, Any], - sketch: Optional[Dict[str, Any]], - operations: list[Dict[str, Any]], - sketches: Dict[str, Dict[str, Any]], -) -> tuple[Optional[float], Optional[float]]: - outer_radii = _sketch_circle_radii_mm(sketch) - if not outer_radii: - return None, None - outer = max(outer_radii) - inner = _flip_side_step_inner_radius_mm(op, sketch, operations, sketches) - if inner is None or outer <= inner + 0.5: - return None, None - if outer < 35 and outer / inner < 1.5: - return None, None - return outer, inner - - -def _effective_extrude_cut_depth_mm( - op: Dict[str, Any], - sketch: Optional[Dict[str, Any]], - distance_mm: float, -) -> float: - params = op.get("parameters") if isinstance(op.get("parameters"), dict) else {} - if not params.get("flip_side_to_cut"): - return distance_mm - workplane = (sketch or {}).get("workplane") or {} - origin = workplane.get("origin_mm") or [0.0, 0.0, 0.0] - normal = workplane.get("normal") or [0.0, 0.0, 1.0] - if not isinstance(origin, list) or not isinstance(normal, list) or len(origin) < 3 or len(normal) < 3: - return distance_mm - axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) - cut_amount = distance_mm if params.get("reverse_direction", False) else -abs(distance_mm) - cut_sign = -1.0 if cut_amount < 0 else 1.0 - owned_values = [] - for face in op.get("source_owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - if not surface.get("is_plane"): - continue - box = face.get("box_m") - if not isinstance(box, list) or len(box) < 6: - continue - thicknesses = [abs(float(box[i + 3]) - float(box[i])) * 1000 for i in range(3)] - if min(thicknesses) > 0.5: - continue - owned_values.extend([float(box[axis]) * 1000, float(box[axis + 3]) * 1000]) - if not owned_values: - return distance_mm - transition = (min(owned_values) if cut_sign < 0 else max(owned_values)) + cut_sign * 1.0 - effective = abs(float(origin[axis]) - transition) - if effective <= 1e-6: - return distance_mm - if abs(effective - abs(distance_mm)) <= 0.25: - return distance_mm - # Guard: owned-face depth can be wrong when all owned faces - # are near the sketch plane (e.g., edge details), not at the - # real cut termination. Fall back to a through-cut distance - # so the invert-cutter extends past the entire body. - if effective < max(2.0, abs(distance_mm) * 0.15): - return max(distance_mm, THROUGH_CUT_AMOUNT_MM) - return effective - - -def _owned_extrude_terminal_offsets_mm( - op: Dict[str, Any], - sketch: Optional[Dict[str, Any]], -) -> tuple[Optional[float], Optional[float]]: - """Return the nearest owned planar end faces along the sketch normal. - - SolidWorks can report a two-sided feature with a stale blind depth when one - side terminates on geometry. The feature-owned end face is the reliable - result geometry: its signed offset from the sketch plane identifies the - actual termination direction and distance. - """ - workplane = (sketch or {}).get("workplane") or {} - origin = workplane.get("origin_mm") or [] - normal = workplane.get("normal") or [] - if not (isinstance(origin, list) and isinstance(normal, list) and len(origin) >= 3 and len(normal) >= 3): - return None, None - magnitude = math.sqrt(sum(float(value) ** 2 for value in normal[:3])) - if magnitude <= 1e-9: - return None, None - unit_normal = [float(value) / magnitude for value in normal[:3]] - positive: list[float] = [] - negative: list[float] = [] - for face in op.get("source_owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - if not surface.get("is_plane"): - continue - params = surface.get("plane_params") - if not isinstance(params, list) or len(params) < 6: - continue - point_mm = [float(value) * 1000 for value in params[3:6]] - offset = sum((point_mm[index] - float(origin[index])) * unit_normal[index] for index in range(3)) - if offset > 1e-4: - positive.append(offset) - elif offset < -1e-4: - negative.append(offset) - return (max(positive) if positive else None, min(negative) if negative else None) - - -def _resolve_extrude_owned_termination( - op: Dict[str, Any], - sketch: Optional[Dict[str, Any]], -) -> Dict[str, Any]: - """Resolve an asymmetric two-sided add from its SolidWorks-owned end face.""" - params = op.get("parameters") if isinstance(op.get("parameters"), dict) else {} - if op.get("type") != "extrude_add" or not params.get("both_directions"): - return op - positive, negative = _owned_extrude_terminal_offsets_mm(op, sketch) - if (positive is None) == (negative is None): - return op - resolved = dict(op) - resolved_params = dict(params) - resolved_params["distance_mm"] = positive if positive is not None else abs(float(negative)) - resolved_params["reverse_distance_mm"] = 0 - resolved_params["both_directions"] = False - resolved_params["reverse_direction"] = negative is not None - resolved_params["owned_termination_resolved"] = True - resolved["parameters"] = resolved_params - return resolved - - -def _generate_extrude( - op: Dict[str, Any], - sketch: Optional[Dict[str, Any]] = None, - operations: Optional[list[Dict[str, Any]]] = None, - sketches: Optional[Dict[str, Dict[str, Any]]] = None, -) -> list[str]: - params = op.get("parameters", {}) - distance = _effective_extrude_cut_depth_mm(op, sketch, float(params.get("distance_mm", 10) or 10)) - reverse_distance = params.get("reverse_distance_mm", 0) - op_type = op.get("type", "") - name = op.get("name", "") - both_directions = params.get("both_directions", False) - flip_side_to_cut = bool(params.get("flip_side_to_cut", False)) - end_condition_code = params.get("end_condition_code") - reverse_end_condition_code = params.get("reverse_end_condition_code") - end_condition = SW_END_CONDITIONS.get(end_condition_code, f"Unknown({end_condition_code})") - operations = operations or [] - sketches = sketches or {} - outer_radius, inner_radius = ( - _flip_side_uses_step_ring(op, sketch, operations, sketches) if flip_side_to_cut else (None, None) - ) - resolved_owned_termination = bool(params.get("owned_termination_resolved")) - - code = [f" # {op_type}: {name}"] - if resolved_owned_termination: - code.append(" # Use the owned planar end face to resolve SW's asymmetric termination") - preserve_visible = bool(op.get("source_owned_faces")) and op_type == "extrude_add" - if end_condition_code is not None: - code.append(f" # SW end condition: {end_condition}") - - owned_cylinder_faces = _owned_cylindrical_cut_faces(op, sketch or {}) - prefer_blind_sketch = _prefer_blind_sketch_extrude( - op, sketch or {}, distance, end_condition_code, owned_cylinder_faces - ) - if op_type == "extrude_cut" and owned_cylinder_faces and flip_side_to_cut: - code.append(" # Replay SW flip-side circular cut from owned cylindrical faces") - code.append(f" result = cut_owned_flip_side_cylindrical_faces(result, {repr(owned_cylinder_faces)})") - return code - - if op_type == "extrude_cut" and owned_cylinder_faces and not flip_side_to_cut and not prefer_blind_sketch: - code.append(" # Replay cut from SW owned cylindrical faces when start/end references are missing") - code.append(f" result = cut_owned_cylindrical_faces(result, {repr(owned_cylinder_faces)})") - return code - - owned_bbox = _owned_bbox_cut(op, sketch or {}, distance) - if op_type == "extrude_cut" and owned_bbox and not flip_side_to_cut and not prefer_blind_sketch: - code.append(" # Replay cut from SW owned face bbox when extrude start/end references are missing") - code.append(f" result = cut_owned_bbox(result, {repr(owned_bbox)})") - return code - - if distance == 0 and reverse_distance == 0: - if op_type == "extrude_cut" and end_condition_code not in (None, 0): - distance = THROUGH_CUT_AMOUNT_MM - both_directions = end_condition_code in (1, 2, 9) - code.append(f" # TODO: exact sw_extrude_cut_{end_condition}; using long cutter") - else: - code.append(" # Skip: zero distance") - return code - elif op_type == "extrude_add" and (end_condition_code in (6, 8) or reverse_end_condition_code in (6, 8)): - code.append(" # SW mid-plane/two-sided extrusion represented by this IR") - distance = distance / 2 - reverse_distance = distance - both_directions = True - elif op_type == "extrude_cut" and end_condition_code not in (None, 0): - distance = max(distance, reverse_distance, THROUGH_CUT_AMOUNT_MM) - both_directions = both_directions or end_condition_code in (1, 2, 9) - code.append(f" # TODO: exact sw_extrude_cut_{end_condition}; using long cutter") - - if both_directions: - amount = max(distance, reverse_distance) if reverse_distance > 0 else distance - if op_type == "extrude_cut": - code.append(f" cutter = extrude(sketch.sketch, amount={amount}, both=True)") - if flip_side_to_cut: - normal = (sketch or {}).get("workplane", {}).get("normal", [0, 0, 1]) - if outer_radius is not None and inner_radius is not None: - code.append( - " result = sw_flip_side_step_cut(" - f"result, cutter, normal={_tuple3(normal)}, " - f"outer_radius_mm={outer_radius}, inner_radius_mm={inner_radius})" - ) - else: - code.append(f" result = sw_inverted_profile_cut(result, cutter, normal={_tuple3(normal)})") - else: - code.append(" result = safe_subtract(result, cutter)") - else: - code.append(f" solid = extrude(sketch.sketch, amount={amount}, both=True)") - code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") - elif op_type == "extrude_cut": - if distance > 0: - cut_amount = distance if params.get("reverse_direction", False) else -distance - code.append(f" cutter = extrude(sketch.sketch, amount={cut_amount})") - # 当盲拉伸从不同于草图的起始面开始时,平移cutter到正确位置 - if prefer_blind_sketch: - face_offset = _blind_extrude_face_offset(op, sketch or {}) - if face_offset is not None: - code.append(f" cutter = cutter.locate(Location({_tuple3(face_offset)}))") - if flip_side_to_cut: - normal = (sketch or {}).get("workplane", {}).get("normal", [0, 0, 1]) - if outer_radius is not None and inner_radius is not None: - code.append( - " result = sw_flip_side_step_cut(" - f"result, cutter, normal={_tuple3(normal)}, " - f"outer_radius_mm={outer_radius}, inner_radius_mm={inner_radius})" - ) - else: - code.append(f" result = sw_inverted_profile_cut(result, cutter, normal={_tuple3(normal)})") - else: - code.append(" result = safe_subtract(result, cutter)") - else: - code.append(" # Skip: zero distance cut") - else: - add_amount = -distance if params.get("reverse_direction", False) else distance - code.append(f" solid = extrude(sketch.sketch, amount={add_amount})") - code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") - - return code - - -def _owned_cylindrical_cut_faces(op: Dict[str, Any], sketch: Dict[str, Any]) -> list[Dict[str, Any]]: - if op.get("type") != "extrude_cut": - return [] - sketch_radii = [ - abs(float(entity.get("radius_mm") or 0)) - for entity in sketch.get("entities", []) or [] - if not entity.get("construction") and entity.get("type") == "circle" - ] - if not sketch_radii: - return [] - matched = [] - for face in op.get("source_owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - params = surface.get("cylinder_params") - bbox = face.get("box_m") - if not (surface.get("is_cylinder") and isinstance(params, list) and len(params) >= 7): - continue - if not (isinstance(bbox, list) and len(bbox) >= 6): - continue - radius_mm = abs(float(params[6]) * 1000) - if not any(abs(radius_mm - sketch_radius) <= max(0.05, sketch_radius * 0.01) for sketch_radius in sketch_radii): - continue - matched.append(face) - return matched - - -def _blind_extrude_face_offset( - op: Dict[str, Any], - sketch: Dict[str, Any], -) -> Optional[list[float]]: - """当盲拉伸从不同于草图的起始面开始时,计算cutter的3D平移向量。 - 返回None表示不需要平移。""" - faces = (op.get("source_owned_faces") or []) - if not faces: - return None - valid_bboxes = [] - for face in faces: - bm = face.get("box_m") - if isinstance(bm, list) and len(bm) >= 6: - valid_bboxes.append([float(v) * 1000 for v in bm[:6]]) - if not valid_bboxes: - return None - normal = (sketch.get("workplane") or {}).get("normal") - if not isinstance(normal, list) or len(normal) < 3: - return None - origin = (sketch.get("workplane") or {}).get("origin_mm") or [0, 0, 0] - # 确定主导轴 (extrude方向) - axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) - normal_sign = 1.0 if float(normal[axis]) >= 0 else -1.0 - sketch_coord = float(origin[axis]) if isinstance(origin, list) and len(origin) > axis else 0.0 - # 取离草图平面最近的面坐标,使cutter从面的最近点开始切入 - # 对于多个面,面可能在草图平面两侧。 - all_coords = [] - for b in valid_bboxes: - all_coords.append(b[axis]) - all_coords.append(b[axis + 3]) - if not all_coords: - return None - # 找离sketch_coord最近的面坐标 - face_coord = min(all_coords, key=lambda c: abs(c - sketch_coord)) - offset = face_coord - sketch_coord - if abs(offset) < 1e-3: - return None - # 返回3D平移向量(仅沿extrude方向) - result = [0.0, 0.0, 0.0] - result[axis] = offset - return result - - -def _prefer_blind_sketch_extrude( - op: Dict[str, Any], - sketch: Dict[str, Any], - distance_mm: float, - end_condition_code: Optional[int], - owned_cylinder_faces: list[Dict[str, Any]], -) -> bool: - """优先使用盲拉伸而非 bbox 回退。对于矩形/圆等简单截面, - 盲拉伸比包围盒近似精确得多。含弧的复杂截面可能因方向问题 - 产生意外偏差,此时仍走 bbox 路径。""" - if owned_cylinder_faces: - return False - if end_condition_code not in (None, 0) or distance_mm <= 0: - return False - if not _sketch_has_buildable_profile(sketch): - return False - # 有 owned_faces 的矩形或纯圆截面: 盲拉伸比 bbox 更精确 - entities = sketch.get("entities", []) or [] - non_const = [e for e in entities if not e.get("construction", False)] - types = {e.get("type") for e in non_const if e.get("type") not in ("point", "text")} - # 排除point/text后仍是简单截面才用盲拉伸。 - # 但如果面位于不同平面,让_blind_extrude_face_offset处理 - is_simple = types <= {"line"} or types <= {"circle"} - if not is_simple: - return False - # 检查草图平面与面是否有关键偏移 - 只有当盲拉伸需要偏移修正时才使用 - faces = op.get("source_owned_faces") or [] - if faces and _blind_extrude_face_offset(op, sketch) is not None: - return True # 有面偏移,需要盲拉伸+offset修正 - # 无面偏移时,只有当start/end引用完整时才用盲拉伸 - if op.get("start_reference") or op.get("end_reference"): - return True - return False - - -def _owned_bbox_cut(op: Dict[str, Any], sketch: Dict[str, Any], distance_mm: float) -> Optional[list[float]]: - if op.get("type") != "extrude_cut": - return None - faces = [ - face for face in (op.get("source_owned_faces") or []) - if isinstance(face, dict) and isinstance(face.get("box_m"), list) and len(face.get("box_m")) >= 6 - ] - if not faces: - return None - bboxes = [[float(value) * 1000 for value in face["box_m"][:6]] for face in faces] - bbox = [ - min(box[axis] for box in bboxes) if axis < 3 else max(box[axis] for box in bboxes) - for axis in range(6) - ] - normal = (sketch.get("workplane") or {}).get("normal") or [0, 0, 1] - if not isinstance(normal, list) or len(normal) < 3: - return None - axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) - extent = abs(bbox[axis + 3] - bbox[axis]) - origin = (sketch.get("workplane") or {}).get("origin_mm") or [0, 0, 0] - origin_coord = float(origin[axis]) if isinstance(origin, list) and len(origin) > axis else None - distance = abs(float(distance_mm or 0)) - origin_outside = ( - origin_coord is not None - and (origin_coord < min(bbox[axis], bbox[axis + 3]) - 1e-6 or origin_coord > max(bbox[axis], bbox[axis + 3]) + 1e-6) - ) - if extent <= distance * 1.25 and not origin_outside: - return None - return bbox - - -def _generate_revolve(op: Dict[str, Any], sketch: Optional[Dict[str, Any]] = None) -> list[str]: - params = op.get("parameters", {}) - angle = params.get("angle_deg") - if angle is None and params.get("angle_rad") is not None: - angle = float(params.get("angle_rad")) * 180 / math.pi - if angle is None: - angle = 360 - if abs(angle - 360) < 1e-6: - angle = 360 - op_type = op.get("type", "") - name = op.get("name", "") - code = [f" # {op_type}: {name}"] - axis_expr = _revolve_axis_expr(params, sketch or {}) - code.append(f" revolve_axis = {axis_expr}") - if op_type == "revolve_cut": - code.append(f" cutter = revolve(sketch.sketch, axis=revolve_axis, revolution_arc={angle})") - code.append(" # Force OCCT to fully evaluate both solids before Boolean ops") - code.append(" _ = list(cutter.solids()); _ = cutter.is_valid; _ = cutter.volume") - code.append(" _ = list(result.solids()); _ = result.is_valid; _ = result.volume") - code.append(" # Use a single subtract and capture the result directly (avoids OCCT heisenbug)") - code.append(" result = safe_subtract(result, cutter)") - else: - code.append(f" solid = revolve(sketch.sketch, axis=revolve_axis, revolution_arc={angle})") - preserve_visible = bool(op.get("source_owned_faces")) - code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") - return code - - -def _revolve_axis_expr(params: Dict[str, Any], sketch: Dict[str, Any]) -> str: - # 优先使用草图中的构造线作为旋转轴, - # 因为它保证位于草图平面上(SW 的 revolve 操作依赖于此) - construction_axis = _sketch_construction_axis(sketch) - if construction_axis: - origin, direction = construction_axis - return f"Axis({_tuple3(origin)}, {_tuple3(direction)})" - - axis_reference = params.get("axis_reference") or {} - if axis_reference.get("origin_mm") and axis_reference.get("direction"): - return f"Axis({_tuple3(axis_reference['origin_mm'])}, {_tuple3(axis_reference['direction'])})" - for candidate in params.get("axis_candidates") or []: - if candidate.get("model_start_mm") and candidate.get("model_direction"): - return f"Axis({_tuple3(candidate['model_start_mm'])}, {_tuple3(candidate['model_direction'])})" - - workplane = sketch.get("workplane", {}) - origin = workplane.get("origin_mm", [0, 0, 0]) - direction = workplane.get("x_dir", [1, 0, 0]) - return f"Axis({_tuple3(origin)}, {_tuple3(direction)})" - - -def _sketch_construction_axis( - sketch: Dict[str, Any], -) -> Optional[tuple[list[float], list[float]]]: - workplane = sketch.get("workplane", {}) - origin = [float(v) for v in workplane.get("origin_mm", [0, 0, 0])] - x_dir = [float(v) for v in workplane.get("x_dir", [1, 0, 0])] - y_dir = [float(v) for v in workplane.get("y_dir", [0, 1, 0])] - - for entity in sketch.get("entities", []): - if entity.get("type") != "line" or not entity.get("construction"): - continue - start = entity.get("start") - end = entity.get("end") - if not start or not end: - continue - start_3d = _sketch_point_to_model_from_basis(origin, x_dir, y_dir, start) - end_3d = _sketch_point_to_model_from_basis(origin, x_dir, y_dir, end) - direction = [end_3d[i] - start_3d[i] for i in range(3)] - length = math.sqrt(sum(component * component for component in direction)) - if length <= 0: - continue - return start_3d, [component / length for component in direction] - return None - - -def _sketch_point_to_model_from_basis( - origin: list[float], x_dir: list[float], y_dir: list[float], point: list[float] -) -> list[float]: - return [ - origin[i] + x_dir[i] * float(point[0]) + y_dir[i] * float(point[1]) - for i in range(3) - ] - - -def _generate_fillet(op: Dict[str, Any]) -> list[str]: - params = op.get("parameters", {}) - radius = params.get("radius_mm") - selectors = op.get("selectors", []) - owned_faces = op.get("source_owned_faces") or [] - if not radius or float(radius) <= 0: - return [f" # Fillet skipped: source radius missing for {op.get('name', '')}"] - return [ - f" # Fillet: {op.get('name', '')}", - " result = fillet_selected(" - f"result, radius={radius}, selectors={repr(selectors)}, owned_faces={repr(owned_faces)})", - ] - - -def _generate_chamfer(op: Dict[str, Any]) -> list[str]: - params = op.get("parameters", {}) - distance = params.get("distance_mm") - selectors = op.get("selectors", []) - owned_faces = op.get("source_owned_faces") or [] - if not distance or float(distance) <= 0: - return [f" # Chamfer skipped: source distance missing for {op.get('name', '')}"] - return [ - f" # Chamfer: {op.get('name', '')}", - " result = chamfer_selected_with_owned_faces(" - f"result, distance={distance}, selectors={repr(selectors)}, owned_faces={repr(owned_faces)})", - ] - - -def _generate_move_face(op: Dict[str, Any]) -> list[str]: - data = (op.get("parameters") or {}).get("move_face_data") or {} - selected_faces = data.get("selected_faces") or [] - return [ - f" # MoveFace pure-JSON operation: {op.get('name', '')}", - " raise NotImplementedError(", - f" 'MoveFace native build123d replay is pending; captured selected_faces={len(selected_faces)}'", - " )", - ] - - -def _hole_should_use_sw_cut_holes(params: Dict[str, Any], owned_cut_faces: list[Dict[str, Any]]) -> bool: - positions = params.get("positions") or [] - diameter = _hole_diameter_mm(params) - if not positions or diameter <= 0: - return False - if len(owned_cut_faces) <= 1: - return False - has_cone_owned = any((face.get("surface") or {}).get("is_cone") for face in owned_cut_faces) - drill_angle = _hole_drill_angle_rad(params) - if has_cone_owned and not (_hole_has_drill_tip(params) and drill_angle > 0): - return False - counterbore_diameter = _hole_counterbore_diameter_mm(params) - counterbore_depth = _hole_counterbore_depth_mm(params) - if counterbore_diameter > diameter and counterbore_depth > 0: - return True - return _hole_has_through_dimension(params) - - -def _effective_hole_cut_depth_mm(params: Dict[str, Any]) -> float: - if _hole_has_through_dimension(params): - return THROUGH_CUT_AMOUNT_MM - return _hole_depth_mm(params) - - -def _generate_hole(op: Dict[str, Any]) -> list[str]: - params = op.get("parameters", {}) - diameter = _hole_diameter_mm(params) - depth = _effective_hole_cut_depth_mm(params) - drill_angle = _hole_drill_angle_rad(params) - include_drill_tip = _hole_has_drill_tip(params) - countersink_diameter = _hole_countersink_diameter_mm(params) - countersink_angle = _hole_countersink_angle_rad(params) - counterbore_diameter = _hole_counterbore_diameter_mm(params) - counterbore_depth = _hole_counterbore_depth_mm(params) - positions = [pos.get("mm") for pos in params.get("positions", []) if pos.get("mm")] - host_face = params.get("host_face") or {} - owned_cut_faces = _hole_owned_cut_faces(op) - # Feature position sketches are occasionally incomplete in the plugin export - # (notably for wizard holes with multiple instances). The faces owned by the - # feature are the authoritative result from SolidWorks, including every hole - # location, counterbore, countersink, and drill tip. Prefer replaying those - # surfaces whenever they are available; fall back to the parametric cutter - # only when the exporter has no usable owned-face geometry. - if owned_cut_faces: - return [ - f" # Hole: {op.get('name', '')}", - " # Replay hole from SW owned cut faces to preserve side and axis", - f" result = cut_owned_cylindrical_faces(result, {repr(owned_cut_faces)})", - ] - return [ - f" # Hole: {op.get('name', '')}", - f" result = sw_cut_holes(result, positions={json.dumps(positions)}, host_face={json.dumps(host_face)}, diameter={diameter}, depth={depth}, drill_angle={drill_angle}, include_drill_tip={include_drill_tip}, countersink_diameter={countersink_diameter}, countersink_angle={countersink_angle}, counterbore_diameter={counterbore_diameter}, counterbore_depth={counterbore_depth})", - ] - - -def _hole_owned_cut_faces(op: Dict[str, Any]) -> list[Dict[str, Any]]: - matched = [] - for face in op.get("source_owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - bbox = face.get("box_m") - has_cylinder = ( - surface.get("is_cylinder") - and isinstance(surface.get("cylinder_params"), list) - and len(surface.get("cylinder_params") or []) >= 7 - ) - has_cone = ( - surface.get("is_cone") - and isinstance(surface.get("cone_params"), list) - and len(surface.get("cone_params") or []) >= 8 - ) - if not (has_cylinder or has_cone): - continue - if not (isinstance(bbox, list) and len(bbox) >= 6): - continue - matched.append(face) - return matched - - -def _generate_linear_pattern( - op: Dict[str, Any], - operations: list[Dict[str, Any]], - sketches: Dict[str, Dict[str, Any]], - references: Dict[str, Any], -) -> list[str]: - params = op.get("parameters", {}) - source_features = params.get("source_features") or [] - offsets = _linear_pattern_offsets(op) - code = [f" # Linear pattern: {op.get('name', '')}"] - - if not source_features or not offsets: - code.append(" # Skip: no source features or pattern offsets") - return code - - for source_feature in source_features: - source_op = _find_operation_for_source_feature(operations, source_feature) - if not source_op: - code.append(f" # Skip: source feature not found {source_feature.get('name')}") - continue - - for offset_index, offset in enumerate(offsets, start=1): - copied_op = _translated_operation(source_op, offset) - copied_op["name"] = f"{source_op.get('name', '')} pattern copy {offset_index}" - op_type = copied_op.get("type") - - if op_type == "hole": - code.extend(_generate_hole(copied_op)) - elif op_type in ("extrude_cut", "extrude_add", "revolve_cut", "revolve_add"): - source_sketch_id = copied_op.get("sketch") - source_sketch = sketches.get(source_sketch_id or "") - if not source_sketch: - code.append(f" # Skip: source sketch not found for {copied_op.get('name')}") - continue - if not _sketch_has_buildable_profile(source_sketch): - code.append(f" # Skip: source sketch has no buildable profile for {copied_op.get('name')}") - continue - - copied_sketch = _translated_sketch(source_sketch, offset, f"{source_sketch_id}_pattern_{offset_index}") - code.extend(_generate_sketch(copied_sketch, references, copied_op)) - if op_type in ("extrude_cut", "extrude_add"): - code.extend(_generate_extrude(copied_op, copied_sketch, operations, sketches)) - else: - code.extend(_generate_revolve(copied_op, copied_sketch)) - else: - code.append(f" # TODO: pattern source type {op_type}") - - return code - - -def _generate_mirror_pattern( - op: Dict[str, Any], - operations: list[Dict[str, Any]], - sketches: Dict[str, Dict[str, Any]], - references: Dict[str, Any], -) -> list[str]: - """生成镜像代码。SW MirrorPattern 镜像的是特征而非整体,因此必须先切掉镜像面负侧的实体,只保留正侧一半再镜像。""" - params = op.get("parameters", {}) - source_features = params.get("source_features") or [] - raw = op.get("raw_parameters", {}) - mirror_plane_info = raw.get("mirror_plane") or {} - - code = [f" # Mirror pattern: {op.get('name', '')}"] - - plane_origin = _extract_mirror_plane_origin(raw, mirror_plane_info) - plane_normal = _extract_mirror_plane_normal(raw, mirror_plane_info) - - mx = plane_origin[0] if plane_origin else 0.0 - my = plane_origin[1] if plane_origin else 0.0 - mz = plane_origin[2] if plane_origin else 0.0 - nx = plane_normal[0] if plane_normal else 0.0 - ny = plane_normal[1] if plane_normal else 0.0 - nz = plane_normal[2] if plane_normal else 1.0 - - code.append(f" mirror_plane = Plane(origin=({mx}, {my}, {mz}), z_dir=({nx}, {ny}, {nz}))") - code.append(f" mx, my, mz = {mx}, {my}, {mz}") - code.append(f" nx, ny, nz = {nx}, {ny}, {nz}") - code.append(f" try:") - code.append(f" bbox = result.bounding_box()") - code.append(f" margin = 10.0") - # Determine dominant axis and cut away the -normal side - adx, ady, adz = abs(nx), abs(ny), abs(nz) - if adx >= ady and adx >= adz: - if nx > 0: - code.append(f" cut_w = (mx - bbox.min.X) + margin") - code.append(f" cut_box = Solid.make_box(cut_w, bbox.max.Y - bbox.min.Y + 2*margin, bbox.max.Z - bbox.min.Z + 2*margin)") - code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") - else: - code.append(f" cut_w = (bbox.max.X - mx) + margin") - code.append(f" cut_box = Solid.make_box(cut_w, bbox.max.Y - bbox.min.Y + 2*margin, bbox.max.Z - bbox.min.Z + 2*margin)") - code.append(f" cut_box = cut_box.translate((mx, bbox.min.Y - margin, bbox.min.Z - margin))") - elif ady >= adx and ady >= adz: - if ny > 0: - code.append(f" cut_h = (my - bbox.min.Y) + margin") - code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, cut_h, bbox.max.Z - bbox.min.Z + 2*margin)") - code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") - else: - code.append(f" cut_h = (bbox.max.Y - my) + margin") - code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, cut_h, bbox.max.Z - bbox.min.Z + 2*margin)") - code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, my, bbox.min.Z - margin))") - else: - if nz > 0: - code.append(f" cut_d = (mz - bbox.min.Z) + margin") - code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, bbox.max.Y - bbox.min.Y + 2*margin, cut_d)") - code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") - else: - code.append(f" cut_d = (bbox.max.Z - mz) + margin") - code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, bbox.max.Y - bbox.min.Y + 2*margin, cut_d)") - code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, mz))") - code.append(f" half = result.cut(cut_box)") - code.append(f" mirrored = half.mirror(mirror_plane)") - code.append(f" result = half.fuse(mirrored).clean()") - code.append(f" except Exception as e:") - code.append(f" print(f'mirror failed: {{e}}')") - return code - - -def _extract_mirror_plane_origin(raw: dict, mirror_plane_info: dict): - mir_origin = raw.get("mirror_plane_origin") - if mir_origin and isinstance(mir_origin, (list, tuple)) and len(mir_origin) >= 3: - return (float(mir_origin[0]), float(mir_origin[1]), float(mir_origin[2])) - origin_list = mirror_plane_info.get("origin_mm") or mirror_plane_info.get("origin") or [] - if origin_list and len(origin_list) >= 3: - return (float(origin_list[0]), float(origin_list[1]), float(origin_list[2])) - frame = mirror_plane_info.get("frame") - if isinstance(frame, dict): - origin_list = frame.get("origin") or [] - if origin_list and len(origin_list) >= 3: - return (float(origin_list[0]), float(origin_list[1]), float(origin_list[2])) - return None - - -def _extract_mirror_plane_normal(raw: dict, mirror_plane_info: dict): - mir_normal = raw.get("mirror_plane_normal") - if mir_normal and isinstance(mir_normal, (list, tuple)) and len(mir_normal) >= 3: - return (float(mir_normal[0]), float(mir_normal[1]), float(mir_normal[2])) - normal_list = mirror_plane_info.get("normal") or [] - if normal_list and len(normal_list) >= 3: - return (float(normal_list[0]), float(normal_list[1]), float(normal_list[2])) - frame = mirror_plane_info.get("frame") - if isinstance(frame, dict): - normal_list = frame.get("normal") or [] - if normal_list and len(normal_list) >= 3: - return (float(normal_list[0]), float(normal_list[1]), float(normal_list[2])) - return None - - -def _find_operation_for_source_feature( - operations: list[Dict[str, Any]], source_feature: Dict[str, Any] -) -> Optional[Dict[str, Any]]: - source_index = source_feature.get("index") - source_name = source_feature.get("name") - source_identity = source_feature.get("identity") if isinstance(source_feature.get("identity"), dict) else {} - source_stable_id = source_feature.get("stable_id") or source_identity.get("stable_id") - source_persistent_reference = source_feature.get("persistent_reference") or source_identity.get("persistent_reference") - for op in operations: - op_source = op.get("source_feature", {}) - if source_index is not None and op_source.get("index") == source_index: - return op - for op in operations: - op_source = op.get("source_feature", {}) - op_identity = op_source.get("identity") if isinstance(op_source.get("identity"), dict) else {} - if source_stable_id and ( - op_source.get("stable_id") == source_stable_id - or op_identity.get("stable_id") == source_stable_id - ): - return op - if source_persistent_reference and ( - op_source.get("persistent_reference") == source_persistent_reference - or op_identity.get("persistent_reference") == source_persistent_reference - ): - return op - for op in operations: - if source_name and op.get("name") == source_name: - return op - return None - - -def _find_source_operation_for_pattern( - operations: list[Dict[str, Any]], source_features: list[Dict[str, Any]] -) -> Optional[Dict[str, Any]]: - for source_feature in source_features: - source_op = _find_operation_for_source_feature(operations, source_feature) - if source_op: - return source_op - return None - - -def _linear_pattern_offsets(op: Dict[str, Any]) -> list[tuple[float, float, float]]: - params = op.get("parameters", {}) - raw = op.get("raw_parameters", {}) - explicit_offsets = raw.get("explicit_offsets_mm") - if isinstance(explicit_offsets, list) and explicit_offsets: - return [ - (float(offset[0]), float(offset[1]), float(offset[2])) - for offset in explicit_offsets - if isinstance(offset, list) and len(offset) >= 3 - ] - d1_count = int(raw.get("d1_total_instances") or params.get("total_instances") or 1) - d2_count = int(raw.get("d2_total_instances") or 1) - d1_spacing = float(raw.get("d1_spacing_mm") or params.get("spacing_mm") or 0) - d2_spacing = float(raw.get("d2_spacing_mm") or 0) - d1_vector = _pattern_direction_vector(raw.get("direction1") or params.get("direction1"), d1_spacing) - d2_vector = _pattern_direction_vector(raw.get("direction2") or params.get("direction2"), d2_spacing) - - offsets = [] - for i in range(d1_count): - for j in range(d2_count): - if i == 0 and j == 0: - continue - offsets.append(tuple(d1_vector[k] * i + d2_vector[k] * j for k in range(3))) - return offsets - - -def _pattern_direction_vector(direction: Optional[Dict[str, Any]], spacing: float) -> tuple[float, float, float]: - if not direction or not spacing: - return (0.0, 0.0, 0.0) - direct_vector = direction.get("vector") - if isinstance(direct_vector, list) and len(direct_vector) >= 3: - vector = tuple(float(direct_vector[i]) for i in range(3)) - length = math.sqrt(sum(component * component for component in vector)) - if length <= 0: - return (0.0, 0.0, 0.0) - return tuple(component / length * spacing for component in vector) - start = direction.get("start", {}).get("mm") - end = direction.get("end", {}).get("mm") - if not start or not end: - return (0.0, 0.0, 0.0) - vector = tuple(float(end[i]) - float(start[i]) for i in range(3)) - length = math.sqrt(sum(component * component for component in vector)) - if length <= 0: - return (0.0, 0.0, 0.0) - return tuple(component / length * spacing for component in vector) - - -def _translated_operation(op: Dict[str, Any], offset: tuple[float, float, float]) -> Dict[str, Any]: - copied = deepcopy(op) - params = copied.get("parameters") or {} - axis_reference = params.get("axis_reference") - if isinstance(axis_reference, dict) and isinstance(axis_reference.get("origin_mm"), list): - origin = list(axis_reference.get("origin_mm") or [0, 0, 0]) - origin = (origin + [0, 0, 0])[:3] - axis_reference["origin_mm"] = [float(origin[i]) + float(offset[i]) for i in range(3)] - - if copied.get("type") == "hole": - positions = params.get("positions") or [] - local_offset = _model_offset_to_host_local(offset, params.get("host_face") or {}) - for position in positions: - if position.get("mm"): - point = list(position.get("mm") or [0, 0, 0]) - point = (point + [0, 0, 0])[:3] - position["mm"] = [ - float(point[0]) + local_offset[0], - float(point[1]) + local_offset[1], - float(point[2]) + local_offset[2], - ] - if position.get("m"): - position["m"] = [value / 1000 for value in position.get("mm", [])] - if any(abs(float(offset[i])) > 1e-9 for i in range(3)): - owned_faces = copied.get("source_owned_faces") or [] - if owned_faces: - copied["source_owned_faces"] = _translate_owned_faces(owned_faces, offset) - return copied - - -def _translate_owned_faces( - faces: list[Dict[str, Any]], - offset: tuple[float, float, float], -) -> list[Dict[str, Any]]: - translated = [] - shift_mm = (float(offset[0]), float(offset[1]), float(offset[2])) - shift_m = (shift_mm[0] / 1000.0, shift_mm[1] / 1000.0, shift_mm[2] / 1000.0) - for face in faces: - if not isinstance(face, dict): - continue - copied = deepcopy(face) - box = copied.get("box_m") - if isinstance(box, list) and len(box) >= 6: - copied["box_m"] = [ - float(box[0]) + shift_m[0], - float(box[1]) + shift_m[1], - float(box[2]) + shift_m[2], - float(box[3]) + shift_m[0], - float(box[4]) + shift_m[1], - float(box[5]) + shift_m[2], - ] - surface = copied.get("surface") - if isinstance(surface, dict): - for key in ("cylinder_params", "cone_params"): - params = surface.get(key) - if isinstance(params, list) and len(params) >= 3: - updated = list(params) - updated[0] = float(updated[0]) + shift_m[0] - updated[1] = float(updated[1]) + shift_m[1] - updated[2] = float(updated[2]) + shift_m[2] - surface[key] = updated - translated.append(copied) - return translated - - -def _model_offset_to_host_local( - offset: tuple[float, float, float], - host_face: Dict[str, Any], -) -> tuple[float, float, float]: - frame = host_face.get("frame") if isinstance(host_face, dict) else {} - if not isinstance(frame, dict): - return offset - x_dir = frame.get("x_dir") - y_dir = frame.get("y_dir") - if not ( - isinstance(x_dir, list) - and len(x_dir) >= 3 - and isinstance(y_dir, list) - and len(y_dir) >= 3 - ): - return offset - local_x = sum(float(offset[i]) * float(x_dir[i]) for i in range(3)) - local_y = sum(float(offset[i]) * float(y_dir[i]) for i in range(3)) - return (local_x, local_y, 0.0) - - -def _translated_sketch( - sketch: Dict[str, Any], offset: tuple[float, float, float], sketch_id: str -) -> Dict[str, Any]: - copied = deepcopy(sketch) - copied["id"] = sketch_id - copied["name"] = f"{sketch.get('name', sketch_id)} pattern copy" - workplane = copied.setdefault("workplane", {}) - origin = list(workplane.get("origin_mm") or [0, 0, 0]) - origin = (origin + [0, 0, 0])[:3] - workplane["origin_mm"] = [float(origin[i]) + float(offset[i]) for i in range(3)] - return copied - - -def _translate_sketch_entities(sketch: Dict[str, Any], offset: tuple[float, float, float]) -> None: - dx, dy = offset[0], offset[1] - for entity in sketch.get("entities", []): - for key in ("start", "end", "center"): - point = entity.get(key) - if isinstance(point, list) and len(point) >= 2: - point[0] = float(point[0]) + dx - point[1] = float(point[1]) + dy - raw = entity.get("raw", {}) - for key in ("start", "end", "center"): - raw_point = raw.get(key) - if isinstance(raw_point, dict): - mm = raw_point.get("mm") - if isinstance(mm, list) and len(mm) >= 2: - mm[0] = float(mm[0]) + dx - mm[1] = float(mm[1]) + dy - raw_point["m"] = [value / 1000 for value in mm] - - -def _hole_diameter_mm(params: Dict[str, Any]) -> float: - if params.get("diameter_mm"): - return float(params["diameter_mm"]) - diameters = params.get("diameters_m", {}) - for key in ( - "hole_diameter", - "thru_hole_diameter", - "tap_drill_diameter", - "thru_tap_drill_diameter", - "thread_diameter", - "diameter", - ): - value = diameters.get(key) - if value: - return float(value) * 1000 - return 0 - - -def _hole_depth_mm(params: Dict[str, Any]) -> float: - if params.get("depth_mm"): - return float(params["depth_mm"]) - depths = params.get("depths_m", {}) - for key in ( - "hole_depth", - "thru_hole_depth", - "tap_drill_depth", - "thru_tap_drill_depth", - "thread_depth", - "depth", - ): - value = depths.get(key) - if value: - return float(value) * 1000 - return THROUGH_CUT_AMOUNT_MM - - -def _hole_drill_angle_rad(params: Dict[str, Any]) -> float: - angle = params.get("angles_rad", {}).get("drill_angle") - return float(angle) if angle else 0 - - -def _hole_countersink_angle_rad(params: Dict[str, Any]) -> float: - angle = params.get("angles_rad", {}).get("countersink_angle") - return float(angle) if angle else 0 - - -def _hole_countersink_diameter_mm(params: Dict[str, Any]) -> float: - diameter = params.get("countersink_diameter_mm") - return float(diameter) if diameter else 0 - - -def _hole_counterbore_diameter_mm(params: Dict[str, Any]) -> float: - diameter = params.get("counterbore_diameter_mm") - return float(diameter) if diameter else 0 - - -def _hole_counterbore_depth_mm(params: Dict[str, Any]) -> float: - depth = params.get("counterbore_depth_mm") - return float(depth) if depth else 0 - - -def _hole_has_drill_tip(params: Dict[str, Any]) -> bool: - depths = params.get("depths_m", {}) - angle = _hole_drill_angle_rad(params) - if angle <= 0: - return False - through_depth_keys = ( - "thru_hole_depth", - "thru_tap_drill_depth", - ) - if any(depths.get(key) for key in through_depth_keys): - return False - if params.get("depth_mm"): - return True - return any(depths.get(key) for key in ("hole_depth", "tap_drill_depth", "depth")) - - -def _hole_has_through_dimension(params: Dict[str, Any]) -> bool: - names = " ".join(str(name).lower() for name in params.get("dimension_names", []) or []) - return any(token in names for token in ("通孔", "through", "thru")) - - -def _hole_dimension_value(data_block: Dict[str, Any], tokens: tuple[str, ...]) -> Optional[float]: - for dim in data_block.get("dimensions", []) or []: - name = str(dim.get("name") or "").lower() - if all(token.lower() in name for token in tokens) and dim.get("value") not in (None, ""): - return float(dim.get("value")) - return None - - -def _feature_length_dimension_mm(feature: Dict[str, Any]) -> Optional[float]: - candidates: list[tuple[int, float]] = [] - for dim in feature.get("dimensions") or []: - if not isinstance(dim, dict): - continue - name = str(dim.get("name") or "") - system_value = dim.get("system_value_m") - if system_value not in (None, ""): - length_mm = abs(float(system_value)) * 1000 - elif dim.get("value") not in (None, ""): - length_mm = abs(float(dim.get("value"))) - else: - continue - if length_mm <= 1e-9 or length_mm > 500: - continue - priority = 0 if name.startswith("D1@") else 1 - candidates.append((priority, length_mm)) - if not candidates: - return None - candidates.sort(key=lambda item: (item[0], item[1])) - return candidates[0][1] - - -def _feature_selection_selectors( - feature: Dict[str, Any], - data_block: Optional[Dict[str, Any]] = None, -) -> list[Dict[str, Any]]: - selectors: list[Dict[str, Any]] = [] - seen: set[str] = set() - sources = [] - if isinstance(data_block, dict): - sources.extend(data_block.get("selections") or []) - sources.extend(feature.get("selections") or []) - - for selection in sources: - if not isinstance(selection, dict) or selection.get("kind") != "selection": - continue - geometry = selection.get("object") - if not isinstance(geometry, dict): - continue - kind = geometry.get("kind") - if kind not in ("edge", "face"): - continue - identity = geometry.get("identity") if isinstance(geometry.get("identity"), dict) else {} - stable_key = ( - geometry.get("stable_id") - or geometry.get("persistent_reference") - or identity.get("stable_id") - or identity.get("persistent_reference") - or json.dumps(geometry, sort_keys=True, ensure_ascii=False, default=str) - ) - if stable_key in seen: - continue - seen.add(str(stable_key)) - selectors.append({ - "kind": kind, - "geometry": geometry, - "mark": selection.get("mark"), - "source_feature": { - "name": selection.get("feature_name"), - "type_name": selection.get("feature_type_name"), - }, - }) - if selectors: - return selectors - - for face in feature.get("owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - cylinder_params = surface.get("cylinder_params") - if not (surface.get("is_cylinder") and isinstance(cylinder_params, list) and len(cylinder_params) >= 7): - continue - radius_mm = abs(float(cylinder_params[6]) * 1000) - line_params = [ - float(cylinder_params[0]), - float(cylinder_params[1]), - float(cylinder_params[2]), - float(cylinder_params[3]), - float(cylinder_params[4]), - float(cylinder_params[5]), - ] - stable_key = f"owned_cylinder:{','.join(f'{value:.9g}' for value in line_params)}:{radius_mm:.6g}" - if stable_key in seen: - continue - seen.add(stable_key) - selectors.append({ - "kind": "edge", - "geometry": { - "kind": "edge", - "curve": { - "kind": "curve", - "is_line": True, - "line_params": line_params, - }, - "bbox_mm": [float(value) * 1000 for value in face.get("box_m", [])[:6]] - if isinstance(face.get("box_m"), list) and len(face.get("box_m")) >= 6 - else None, - }, - "tolerance_mm": max(0.5, radius_mm * 2.5), - "source": "owned_cylindrical_face_axis", - }) - for face in feature.get("owned_faces") or []: - if not isinstance(face, dict): - continue - box_m = face.get("box_m") - if not (isinstance(box_m, list) and len(box_m) >= 6): - continue - bbox_mm = [float(value) * 1000 for value in box_m[:6]] - if any(not math.isfinite(value) for value in bbox_mm): - continue - sizes = [abs(bbox_mm[i + 3] - bbox_mm[i]) for i in range(3)] - stable_key = f"owned_face_bbox:{','.join(f'{value:.9g}' for value in bbox_mm)}" - if stable_key in seen: - continue - seen.add(stable_key) - selectors.append({ - "kind": "edge", - "geometry": { - "kind": "edge", - "bbox_mm": bbox_mm, - }, - "tolerance_mm": max(0.5, min(max(sizes), 10.0) * 0.35), - "source": "owned_face_bbox", - }) - return selectors - - -def _feature_selection_source( - feature: Dict[str, Any], - data_block: Optional[Dict[str, Any]] = None, -) -> str: - sources = [] - if isinstance(data_block, dict): - sources.extend(data_block.get("selections") or []) - sources.extend(feature.get("selections") or []) - if any(isinstance(item, dict) and item.get("kind") == "selection" for item in sources): - return "solidworks_original_selection" - if feature.get("owned_faces"): - return "post_feature_owned_face_inference" - return "missing" - - -def _hole_dimension_value_excluding( - data_block: Dict[str, Any], - tokens: tuple[str, ...], - excluded: tuple[str, ...] = (), -) -> Optional[float]: - for dim in data_block.get("dimensions", []) or []: - name = str(dim.get("name") or "").lower() - if excluded and any(token.lower() in name for token in excluded): - continue - if all(token.lower() in name for token in tokens) and dim.get("value") not in (None, ""): - return float(dim.get("value")) - return None - - -def _hole_primary_dimension_fallback(data_block: Dict[str, Any], prefer_small: bool) -> Optional[float]: - values = [] - for dim in data_block.get("dimensions", []) or []: - name = str(dim.get("name") or "").lower() - if not any(token in name for token in ("孔", "hole", "螺", "thread")): - continue - if any(token in name for token in ("沉头", "锥", "counter", "csk", "导头", "angle", "角度")): - continue - value = dim.get("value") - if value in (None, ""): - continue - number = abs(float(value)) - if 0 < number < 200: - values.append(number) - if not values: - return None - return min(values) if prefer_small else max(values) - - -def _hole_primary_diameter_mm(data_block: Dict[str, Any]) -> float: - diameter = ( - _hole_dimension_value_excluding(data_block, ("tap", "drill", "dia"), ("depth", "angle")) - or _hole_dimension_value_excluding(data_block, ("tap", "drill", "diameter"), ("depth", "angle")) - or _hole_dimension_value_excluding(data_block, ("螺纹孔钻头", "直径"), ("深度", "角度")) - or _hole_dimension_value_excluding(data_block, ("钻头", "直径"), ("深度", "角度")) - or _hole_dimension_value_excluding(data_block, ("通孔", "孔直径"), ("沉头", "锥", "counter", "csk", "角度", "深度")) - or _hole_dimension_value_excluding(data_block, ("孔直径",), ("沉头", "锥", "counter", "csk", "角度", "深度")) - or _hole_dimension_value_excluding(data_block, ("hole", "diameter"), ("counter", "csk", "angle", "depth")) - or _hole_dimension_value_excluding(data_block, ("thread", "diameter"), ("counter", "csk", "angle", "depth")) - or _hole_dimension_value_excluding(data_block, ("螺纹",), ("深度", "depth", "角度", "angle")) - or _hole_primary_dimension_fallback(data_block, prefer_small=True) - ) - return abs(float(diameter)) if diameter else 0 - - -def _hole_primary_depth_mm(data_block: Dict[str, Any]) -> float: - depth = ( - _hole_dimension_value_excluding(data_block, ("通孔", "孔深度"), ("沉头", "锥", "counter", "csk", "角度", "直径")) - or _hole_dimension_value_excluding(data_block, ("孔深度",), ("沉头", "锥", "counter", "csk", "角度", "直径")) - or _hole_dimension_value_excluding(data_block, ("螺纹孔钻头", "深度"), ("直径", "角度")) - or _hole_dimension_value_excluding(data_block, ("通孔", "螺纹孔钻头", "深度"), ("直径", "角度")) - or _hole_dimension_value_excluding(data_block, ("tap", "drill", "depth"), ("diameter", "angle")) - or _hole_dimension_value_excluding(data_block, ("hole", "depth"), ("counter", "csk", "angle", "diameter")) - or _hole_dimension_value_excluding(data_block, ("thread", "depth"), ("counter", "csk", "angle", "diameter")) - ) - if depth: - return abs(float(depth)) - return THROUGH_CUT_AMOUNT_MM - - -def _hole_counterbore_dimension_mm(data_block: Dict[str, Any]) -> Optional[float]: - return ( - _hole_dimension_value(data_block, ("柱形沉头", "直径")) - or _hole_dimension_value(data_block, ("柱形沉头孔", "直径")) - or _hole_dimension_value(data_block, ("沉头孔", "直径")) - or _hole_dimension_value(data_block, ("counterbore", "diameter")) - or _hole_dimension_value(data_block, ("counter", "bore", "diameter")) - ) - - -def _hole_counterbore_depth_dimension_mm(data_block: Dict[str, Any]) -> Optional[float]: - return ( - _hole_dimension_value(data_block, ("柱形沉头", "深度")) - or _hole_dimension_value(data_block, ("柱形沉头孔", "深度")) - or _hole_dimension_value(data_block, ("沉头孔", "深度")) - or _hole_dimension_value(data_block, ("counterbore", "depth")) - or _hole_dimension_value(data_block, ("counter", "bore", "depth")) - ) - - -def _hole_angle_dimension_rad(data_block: Dict[str, Any], tokens: tuple[str, ...]) -> Optional[float]: - for dim in data_block.get("dimensions", []) or []: - name = str(dim.get("name") or "").lower() - if all(token.lower() in name for token in tokens): - if dim.get("system_value_m") not in (None, ""): - return float(dim.get("system_value_m")) - if dim.get("value") not in (None, ""): - value = float(dim.get("value")) - return value / 1000 if value > math.tau else value - return None - - -def _extract_edge_selector_points(op: Dict[str, Any]) -> list[list[tuple[float, float, float]]]: - selector_points = [] - for selector in op.get("selectors", []): - geometry = selector.get("geometry") or {} - start = geometry.get("start_vertex") or {} - start_point = start.get("point_m") if isinstance(start, dict) else None - end = geometry.get("end_vertex") or {} - end_point = end.get("point_m") if isinstance(end, dict) else None - if start_point and end_point: - selector_points.append([_point_m_to_mm(start_point), _point_m_to_mm(end_point)]) - return selector_points - - -_SW_METADATA_FEATURE_TYPES = { - "commentsfolder", - "favoritefolder", - "historyfolder", - "selectionsetfolder", - "sensorfolder", - "docsfolder", - "detailcabinet", - "surfacebodyfolder", - "solidbodyfolder", - "envfolder", - "inkmarkupfolder", - "eqnfolder", - "materialfolder", - "configtablefolder", - "ftrfolder", -} - - -def _source_feature(feature: Dict[str, Any], index: int) -> Dict[str, Any]: - source = feature.get("source_feature") if isinstance(feature.get("source_feature"), dict) else {} - identity = source.get("identity") if isinstance(source.get("identity"), dict) else {} - return { - "index": source.get("index", index), - "id": feature.get("id"), - "name": feature.get("name"), - "type": feature.get("type"), - "type_name": feature.get("type_name"), - "stable_id": source.get("stable_id") or identity.get("stable_id"), - "persistent_reference": source.get("persistent_reference") or identity.get("persistent_reference"), - "identity": identity or None, - } - - -def _source_owned_faces(feature: Dict[str, Any]) -> list[Dict[str, Any]]: - faces = feature.get("owned_faces") - if not isinstance(faces, list): - return [] - summarized = [] - for face in faces: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - summarized.append( - { - "box_m": face.get("box_m"), - "area_m2": face.get("area_m2"), - "surface": { - "is_plane": bool(surface.get("is_plane")), - "is_cylinder": bool(surface.get("is_cylinder")), - "is_cone": bool(surface.get("is_cone")), - "is_sphere": bool(surface.get("is_sphere")), - "is_torus": bool(surface.get("is_torus")), - "cylinder_params": surface.get("cylinder_params"), - "cone_params": surface.get("cone_params"), - "plane_params": surface.get("plane_params"), - }, - } - ) - return summarized - - -def _convert_sw_reference(feature: Dict[str, Any], index: int) -> Dict[str, Any]: - snapshot = feature.get("definition_snapshot", {}) - return { - "id": feature.get("id") or f"reference_{index:03d}", - "name": feature.get("name"), - "type": feature.get("type"), - "sw_type": feature.get("type_name"), - "definition": snapshot.get("values", {}), - "source_feature": _source_feature(feature, index), - } - - -def _convert_sw_sketch(feature: Dict[str, Any], sketch_id: str, index: int) -> Dict[str, Any]: - sketch_data = feature.get("sketch_data", {}) - raw_entities = sketch_data.get("entities", []) - raw_converted_entities = [_convert_sw_sketch_entity(entity) for entity in raw_entities] - converted_entities = [] - raw_to_converted_index: dict[int, int] = {} - stable_id_to_raw_index: dict[str, int] = {} - for raw_index, (raw_entity, converted_entity) in enumerate(zip(raw_entities, raw_converted_entities)): - for stable_id in _selectable_stable_ids(raw_entity): - stable_id_to_raw_index.setdefault(stable_id, raw_index) - if converted_entity is None: - continue - raw_to_converted_index[raw_index] = len(converted_entities) - converted_entities.append(converted_entity) - loops = [] - for contour in sketch_data.get("sketch_contours", []) or sketch_data.get("contours", []) or []: - if not isinstance(contour, dict): - continue - entity_indices = contour.get("entity_indices") or contour.get("segment_indices") or [] - if not entity_indices: - entity_indices = _contour_entity_indices_from_segments(contour, stable_id_to_raw_index) - if not entity_indices: - continue - normalized_indices = [ - raw_to_converted_index[int(idx)] - for idx in entity_indices - if isinstance(idx, (int, float)) and int(idx) in raw_to_converted_index - ] - if not normalized_indices: - continue - bbox = _loop_bbox([ - converted_entities[idx] - for idx in normalized_indices - if 0 <= idx < len(converted_entities) - ]) - loops.append({ - "id": contour.get("contour_id"), - "entity_indices": normalized_indices, - "is_closed": contour.get("is_closed"), - "bbox_mm": bbox or contour.get("bbox_mm"), - "bbox_area_mm2": _bbox_area_2d(bbox) if bbox else contour.get("bbox_area_mm2"), - "source": "solidworks_sketch_contour", - }) - workplane = sketch_data.get("workplane") or {} - if not workplane: - workplane = {"name": sketch_data.get("plane"), "origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]} - return { - "id": sketch_id, - "name": feature.get("name", sketch_id), - "workplane": workplane, - "host_reference": sketch_data.get("host_reference"), - "entities": converted_entities, - "loops": loops, - "sketch_regions": sketch_data.get("sketch_regions", []), - "constraints": sketch_data.get("constraints", []), - "inferred_constraints": sketch_data.get("inferred_constraints", []), - "dimensions": sketch_data.get("dimensions", []), - "feature_dimensions": sketch_data.get("feature_dimensions", []), - "source_feature": _source_feature(feature, index), - } - - -def _selectable_stable_ids(value: Any) -> list[str]: - if not isinstance(value, dict): - return [] - candidates = [value.get("stable_id")] - identity = value.get("identity") - if isinstance(identity, dict): - candidates.append(identity.get("stable_id")) - return [str(candidate) for candidate in candidates if candidate] - - -def _contour_entity_indices_from_segments(contour: Dict[str, Any], stable_id_to_raw_index: dict[str, int]) -> list[int]: - indices: list[int] = [] - seen: set[int] = set() - for segment in contour.get("sketch_segments") or []: - for stable_id in _selectable_stable_ids(segment): - raw_index = stable_id_to_raw_index.get(stable_id) - if raw_index is None or raw_index in seen: - continue - seen.add(raw_index) - indices.append(raw_index) - break - return indices - - -def _convert_sw_sketch_entity(entity: Dict[str, Any]) -> Optional[Dict[str, Any]]: - entity_type = str(entity.get("canonical_entity_type") or entity.get("entity_type", "")).lower() - curve = entity.get("curve") if isinstance(entity.get("curve"), dict) else {} - if ( - entity_type == "circle_or_arc" - or curve.get("is_circle") is True - or entity.get("curve_entity_type") == "circle_or_arc" - ): - center = entity.get("curve_center_mm") or entity.get("center_mm") - radius_mm_value = entity.get("curve_radius_mm") or entity.get("radius_mm") - radius_raw_value = entity.get("radius") - start = entity.get("start_mm") - end = entity.get("end_mm") - start_2d = [float(start[0]), float(start[1])] if isinstance(start, list) and len(start) >= 2 else None - end_2d = [float(end[0]), float(end[1])] if isinstance(end, list) and len(end) >= 2 else None - center_2d = [float(center[0]), float(center[1])] if isinstance(center, list) and len(center) >= 2 else [0.0, 0.0] - radius_mm = float(radius_mm_value) if radius_mm_value is not None else _scale_length(radius_raw_value or 0) - if start_2d and end_2d and math.hypot(start_2d[0] - end_2d[0], start_2d[1] - end_2d[1]) > 1e-6: - # 计算 sweep 方向 - import math as _math - sa = _math.degrees(_math.atan2(start_2d[1] - center_2d[1], start_2d[0] - center_2d[0])) - ea = _math.degrees(_math.atan2(end_2d[1] - center_2d[1], end_2d[0] - center_2d[0])) - sweep = round(ea - sa, 10) - while sweep <= -180: - sweep += 360 - while sweep > 180: - sweep -= 360 - result = { - "type": "arc", - "center": center_2d, - "start": start_2d, - "end": end_2d, - "radius_mm": radius_mm, - "start_angle_deg": round(sa, 10), - "end_angle_deg": round(ea, 10), - "arc_sweep_deg": round(sweep, 10), - "construction": bool(entity.get("construction")), - "raw": entity, - } - curve_axis = entity.get("curve_axis") - if isinstance(curve_axis, list) and len(curve_axis) >= 3: - result["curve_axis"] = [float(v) for v in curve_axis[:3]] - return result - return { - "type": "circle", - "center": center_2d, - "radius_mm": radius_mm, - "construction": bool(entity.get("construction")), - "raw": entity, - } - if "line" in entity_type: - return { - "type": "line", - "start": _sketch_point_mm(entity, "start"), - "end": _sketch_point_mm(entity, "end"), - "construction": bool(entity.get("construction")), - "raw": entity, - } - if "circle" in entity_type: - return { - "type": "circle", - "center": _sketch_point_mm(entity, "center"), - "radius_mm": _sketch_radius_mm(entity), - "construction": bool(entity.get("construction")), - "raw": entity, - } - if "arc" in entity_type: - return { - "type": "arc", - "center": _sketch_point_mm(entity, "center"), - "start": _sketch_point_mm(entity, "start"), - "end": _sketch_point_mm(entity, "end"), - "radius_mm": _sketch_radius_mm(entity), - "start_angle_deg": _to_degrees(entity.get("start_angle", 0)), - "end_angle_deg": _to_degrees(entity.get("end_angle", 360)), - "construction": bool(entity.get("construction")), - "raw": entity, - } - if entity_type == "point": - point = entity.get("point_mm") or [float(entity.get("x", 0)) * 1000, float(entity.get("y", 0)) * 1000, 0] - return {"type": "point", "point": point[:2], "point_mm": point, "construction": bool(entity.get("construction")), "raw": entity} - return None - - -def _sketch_point_mm(entity: Dict[str, Any], key: str) -> list[float]: - point = entity.get(f"{key}_mm") - if isinstance(point, list) and len(point) >= 2: - return [float(point[0]), float(point[1])] - return _scale_point(entity.get(key, [0, 0])) - - -def _sketch_radius_mm(entity: Dict[str, Any]) -> float: - for key in ("radius_mm", "major_radius_mm", "major_radius"): - if entity.get(key) is not None: - return _scale_length(entity.get(key)) - if entity.get("radius") is not None: - return _scale_length(entity.get("radius")) - start = _sketch_point_mm(entity, "start") - center = _sketch_point_mm(entity, "center") - if start and center: - return math.hypot(float(start[0]) - float(center[0]), float(start[1]) - float(center[1])) - return 1.0 - - -def _convert_sw_extrude(feature: Dict[str, Any], type_name: str, sketch_id: Optional[str], index: int) -> Dict[str, Any]: - data_block = feature.get("extrude_data", {}) - op_type = "extrude_cut" if _is_cut_feature(feature, type_name) else "extrude_add" - distance = _best_extrude_depth_mm(feature, data_block) - reverse_end_condition_code = data_block.get("reverse_end_condition_code") - reverse_distance = abs(data_block.get("reverse_depth") or 0) - both_directions = bool(data_block.get("both_directions", False)) - reverse_direction = data_block.get("is_reverse") - if reverse_direction is None: - reverse_direction = data_block.get("definition_snapshot", {}).get("ReverseDirection") - if reverse_direction is None: - reverse_direction = feature.get("definition_snapshot", {}).get("values", {}).get("ReverseDirection", False) - if reverse_end_condition_code in (None, 0) and data_block.get("effective_depth_source") == "feature_dimension": - spans_both_sides = _extrude_owned_faces_span_sketch_plane(feature, data_block) - if spans_both_sides and bool(reverse_direction): - both_directions = True - reverse_distance = reverse_distance or distance - else: - both_directions = False - reverse_distance = 0 - raw_depth = abs(data_block.get("depth") or data_block.get("blind_depth") or 0) - uses_reverse_depth_only = ( - op_type == "extrude_cut" - and - feature.get("type") == "ice" - and raw_depth <= 1e-9 - and reverse_distance > 0 - ) - if uses_reverse_depth_only: - reverse_direction = not bool(reverse_direction) if False else bool(reverse_direction) - return { - "id": feature.get("id"), - "name": feature.get("name"), - "type": op_type, - "sketch": sketch_id, - "parameters": { - "distance_mm": distance, - "reverse": bool(reverse_direction), - "reverse_direction": bool(reverse_direction), - "reverse_distance_mm": reverse_distance, - "both_directions": False if uses_reverse_depth_only else both_directions, - "end_condition": data_block.get("end_condition"), - "end_condition_code": data_block.get("end_condition_code"), - "reverse_end_condition_code": reverse_end_condition_code, - "flip_side_to_cut": bool(data_block.get("flip_side_to_cut", False)), - "start_condition_reference": _clean_null_reference(data_block.get("start_condition_reference")), - "end_condition_reference": _clean_null_reference(data_block.get("end_condition_reference")), - "reverse_end_condition_reference": _clean_null_reference(data_block.get("reverse_end_condition_reference")), - "draft_angle_rad": data_block.get("draft_angle_rad"), - "reverse_draft_angle_rad": data_block.get("reverse_draft_angle_rad"), - }, - "source_feature": _source_feature(feature, index), - "source_owned_faces": _source_owned_faces(feature), - } - - -def _extrude_owned_faces_span_sketch_plane(feature: Dict[str, Any], data_block: Dict[str, Any]) -> bool: - sketches = data_block.get("source_sketches") or [] - workplane = sketches[0].get("workplane") if sketches and isinstance(sketches[0], dict) else None - if not isinstance(workplane, dict): - return bool(data_block.get("both_directions")) and (data_block.get("reverse_depth") not in (None, 0)) - - origin = workplane.get("origin_mm") or [0, 0, 0] - normal = workplane.get("normal") or [0, 0, 1] - if not isinstance(origin, list) or not isinstance(normal, list) or len(origin) < 3 or len(normal) < 3: - return False - - nx, ny, nz = (float(normal[0]), float(normal[1]), float(normal[2])) - length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0 - nx, ny, nz = nx / length, ny / length, nz / length - ox, oy, oz = float(origin[0]), float(origin[1]), float(origin[2]) - - min_distance = math.inf - max_distance = -math.inf - for face in feature.get("owned_faces") or []: - box = face.get("box_m") if isinstance(face, dict) else None - if not isinstance(box, list) or len(box) < 6: - continue - xs = [float(box[0]) * 1000, float(box[3]) * 1000] - ys = [float(box[1]) * 1000, float(box[4]) * 1000] - zs = [float(box[2]) * 1000, float(box[5]) * 1000] - for x in xs: - for y in ys: - for z in zs: - distance_to_plane = (x - ox) * nx + (y - oy) * ny + (z - oz) * nz - min_distance = min(min_distance, distance_to_plane) - max_distance = max(max_distance, distance_to_plane) - - if math.isinf(min_distance) or math.isinf(max_distance): - return False - tolerance = 1e-4 - return min_distance < -tolerance and max_distance > tolerance - - -def _convert_sw_revolve(feature: Dict[str, Any], type_name: str, sketch_id: Optional[str], index: int) -> Dict[str, Any]: - data_block = feature.get("revolve_data", {}) - op_type = "revolve_cut" if _is_cut_feature(feature, type_name) else "revolve_add" - selected_axis = _axis_reference_from_feature_selections(data_block.get("selections")) - owned_face_axis = _axis_reference_from_owned_faces(feature) - extracted_axis = _extract_axis_reference(data_block.get("axis_reference")) - axis_reference = selected_axis or owned_face_axis - if not axis_reference and not _is_weak_inferred_axis(extracted_axis): - axis_reference = extracted_axis - return { - "id": feature.get("id"), - "name": feature.get("name"), - "type": op_type, - "sketch": sketch_id, - "parameters": { - "angle_deg": abs(data_block.get("angle") or 360), - "angle_rad": data_block.get("angle_rad"), - "reverse": data_block.get("is_reverse", False), - "end_condition": data_block.get("end_condition"), - "end_condition_code": data_block.get("end_condition_code"), - "axis_reference": axis_reference, - "axis_candidates": data_block.get("axis_candidates", []), - }, - "source_feature": _source_feature(feature, index), - "source_owned_faces": _source_owned_faces(feature), - } - - -def _axis_reference_from_owned_faces(feature: Dict[str, Any]) -> Optional[Dict[str, Any]]: - candidates: list[tuple[float, Dict[str, Any]]] = [] - for face in feature.get("owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - params = None - if surface.get("is_cylinder") and isinstance(surface.get("cylinder_params"), list): - params = surface.get("cylinder_params") - elif surface.get("is_cone") and isinstance(surface.get("cone_params"), list): - params = surface.get("cone_params") - if not isinstance(params, list) or len(params) < 6: - continue - direction = [float(value) for value in params[3:6]] - norm = math.sqrt(sum(value * value for value in direction)) - if norm <= 1e-9: - continue - candidates.append(( - float(face.get("area_m2") or 0.0), - { - "origin_mm": [float(value) * 1000 for value in params[:3]], - "direction": [value / norm for value in direction], - "source": "owned_face_axis", - }, - )) - if not candidates: - return None - candidates.sort(key=lambda item: item[0], reverse=True) - return candidates[0][1] - - -def _is_weak_inferred_axis(axis_reference: Optional[Dict[str, Any]]) -> bool: - if not isinstance(axis_reference, dict): - return False - return str(axis_reference.get("source") or "") in {"construction_line_candidate", "construction_line"} - - -def _convert_sw_hole(feature: Dict[str, Any], index: int) -> Dict[str, Any]: - data_block = feature.get("hole_data", {}) - positions = [] - host_face = _host_face_from_feature_selections(data_block.get("selections")) or {} - position_sketches = _hole_position_sketches(data_block.get("position_sketches", []) or []) - for sketch in position_sketches: - workplane = sketch.get("workplane") or {} - if not host_face and workplane: - host_face = _host_face_from_workplane(workplane) - for point in _hole_position_points(sketch): - positions.append({"mm": [float(point[0]), float(point[1]), float(point[2] if len(point) > 2 else 0)]}) - diameter_mm = abs(data_block.get("diameter") or 0) or _hole_primary_diameter_mm(data_block) - depth_mm = abs(data_block.get("depth") or 0) or _hole_primary_depth_mm(data_block) - return { - "id": feature.get("id"), - "name": feature.get("name"), - "type": "hole", - "parameters": { - "diameter_mm": diameter_mm, - "depth_mm": depth_mm, - "counterbore_diameter_mm": _hole_counterbore_dimension_mm(data_block), - "counterbore_depth_mm": _hole_counterbore_depth_dimension_mm(data_block), - "countersink_diameter_mm": _hole_dimension_value(data_block, ("锥形沉头", "直径")) - or _hole_dimension_value(data_block, ("近端锥形沉头", "直径")) - or _hole_dimension_value(data_block, ("锥坑", "直径")) - or _hole_dimension_value(data_block, ("countersink", "diameter")) - or _hole_dimension_value(data_block, ("csk", "diameter")), - "angles_rad": { - "countersink_angle": _hole_angle_dimension_rad(data_block, ("锥形沉头", "角度")) - or _hole_angle_dimension_rad(data_block, ("近端锥形沉头", "角度")) - or _hole_angle_dimension_rad(data_block, ("锥坑", "角度")) - or _hole_angle_dimension_rad(data_block, ("countersink", "angle")) - or _hole_angle_dimension_rad(data_block, ("csk", "angle")), - "drill_angle": _hole_angle_dimension_rad(data_block, ("导头", "角度")) - or _hole_angle_dimension_rad(data_block, ("drill", "angle")) - or _hole_angle_dimension_rad(data_block, ("tip", "angle")), - }, - "positions": positions, - "host_face": host_face, - "hole_type": data_block.get("hole_type"), - "standard": data_block.get("standard"), - "size": data_block.get("size"), - "dimension_names": [ - str(dim.get("name") or "") - for dim in data_block.get("dimensions", []) or [] - if isinstance(dim, dict) - ], - }, - "source_feature": _source_feature(feature, index), - "source_owned_faces": _source_owned_faces(feature), - } - - -def _hole_position_sketches(sketches: list[Dict[str, Any]]) -> list[Dict[str, Any]]: - point_only = [] - for sketch in sketches: - entities = sketch.get("entities") or [] - if not entities: - continue - if _is_hole_profile_sketch(sketch): - continue - point_count = sum(1 for entity in entities if _is_sketch_point_entity(entity)) - drawable_segment_count = sum( - 1 - for entity in entities - if not _is_sketch_point_entity(entity) and not entity.get("construction") - ) - if point_count > 0 and drawable_segment_count == 0: - point_only.append(sketch) - return point_only or sketches[:1] - - -def _is_hole_profile_sketch(sketch: Dict[str, Any]) -> bool: - tokens = ( - "孔直径", - "孔深度", - "沉头", - "导头", - "螺纹孔钻头", - "tap drill", - "drill", - "counterbore", - "countersink", - "hole diameter", - "hole depth", - ) - dimension_sources = [] - dimension_sources.extend(sketch.get("dimensions") or []) - dimension_sources.extend(sketch.get("feature_dimensions") or []) - for dim in dimension_sources: - if not isinstance(dim, dict): - continue - name = str(dim.get("name") or "").lower() - if any(token in name for token in tokens): - return True - return False - - -def _is_sketch_point_entity(entity: Dict[str, Any]) -> bool: - entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() - return entity_type == "point" - - -def _hole_position_entity_flags(entity: Dict[str, Any]) -> tuple[Optional[bool], bool]: - raw = entity.get("raw") if isinstance(entity.get("raw"), dict) else entity - candidate = raw.get("hole_position_candidate") - if candidate is None: - candidate = entity.get("hole_position_candidate") - if isinstance(candidate, bool): - candidate_flag: Optional[bool] = candidate - else: - candidate_flag = None - construction_reference = bool( - raw.get("construction_endpoint_reference") or entity.get("construction_endpoint_reference") - ) - return candidate_flag, construction_reference - - -def _construction_endpoint_degrees(sketch: Dict[str, Any]) -> dict[tuple[float, float, float], int]: - degrees: dict[tuple[float, float, float], int] = {} - for entity in sketch.get("entities") or []: - if not entity.get("construction"): - continue - entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() - if "line" not in entity_type: - continue - for key in ("start_mm", "end_mm"): - endpoint = entity.get(key) - if isinstance(endpoint, list) and len(endpoint) >= 2: - point_key = _rounded_point_key(endpoint) - degrees[point_key] = degrees.get(point_key, 0) + 1 - return degrees - - -def _hole_position_points(sketch: Dict[str, Any]) -> list[list[float]]: - """Return only real Hole Wizard placement points from a position sketch. - - SolidWorks Hole Wizard position sketches often include construction - segments whose endpoints are reference geometry, not hole centers. Older - parser JSON exposes those endpoints as ordinary sketch points, so we filter - them generically here instead of letting every point become a hole. - """ - entities = sketch.get("entities") or [] - point_entities: list[tuple[list[float], Optional[bool], bool]] = [] - construction_endpoints: set[tuple[float, float, float]] = set() - - for entity in entities: - point = entity.get("point_mm") - if _is_sketch_point_entity(entity) and isinstance(point, list) and len(point) >= 2: - candidate_flag, construction_reference = _hole_position_entity_flags(entity) - point_entities.append( - ( - [float(point[0]), float(point[1]), float(point[2] if len(point) > 2 else 0)], - candidate_flag, - construction_reference, - ) - ) - continue - if not entity.get("construction"): - continue - entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() - if "line" not in entity_type: - continue - for key in ("start_mm", "end_mm"): - endpoint = entity.get(key) - if isinstance(endpoint, list) and len(endpoint) >= 2: - construction_endpoints.add(_rounded_point_key(endpoint)) - - if not point_entities: - return [] - - explicit_candidates = [ - point for point, candidate_flag, _ in point_entities if candidate_flag is True - ] - if explicit_candidates: - return _dedupe_points(explicit_candidates) - - endpoint_degrees = _construction_endpoint_degrees(sketch) - if endpoint_degrees: - filtered = [] - for point, candidate_flag, construction_reference in point_entities: - point_key = _rounded_point_key(point) - degree = endpoint_degrees.get(point_key, 0) - if candidate_flag is False and construction_reference and degree <= 1: - continue - if degree >= 2 or not construction_reference: - filtered.append(point) - filtered = _dedupe_points(filtered) - non_origin_filtered = [point for point in filtered if not _is_near_origin(point)] - if non_origin_filtered: - return _dedupe_points(non_origin_filtered) - if filtered: - return filtered - - raw_points = _dedupe_points([point for point, _, _ in point_entities]) - if not raw_points or not construction_endpoints: - return raw_points - - legacy_filtered = [point for point in raw_points if _rounded_point_key(point) not in construction_endpoints] - non_origin_raw = [point for point in raw_points if not _is_near_origin(point)] - non_origin_filtered = [point for point in legacy_filtered if not _is_near_origin(point)] - if non_origin_filtered: - return _dedupe_points(non_origin_filtered) - if non_origin_raw: - return _dedupe_points(non_origin_raw) - return _dedupe_points(legacy_filtered or raw_points) - - -def _dedupe_points(points: list[list[float]]) -> list[list[float]]: - result = [] - seen = set() - for point in points: - key = _rounded_point_key(point) - if key in seen: - continue - seen.add(key) - result.append(point) - return result - - -def _is_near_origin(point: list[float], tolerance: float = 1e-6) -> bool: - return math.sqrt(sum(float(component) * float(component) for component in point[:3])) <= tolerance - - -def _rounded_point_key(point: list[Any], digits: int = 5) -> tuple[float, float, float]: - z = point[2] if len(point) > 2 else 0 - return (round(float(point[0]), digits), round(float(point[1]), digits), round(float(z), digits)) - - -def _convert_sw_linear_pattern( - feature: Dict[str, Any], - index: int, - previous_build_op: Optional[Dict[str, Any]], - source_frame: Optional[Dict[str, Any]] = None, - sketches: Optional[list[Dict[str, Any]]] = None, - source_bbox: Optional[list[float]] = None, -) -> Dict[str, Any]: - data_block = feature.get("linear_pattern_data", {}) - source_features = data_block.get("source_features") or [] - if not source_features and previous_build_op: - source_features = [previous_build_op.get("source_feature", {})] - spacing_1 = data_block.get("spacing_1") - spacing_2 = data_block.get("spacing_2") - direction_1 = _pattern_direction_from_plugin(data_block.get("direction_1"), axis="x", source_frame=source_frame) - direction_2 = _pattern_direction_from_plugin(data_block.get("direction_2"), axis="y", source_frame=source_frame) - direction_1 = _pattern_direction_from_reference(direction_1, data_block.get("direction_1_reference"), source_frame) - direction_2 = _pattern_direction_from_reference(direction_2, data_block.get("direction_2_reference"), source_frame) - if data_block.get("direction_1_reverse") is True: - direction_1 = _reverse_pattern_direction(direction_1) - if data_block.get("direction_2_reverse") is True: - direction_2 = _reverse_pattern_direction(direction_2) - source_op_bbox = _operation_profile_bbox(previous_build_op, sketches or []) - if data_block.get("direction_1") is None: - direction_1 = _choose_pattern_direction_sign( - direction_1, - spacing_1 or 0, - int(data_block.get("pattern_count_1") or 1), - source_op_bbox, - source_bbox, - ) - if data_block.get("direction_2") is None: - direction_2 = _choose_pattern_direction_sign( - direction_2, - spacing_2 or 0, - int(data_block.get("pattern_count_2") or 1), - source_op_bbox, - source_bbox, - ) - explicit_offsets = _owned_face_pattern_offsets(previous_build_op, feature) - return { - "id": feature.get("id"), - "name": feature.get("name"), - "type": "linear_pattern", - "parameters": { - "source_features": source_features, - "total_instances": data_block.get("pattern_count_1") or 1, - "spacing_mm": spacing_1 or 0, - "direction1": direction_1, - "direction2": direction_2, - }, - "raw_parameters": { - "d1_total_instances": data_block.get("pattern_count_1") or 1, - "d2_total_instances": data_block.get("pattern_count_2") or 1, - "d1_spacing_mm": spacing_1 or 0, - "d2_spacing_mm": spacing_2 or 0, - "direction1": direction_1, - "direction2": direction_2, - "explicit_offsets_mm": explicit_offsets, - }, - "source_feature": _source_feature(feature, index), - "source_owned_faces": _source_owned_faces(feature), - } - - -def _owned_face_pattern_offsets( - source_op: Optional[Dict[str, Any]], - pattern_feature: Dict[str, Any], -) -> list[list[float]]: - if not source_op: - return [] - source_faces = _owned_face_signatures(source_op.get("source_owned_faces") or []) - pattern_faces = _owned_face_signatures(_source_owned_faces(pattern_feature)) - if not source_faces or not pattern_faces: - return [] - - votes: Dict[tuple[float, float, float], int] = {} - for pattern_face in pattern_faces: - for source_face in source_faces: - if pattern_face["kind"] != source_face["kind"]: - continue - if not _similar_bbox_size(pattern_face["size"], source_face["size"]): - continue - offset = tuple( - round(pattern_face["center"][axis] - source_face["center"][axis], 3) - for axis in range(3) - ) - if math.sqrt(sum(component * component for component in offset)) < 1e-6: - continue - votes[offset] = votes.get(offset, 0) + 1 - - if not votes: - return [] - threshold = max(1, min(2, len(source_faces))) - offsets = [offset for offset, count in votes.items() if count >= threshold] - offsets.sort(key=lambda offset: (offset[0] * offset[0] + offset[1] * offset[1] + offset[2] * offset[2], offset)) - return [[float(value) for value in offset] for offset in offsets] - - -def _owned_face_signatures(faces: list[Dict[str, Any]]) -> list[Dict[str, Any]]: - signatures = [] - for face in faces: - if not isinstance(face, dict): - continue - box = face.get("box_m") - if not isinstance(box, list) or len(box) < 6: - continue - box_mm = [float(value) * 1000 for value in box[:6]] - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - kind = "other" - if surface.get("is_cylinder"): - kind = "cylinder" - elif surface.get("is_cone"): - kind = "cone" - elif surface.get("is_plane"): - kind = "plane" - signatures.append( - { - "kind": kind, - "center": [(box_mm[i] + box_mm[i + 3]) / 2 for i in range(3)], - "size": [abs(box_mm[i + 3] - box_mm[i]) for i in range(3)], - } - ) - return signatures - - -def _similar_bbox_size(a: list[float], b: list[float], tolerance: float = 0.05) -> bool: - return all(abs(float(a[i]) - float(b[i])) <= tolerance for i in range(3)) - - -def _source_pattern_frame( - previous_build_op: Optional[Dict[str, Any]], - sketches: list[Dict[str, Any]], -) -> Optional[Dict[str, Any]]: - if not previous_build_op: - return None - params = previous_build_op.get("parameters") or {} - host_frame = ((params.get("host_face") or {}).get("frame") or {}) - if host_frame.get("x_dir") and host_frame.get("y_dir"): - return host_frame - sketch_id = previous_build_op.get("sketch") - for sketch in sketches: - if sketch.get("id") == sketch_id: - workplane = sketch.get("workplane") or {} - if workplane.get("x_dir") and workplane.get("y_dir"): - return workplane - return None - - -def _source_bbox_from_plugin_json(data: Dict[str, Any]) -> Optional[list[float]]: - bbox = (data.get("validation_hints") or {}).get("part_box_m") - if isinstance(bbox, list) and len(bbox) >= 6: - return [float(v) * 1000 for v in bbox[:6]] - return None - - -def _operation_profile_bbox( - op: Optional[Dict[str, Any]], - sketches: list[Dict[str, Any]], -) -> Optional[list[float]]: - if not op: - return None - if op.get("type") == "hole": - host_face = (op.get("parameters") or {}).get("host_face") or {} - positions = [ - _hole_position_to_model(pos.get("mm"), host_face) - for pos in (op.get("parameters") or {}).get("positions", []) - if isinstance(pos.get("mm"), list) and len(pos.get("mm")) >= 3 - ] - if positions: - return _points_bbox(positions) - sketch_id = op.get("sketch") - sketch = next((item for item in sketches if item.get("id") == sketch_id), None) - if not sketch: - return None - points = [] - workplane = sketch.get("workplane") or {} - origin = workplane.get("origin_mm") or [0, 0, 0] - x_dir = workplane.get("x_dir") or [1, 0, 0] - y_dir = workplane.get("y_dir") or [0, 1, 0] - for entity in sketch.get("entities", []) or []: - if entity.get("type") == "circle": - center = entity.get("center") or [0, 0] - radius = float(entity.get("radius_mm") or 0) - for dx, dy in ((-radius, -radius), (-radius, radius), (radius, -radius), (radius, radius)): - points.append(_sketch_point_to_model_bbox(origin, x_dir, y_dir, [float(center[0]) + dx, float(center[1]) + dy])) - for key in ("start", "end", "center", "point"): - point = entity.get(key) - if isinstance(point, list) and len(point) >= 2: - points.append(_sketch_point_to_model_bbox(origin, x_dir, y_dir, point)) - return _points_bbox(points) - - -def _sketch_point_to_model_bbox(origin: list[Any], x_dir: list[Any], y_dir: list[Any], point: list[Any]) -> list[float]: - return [ - float(origin[i]) + float(x_dir[i]) * float(point[0]) + float(y_dir[i]) * float(point[1]) - for i in range(3) - ] - - -def _points_bbox(points: list[list[float]]) -> Optional[list[float]]: - if not points: - return None - return [ - min(point[0] for point in points), - min(point[1] for point in points), - min(point[2] for point in points), - max(point[0] for point in points), - max(point[1] for point in points), - max(point[2] for point in points), - ] - - -def _hole_position_to_model(point: list[Any], host_face: Dict[str, Any]) -> list[float]: - frame = host_face.get("frame") if isinstance(host_face, dict) else {} - if not isinstance(frame, dict): - return [float(v) for v in (point + [0, 0, 0])[:3]] - origin = frame.get("origin_mm") or [0, 0, 0] - x_dir = frame.get("x_dir") or [1, 0, 0] - y_dir = frame.get("y_dir") or [0, 1, 0] - values = [float(v) for v in (point + [0, 0, 0])[:3]] - return [ - float(origin[i]) + float(x_dir[i]) * values[0] + float(y_dir[i]) * values[1] - for i in range(3) - ] - - -def _choose_pattern_direction_sign( - direction: Dict[str, Any], - spacing: float, - count: int, - source_op_bbox: Optional[list[float]], - source_bbox: Optional[list[float]], -) -> Dict[str, Any]: - vector = direction.get("vector") - if ( - not isinstance(vector, list) - or len(vector) < 3 - or not spacing - or count <= 1 - or not source_op_bbox - or not source_bbox - ): - return direction - unit = _unit3(vector) - distance = float(spacing) * (count - 1) - positive = [component * distance for component in unit] - negative = [-component * distance for component in unit] - positive_score = _bbox_overflow_score(_translated_bbox(source_op_bbox, positive), source_bbox) - negative_score = _bbox_overflow_score(_translated_bbox(source_op_bbox, negative), source_bbox) - if abs(positive_score - negative_score) <= 1e-9: - positive_score += _bbox_center_distance_score(_translated_bbox(source_op_bbox, positive), source_bbox) - negative_score += _bbox_center_distance_score(_translated_bbox(source_op_bbox, negative), source_bbox) - copied = dict(direction) - if negative_score + 1e-9 < positive_score: - copied["vector"] = [-component for component in unit] - copied["source"] = f"{direction.get('source', 'missing_direction')}_sign_from_source_bbox" - return copied - copied["vector"] = unit - if positive_score + 1e-9 < negative_score: - copied["source"] = f"{direction.get('source', 'missing_direction')}_sign_from_source_bbox" - return copied - - -def _unit3(vector: list[Any]) -> list[float]: - raw = [float(vector[i]) for i in range(3)] - length = math.sqrt(sum(v * v for v in raw)) - if length <= 0: - return [0.0, 0.0, 0.0] - return [v / length for v in raw] - - -def _translated_bbox(bbox: list[float], offset: list[float]) -> list[float]: - return [ - bbox[0] + offset[0], - bbox[1] + offset[1], - bbox[2] + offset[2], - bbox[3] + offset[0], - bbox[4] + offset[1], - bbox[5] + offset[2], - ] - - -def _bbox_overflow_score(candidate: list[float], source: list[float]) -> float: - score = 0.0 - for axis in range(3): - score += max(source[axis] - candidate[axis], 0) - score += max(candidate[axis + 3] - source[axis + 3], 0) - return score - - -def _bbox_center_distance_score(candidate: list[float], source: list[float]) -> float: - score = 0.0 - for axis in range(3): - source_center = (source[axis] + source[axis + 3]) / 2 - candidate_center = (candidate[axis] + candidate[axis + 3]) / 2 - axis_size = max(source[axis + 3] - source[axis], 1.0) - score += abs(candidate_center - source_center) / axis_size - return score - - -def _is_cut_feature(feature: Dict[str, Any], type_name: str) -> bool: - text = f"{type_name} {feature.get('name', '')}".lower() - return "cut" in text or "切除" in text or "revcut" in text - - -def _best_extrude_depth_mm(feature: Dict[str, Any], data_block: Dict[str, Any]) -> float: - for key in ("depth", "blind_depth"): - value = data_block.get(key) - if value: - return abs(float(value)) - owned_face_depth = _extrude_depth_from_owned_faces(feature, data_block) - effective_depth = abs(float(data_block.get("effective_depth") or 0)) - if ( - owned_face_depth - and _is_cut_feature(feature, str(feature.get("type_name") or feature.get("type") or "")) - and data_block.get("effective_depth_source") == "feature_dimension" - and not data_block.get("depth") - and not data_block.get("blind_depth") - and not data_block.get("reverse_depth") - and effective_depth > owned_face_depth * 2 - ): - return owned_face_depth - owner_name = feature.get("name") - for dim in data_block.get("dimensions", []) or []: - name = dim.get("name") or "" - if owner_name and f"@{owner_name}@" in name and dim.get("value") not in (None, 0): - return abs(float(dim.get("value"))) - for dim in data_block.get("dimensions", []) or []: - if dim.get("owner") == owner_name and dim.get("value") not in (None, 0): - return abs(float(dim.get("value"))) - if data_block.get("reverse_depth") not in (None, 0): - return abs(float(data_block.get("reverse_depth"))) - if data_block.get("effective_depth") not in (None, 0): - return abs(float(data_block.get("effective_depth"))) - return 0.0 - - -def _extrude_depth_from_owned_faces(feature: Dict[str, Any], data_block: Dict[str, Any]) -> Optional[float]: - sketches = data_block.get("source_sketches") or [] - workplane = sketches[0].get("workplane") if sketches and isinstance(sketches[0], dict) else None - if not isinstance(workplane, dict): - return None - normal = workplane.get("normal") or [0, 0, 1] - if not isinstance(normal, list) or len(normal) < 3: - return None - axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) - values: list[float] = [] - for face in feature.get("owned_faces") or []: - if not isinstance(face, dict): - continue - box = face.get("box_m") - if isinstance(box, list) and len(box) >= 6: - values.extend([float(box[axis]) * 1000, float(box[axis + 3]) * 1000]) - if not values: - return None - extent = max(values) - min(values) - return abs(extent) if extent > 1e-6 else None - - -def _host_face_from_workplane(workplane: Dict[str, Any]) -> Dict[str, Any]: - origin = workplane.get("origin_mm") or [0, 0, 0] - normal = workplane.get("normal") or [0, 0, 1] - x_dir = workplane.get("x_dir") or [1, 0, 0] - y_dir = workplane.get("y_dir") or [0, 1, 0] - return { - "surface": {"plane_params": [*normal[:3], *(float(v) / 1000 for v in origin[:3])]}, - "frame": {"origin_mm": origin[:3], "x_dir": x_dir[:3], "y_dir": y_dir[:3], "normal": normal[:3]}, - } - - -def _pattern_direction_from_plugin( - direction: Any, - axis: str, - source_frame: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - if isinstance(direction, dict): - return direction - if source_frame: - key = "y_dir" if axis == "y" else "x_dir" - vector = source_frame.get(key) - if isinstance(vector, list) and len(vector) >= 3: - return {"vector": vector[:3], "source": f"source_feature_frame_{key}"} - if axis == "y": - return {"vector": [0, 1, 0], "source": "default_y_when_plugin_direction_missing"} - return {"vector": [1, 0, 0], "source": "default_x_when_plugin_direction_missing"} - - -def _pattern_direction_from_reference( - fallback: Dict[str, Any], - reference: Any, - source_frame: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - axis = _extract_axis_reference(reference) - if not axis: - return fallback - vector = axis.get("direction") - if not isinstance(vector, list) or len(vector) < 3: - return fallback - model_vector = _sketch_vector_to_model(vector[:3], source_frame) or vector[:3] - model_origin = _sketch_point_to_model(axis.get("origin_mm"), source_frame) or axis.get("origin_mm") - return { - "vector": _unit3(model_vector), - "origin_mm": model_origin, - "source": axis.get("source") or "direction_reference", - } - - -def _sketch_vector_to_model( - vector: list[Any], - source_frame: Optional[Dict[str, Any]], -) -> Optional[list[float]]: - if not source_frame: - return None - x_dir = source_frame.get("x_dir") - y_dir = source_frame.get("y_dir") - normal = source_frame.get("normal") - if not ( - isinstance(x_dir, list) - and len(x_dir) >= 3 - and isinstance(y_dir, list) - and len(y_dir) >= 3 - ): - return None - if not (isinstance(normal, list) and len(normal) >= 3): - normal = [ - float(x_dir[1]) * float(y_dir[2]) - float(x_dir[2]) * float(y_dir[1]), - float(x_dir[2]) * float(y_dir[0]) - float(x_dir[0]) * float(y_dir[2]), - float(x_dir[0]) * float(y_dir[1]) - float(x_dir[1]) * float(y_dir[0]), - ] - values = [float(v) for v in (vector + [0, 0, 0])[:3]] - return [ - values[0] * float(x_dir[i]) + values[1] * float(y_dir[i]) + values[2] * float(normal[i]) - for i in range(3) - ] - - -def _sketch_point_to_model( - point: Any, - source_frame: Optional[Dict[str, Any]], -) -> Optional[list[float]]: - if not isinstance(point, list) or len(point) < 3 or not source_frame: - return None - origin = source_frame.get("origin_mm") - vector = _sketch_vector_to_model(point[:3], source_frame) - if not (isinstance(origin, list) and len(origin) >= 3 and vector): - return None - return [float(origin[i]) + vector[i] for i in range(3)] - - -def _reverse_pattern_direction(direction: Dict[str, Any]) -> Dict[str, Any]: - vector = direction.get("vector") - if not isinstance(vector, list) or len(vector) < 3: - return direction - copied = dict(direction) - copied["vector"] = [-float(vector[0]), -float(vector[1]), -float(vector[2])] - copied["source"] = f"{direction.get('source', 'direction')}_reversed" - return copied - - -def _clean_null_reference(reference: Any) -> Optional[Dict[str, Any]]: - if not isinstance(reference, dict): - return None - if reference.get("kind") == "null": - return None - obj = reference.get("object") - if isinstance(obj, dict) and obj.get("kind") == "null": - return None - return reference - - -def _extract_axis_reference(reference: Any) -> Optional[Dict[str, Any]]: - if not isinstance(reference, dict): - return None - if reference.get("origin_mm") and reference.get("direction"): - return { - "origin_mm": [float(v) for v in reference.get("origin_mm", [])[:3]], - "direction": [float(v) for v in reference.get("direction", [])[:3]], - "source": reference.get("source") or "axis_reference", - } - obj = reference.get("object") if isinstance(reference.get("object"), dict) else reference - if obj.get("kind") == "null": - return None - - line_params = obj.get("line_params") - if isinstance(line_params, list) and len(line_params) >= 6: - return { - "origin_mm": [float(v) * 1000 for v in line_params[:3]], - "direction": [float(v) for v in line_params[3:6]], - "source": reference.get("source") or "selection_line_params", - } - - curve = obj.get("curve") if isinstance(obj.get("curve"), dict) else {} - curve_line_params = curve.get("line_params") - if isinstance(curve_line_params, list) and len(curve_line_params) >= 6: - return { - "origin_mm": [float(v) * 1000 for v in curve_line_params[:3]], - "direction": [float(v) for v in curve_line_params[3:6]], - "source": reference.get("source") or "selection_curve_line_params", - } - return None - - -def _selection_objects(selections: Any) -> list[Dict[str, Any]]: - objects: list[Dict[str, Any]] = [] - if not isinstance(selections, list): - return objects - for selection in selections: - if not isinstance(selection, dict): - continue - obj = selection.get("object") - if isinstance(obj, dict) and obj.get("kind") != "null": - objects.append(obj) - return objects - - -def _axis_reference_from_feature_selections(selections: Any) -> Optional[Dict[str, Any]]: - for obj in _selection_objects(selections): - axis = _extract_axis_reference(obj) - if axis: - axis["source"] = "feature_selection_axis" - return axis - return None - - -def _host_face_from_feature_selections(selections: Any) -> Optional[Dict[str, Any]]: - for obj in _selection_objects(selections): - if obj.get("kind") != "face": - continue - surface = obj.get("surface") if isinstance(obj.get("surface"), dict) else {} - frame = obj.get("frame") if isinstance(obj.get("frame"), dict) else {} - if not frame: - continue - normal = frame.get("normal") or (surface.get("plane_params") or [0, 0, 1])[:3] - origin = frame.get("origin_mm") - if not origin: - plane_params = surface.get("plane_params") - if isinstance(plane_params, list) and len(plane_params) >= 6: - origin = [float(v) * 1000 for v in plane_params[3:6]] - if not origin: - origin = [0, 0, 0] - x_dir = frame.get("x_dir") or [1, 0, 0] - y_dir = frame.get("y_dir") or [0, 1, 0] - origin_values = list(origin) - x_values = list(x_dir) - y_values = list(y_dir) - normal_values = list(normal) - return { - "surface": surface, - "frame": { - "origin_mm": [float(v) for v in (origin_values + [0, 0, 0])[:3]], - "x_dir": [float(v) for v in (x_values + [0, 0, 0])[:3]], - "y_dir": [float(v) for v in (y_values + [0, 0, 0])[:3]], - "normal": [float(v) for v in (normal_values + [0, 0, 1])[:3]], - }, - "source": "feature_selection_face", - } - return None - - -def _tuple3(values: Any) -> tuple[float, float, float]: - values = list(values or [0, 0, 0]) - values = (values + [0, 0, 0])[:3] - return tuple(values) - - -def _point_m_to_mm(point: Any) -> tuple[float, float, float]: - values = list(point or [0, 0, 0]) - values = (values + [0, 0, 0])[:3] - return tuple(float(value) * 1000 for value in values) - - -def _scale_point(point: Any) -> list[float]: - values = [0 if value is None else float(value) for value in (point or [0, 0])] - return [_scale_length(value) for value in values[:2]] - - -def _scale_length(value: Any) -> float: - value = 0 if value is None else float(value) - return value * 1000 if abs(value) <= 10 else value - - -def _to_degrees(value: Any) -> float: - value = 0 if value is None else float(value) - return value * 180 / 3.141592653589793 if abs(value) <= 6.283185307179586 else value diff --git a/backend/engine/cdsl_engine/translator/__init__.py b/backend/engine/cdsl_engine/translator/__init__.py new file mode 100644 index 00000000..8387e37c --- /dev/null +++ b/backend/engine/cdsl_engine/translator/__init__.py @@ -0,0 +1,164 @@ +"""Compatibility package for the historical ``cdsl_engine.translator`` module. + +The former single-file translator now lives in ``ir`` (SolidWorks plugin JSON +to backend IR), ``codegen`` (backend IR to build123d source), ``runtime_lib`` +(frozen generated-script library), and ``common`` (shared helpers). Every +historical import path and symbol keeps working. +""" + +from __future__ import annotations + +from .common import SW_END_CONDITIONS, THROUGH_CUT_AMOUNT_MM +from .ir import ( + _SW_METADATA_FEATURE_TYPES, + _append_feature_source_sketches, + _axis_reference_from_feature_selections, + _axis_reference_from_owned_faces, + _best_extrude_depth_mm, + _choose_pattern_direction_sign, + _clean_null_reference, + _construction_endpoint_degrees, + _contour_entity_indices_from_segments, + _convert_sw_assembly, + _convert_sw_extrude, + _convert_sw_hole, + _convert_sw_imported_body, + _convert_sw_linear_pattern, + _convert_sw_reference, + _convert_sw_revolve, + _convert_sw_sketch, + _convert_sw_sketch_entity, + _editable_param, + _extract_axis_reference, + _extract_edge_selector_points, + _extract_sketch_parameters, + _extrude_depth_from_owned_faces, + _extrude_owned_faces_span_sketch_plane, + _feature_length_dimension_mm, + _feature_selection_selectors, + _feature_selection_source, + _hole_angle_dimension_rad, + _hole_counterbore_depth_dimension_mm, + _hole_counterbore_dimension_mm, + _hole_dimension_value, + _hole_dimension_value_excluding, + _hole_position_entity_flags, + _hole_position_points, + _hole_position_sketches, + _hole_position_to_model, + _hole_primary_depth_mm, + _hole_primary_diameter_mm, + _hole_primary_dimension_fallback, + _host_face_from_feature_selections, + _host_face_from_workplane, + _is_cut_feature, + _is_hole_profile_sketch, + _is_imported_body_feature, + _is_sketch_point_entity, + _is_weak_inferred_axis, + _operation_profile_bbox, + _owned_face_pattern_offsets, + _owned_face_signatures, + _pattern_direction_from_plugin, + _pattern_direction_from_reference, + _reverse_pattern_direction, + _selectable_stable_ids, + _selection_objects, + _selector_has_persistent_reference, + _sketch_bounds, + _sketch_point_mm, + _sketch_point_to_model, + _sketch_point_to_model_bbox, + _sketch_radius_mm, + _sketch_vector_to_model, + _source_bbox_from_plugin_json, + _source_feature, + _source_owned_faces, + _source_pattern_frame, + analyze_parameterization_status, + convert_sw_plugin_json_to_ir, + enrich_rebuild_parameters, + extract_editable_parameters, + normalize_to_ir, +) +from .codegen import ( + _active_profile_loops, + _aligned_workplane_for_owned_midplane, + _blind_extrude_face_offset, + _effective_extrude_cut_depth_mm, + _effective_hole_cut_depth_mm, + _extract_mirror_plane_normal, + _extract_mirror_plane_origin, + _find_operation_for_source_feature, + _find_source_operation_for_pattern, + _flip_side_step_inner_radius_mm, + _flip_side_uses_step_ring, + _generate_assembly_compose, + _generate_chamfer, + _generate_extrude, + _generate_fillet, + _generate_hole, + _generate_imported_body_pending, + _generate_linear_pattern, + _generate_mirror_pattern, + _generate_move_face, + _generate_revolve, + _generate_sketch, + _hole_counterbore_depth_mm, + _hole_counterbore_diameter_mm, + _hole_countersink_angle_rad, + _hole_countersink_diameter_mm, + _hole_depth_mm, + _hole_diameter_mm, + _hole_drill_angle_rad, + _hole_has_drill_tip, + _hole_has_through_dimension, + _hole_owned_cut_faces, + _hole_should_use_sw_cut_holes, + _infer_closed_wire_loops, + _linear_pattern_offsets, + _looks_like_reverse_history, + _loop_area_from_radii, + _loop_radius_candidates, + _loops_matching_owned_radii, + _model_offset_to_host_local, + _operation_priority, + _ordered_wire_entities, + _owned_bbox_cut, + _owned_cylindrical_cut_faces, + _owned_extrude_terminal_offsets_mm, + _owned_profile_radii_mm, + _pattern_direction_vector, + _prefer_blind_sketch_extrude, + _project_owned_faces_to_sketch_bbox, + _resolve_extrude_owned_termination, + _reverse_curve_entity, + _revolve_axis_expr, + _sketch_circle_radii_mm, + _sketch_construction_axis, + _sketch_has_buildable_profile, + _sketch_point_to_model_from_basis, + _sw_math_transform_matrix, + _translate_owned_faces, + _translate_sketch_entities, + _translated_operation, + _translated_sketch, + generate_build123d_code, + get_part_name, + sort_operations_for_history, +) +from .runtime_lib import RUNTIME_LIB_LINES + +__all__ = [ + "RUNTIME_LIB_LINES", + "SW_END_CONDITIONS", + "THROUGH_CUT_AMOUNT_MM", + "analyze_parameterization_status", + "convert_sw_plugin_json_to_ir", + "enrich_rebuild_parameters", + "extract_editable_parameters", + "generate_build123d_code", + "get_part_name", + "normalize_to_ir", + "sort_operations_for_history", +] diff --git a/backend/engine/cdsl_engine/translator/codegen.py b/backend/engine/cdsl_engine/translator/codegen.py new file mode 100644 index 00000000..9ba37f84 --- /dev/null +++ b/backend/engine/cdsl_engine/translator/codegen.py @@ -0,0 +1,2016 @@ +"""Backend-IR to build123d source-code generation.""" + +from __future__ import annotations + +import json +import math +import os +import re +from copy import deepcopy +from typing import Any, Dict, Optional + +from .common import ( + _tuple3, + _point_key, + _bbox_area_2d, + _bbox_contains_2d, + _bbox_overlap_ratio_2d, + _loop_bbox, + SW_END_CONDITIONS, + THROUGH_CUT_AMOUNT_MM, +) +from .runtime_lib import RUNTIME_LIB_LINES + + +def get_part_name(data: Dict[str, Any]) -> str: + part_name = data.get("part_name") or data.get("metadata", {}).get("source", {}).get("file_name", "part") + part_name = str(part_name) + for suffix in (".sldprt", ".sldasm", ".step", ".stp", ".json"): + if part_name.lower().endswith(suffix): + part_name = part_name[:-len(suffix)] + break + return re.sub(r"[^0-9A-Za-z_\u4e00-\u9fff]+", "_", part_name).strip("_") or "part" + +def generate_build123d_code(data: Dict[str, Any], gold_volume_mm3: float | None = None) -> str: + """Generate build123d Python code from generic SW/build123d IR.""" + rebuild_contract = data.get("rebuild_contract") if isinstance(data.get("rebuild_contract"), dict) else {} + if rebuild_contract and rebuild_contract.get("ready") is False: + blockers = rebuild_contract.get("blockers") or [] + raise ValueError(f"Pure-JSON rebuild contract is not ready: {blockers}") + source_volume_mm3 = None + source_area_mm2 = None + mass_props = data.get("validation_hints", {}).get("mass_properties_raw") + if mass_props and len(mass_props) >= 5: + source_volume_mm3 = float(mass_props[3]) * 1_000_000_000 + source_area_mm2 = float(mass_props[4]) * 1_000_000 + lines = [ + "from build123d import *", + "import math", + f"SOURCE_VOLUME_MM3 = {source_volume_mm3!r}", + f"SOURCE_AREA_MM2 = {source_area_mm2!r}", + *RUNTIME_LIB_LINES, + ] + + part_name_clean = get_part_name(data) + lines.append(f"def build_{part_name_clean}():") + lines.append(' """Auto-generated build123d code from SolidWorks IR."""') + lines.append("") + + sketches = {s["id"]: s for s in data.get("sketches", [])} + operations = data.get("operations", []) + references = {r["id"]: r for r in data.get("references", [])} + generated_sketches = set() + + lines.append(" result = None") + lines.append("") + + for op in sort_operations_for_history(operations): + op_type = op.get("type", "") + op_name = op.get("name", "") + if op_type in ["unsupported", "unknown"]: + lines.append(f" # Skipping unsupported metadata feature: {op_name}") + lines.append("") + continue + + if op_type == "imported_body": + lines.extend(_generate_imported_body_pending(op)) + elif op_type == "assembly_compose": + lines.extend(_generate_assembly_compose(op)) + elif op_type == "move_face": + lines.extend(_generate_move_face(op)) + elif op_type == "fillet": + lines.extend(_generate_fillet(op)) + elif op_type == "chamfer": + lines.extend(_generate_chamfer(op)) + elif op_type == "hole": + lines.extend(_generate_hole(op)) + elif op_type in ("extrude_cut", "extrude_add"): + build_op = _resolve_extrude_owned_termination(op, sketches.get(op.get("sketch") or "")) + sketch_id = op.get("sketch") + if sketch_id and sketch_id in sketches and not _sketch_has_buildable_profile(sketches[sketch_id]): + lines.append(f" # Skip: sketch has no buildable closed/profile geometry for {op_name}") + continue + if sketch_id and sketch_id in sketches and sketch_id not in generated_sketches: + lines.extend(_generate_sketch(sketches[sketch_id], references, build_op)) + generated_sketches.add(sketch_id) + lines.extend(_generate_extrude(build_op, sketches.get(sketch_id, {}), operations, sketches)) + elif op_type in ("revolve_cut", "revolve_add"): + sketch_id = op.get("sketch") + if sketch_id and sketch_id in sketches and not _sketch_has_buildable_profile(sketches[sketch_id]): + lines.append(f" # Skip: sketch has no buildable closed/profile geometry for {op_name}") + continue + if sketch_id and sketch_id in sketches and sketch_id not in generated_sketches: + lines.extend(_generate_sketch(sketches[sketch_id], references, op)) + generated_sketches.add(sketch_id) + lines.extend(_generate_revolve(op, sketches.get(sketch_id, {}))) + elif op_type in ("linear_pattern", "pattern_linear"): + lines.extend(_generate_linear_pattern(op, operations, sketches, references)) + elif op_type == "pattern_mirror": + lines.extend(_generate_mirror_pattern(op, operations, sketches, references)) + else: + lines.append(f" # TODO: {op_type} - {op_name}") + + lines.append("") + + lines.append(" if result is None:") + lines.append(' raise Exception("No solid was created")') + lines.append("") + lines.append(" # Clean up small inaccuracies from Boolean operations") + lines.append(" try:") + lines.append(" result = result.clean()") + lines.append(" except Exception:") + lines.append(" pass") + lines.append(f' export_step(result, "{part_name_clean}.step")') + lines.append(" return result") + lines.append("") + lines.append("# Run the function") + lines.append('if __name__ == "__main__":') + lines.append(f" build_{part_name_clean}()") + + return "\n".join(lines) + +def _generate_imported_body_pending(op: Dict[str, Any]) -> list[str]: + return [ + f" # Imported body requires generic JSON B-Rep reconstruction: {op.get('name', '')}", + " raise NotImplementedError(", + " 'Pure-JSON imported-body reconstruction is not implemented yet; '", + " 'the plugin captured solid_bodies topology and the part is marked not ready.'", + " )", + ] + +def _generate_assembly_compose(op: Dict[str, Any]) -> list[str]: + params = op.get("parameters") or {} + components = params.get("components") or [] + component_ids = [component.get("component_id") for component in components] + message = f"Assembly requires rebuilt component JSON registry: {component_ids!r}" + return [ + f" # Pure-JSON assembly composition: {op.get('name', '')}", + " raise NotImplementedError(", + f" {message!r}", + " )", + ] + +def _sw_math_transform_matrix(array_data: Any, component_name: str) -> list[list[float]]: + if not isinstance(array_data, list) or len(array_data) < 13: + raise ValueError(f"Assembly component {component_name} has no complete 16-value transform") + values = [float(value or 0) for value in array_data] + scale = values[12] + if abs(scale) <= 1e-12: + raise ValueError(f"Assembly component {component_name} has an invalid zero scale") + # SOLIDWORKS stores row-vector axes and translation in elements 9..11. + # build123d/OpenCascade uses a column-vector 3x4 matrix, hence transpose. + return [ + [values[0] * scale, values[3] * scale, values[6] * scale, values[9] * 1000.0], + [values[1] * scale, values[4] * scale, values[7] * scale, values[10] * 1000.0], + [values[2] * scale, values[5] * scale, values[8] * scale, values[11] * 1000.0], + [0.0, 0.0, 0.0, 1.0], + ] + +def sort_operations_for_history(operations: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + """Return operations in SW rebuild order.""" + if _looks_like_reverse_history(operations): + return list(reversed(operations)) + if all(op.get("source_feature", {}).get("index") is not None for op in operations): + return sorted(operations, key=lambda op: op.get("source_feature", {}).get("index", 0)) + return sorted(operations, key=_operation_priority) + +def _looks_like_reverse_history(operations: list[Dict[str, Any]]) -> bool: + build_ops = [ + op + for op in operations + if op.get("type") not in ("unsupported", "unknown") + ] + if len(build_ops) < 2: + return False + additive = {"extrude_add", "revolve_add", "sweep", "loft"} + downstream = {"extrude_cut", "revolve_cut", "fillet", "chamfer", "hole", "linear_pattern", "pattern_linear"} + return build_ops[0].get("type") in downstream and build_ops[-1].get("type") in additive + +def _operation_priority(op: Dict[str, Any]) -> int: + op_type = op.get("type", "") + if op_type == "extrude_add": + return 0 + if op_type in ("extrude_cut", "revolve_cut"): + return 1 + if op_type == "revolve_add": + return 2 + if op_type in ("fillet", "chamfer"): + return 3 + if op_type in ("sweep", "loft"): + return 4 + return 99 + +def _sketch_has_buildable_profile(sketch: Dict[str, Any]) -> bool: + for entity in sketch.get("entities", []) or []: + if entity.get("construction"): + continue + if entity.get("type") == "circle" and float(entity.get("radius_mm") or 0) > 0: + return True + if entity.get("type") == "arc" and float(entity.get("radius_mm") or 0) > 0: + return True + valid_lines = 0 + for entity in sketch.get("entities", []) or []: + if entity.get("construction") or entity.get("type") != "line": + continue + start = entity.get("start") or [0, 0] + end = entity.get("end") or [0, 0] + if math.hypot(float(start[0]) - float(end[0]), float(start[1]) - float(end[1])) > 1e-6: + valid_lines += 1 + return valid_lines >= 2 + +def _reverse_curve_entity(ent: Dict[str, Any]) -> Dict[str, Any]: + """Reverse a sketch segment while preserving its geometric traversal.""" + reversed_ent = dict(ent) + reversed_ent["start"], reversed_ent["end"] = ent.get("end"), ent.get("start") + reversed_ent["reversed"] = not bool(ent.get("reversed", False)) + if ent.get("type") == "arc": + raw = ent.get("raw") if isinstance(ent.get("raw"), dict) else {} + axis = ent.get("curve_axis") or raw.get("curve_axis") + if isinstance(axis, list) and len(axis) >= 3: + # The arc's endpoints and orientation are a pair. Keep the + # source `raw` untouched, but provide a flipped top-level axis for + # code generation so a reversed minor arc remains a minor arc. + reversed_ent["curve_axis"] = [-float(value) for value in axis[:3]] + # 必须删除预置的角度字段,否则代码生成会使用旧的(start,end未翻转时的)角度, + # 导致弧段遍历方向与连接顺序相反(如对外弧CW而对内弧也CW而非CCW)。 + reversed_ent.pop("start_angle_deg", None) + reversed_ent.pop("end_angle_deg", None) + reversed_ent.pop("arc_sweep_deg", None) + return reversed_ent + +def _ordered_wire_entities(entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + """Order sketch line/arc entities into connected loops when SW did not export contours.""" + drawable = [ + ent for ent in entities + if ent.get("type") in ("line", "arc") + and _point_key(ent.get("start")) is not None + and _point_key(ent.get("end")) is not None + ] + if len(drawable) < 3: + return entities + + by_node: dict[tuple[float, float], list[tuple[int, str]]] = {} + for idx, ent in enumerate(drawable): + by_node.setdefault(_point_key(ent.get("start")), []).append((idx, "start")) + by_node.setdefault(_point_key(ent.get("end")), []).append((idx, "end")) + + if not by_node or any(len(touches) != 2 for touches in by_node.values()): + return entities + + remaining = set(range(len(drawable))) + ordered: list[Dict[str, Any]] = [] + + while remaining: + first_idx = min(remaining) + remaining.remove(first_idx) + first = drawable[first_idx] + loop = [first] + loop_start = _point_key(first.get("start")) + cursor = _point_key(first.get("end")) + + while cursor != loop_start: + next_idx = None + next_side = None + for candidate_idx, side in by_node.get(cursor, []): + if candidate_idx in remaining: + next_idx = candidate_idx + next_side = side + break + if next_idx is None: + return entities + + remaining.remove(next_idx) + next_ent = drawable[next_idx] + if next_side == "end": + next_ent = _reverse_curve_entity(next_ent) + loop.append(next_ent) + cursor = _point_key(next_ent.get("end")) + + ordered.extend(loop) + + return ordered + +def _infer_closed_wire_loops(entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + drawable = [ + (idx, ent) for idx, ent in enumerate(entities) + if not ent.get("construction", False) + and ent.get("type") in ("line", "arc") + and _point_key(ent.get("start")) is not None + and _point_key(ent.get("end")) is not None + ] + if len(drawable) < 3: + return [] + + by_node: dict[tuple[float, float], list[tuple[int, str]]] = {} + for local_idx, (_, ent) in enumerate(drawable): + by_node.setdefault(_point_key(ent.get("start")), []).append((local_idx, "start")) + by_node.setdefault(_point_key(ent.get("end")), []).append((local_idx, "end")) + + remaining = set(range(len(drawable))) + loops: list[Dict[str, Any]] = [] + while remaining: + first_idx = min(remaining) + remaining.remove(first_idx) + _, first = drawable[first_idx] + loop_indices = [first_idx] + loop_start = _point_key(first.get("start")) + cursor = _point_key(first.get("end")) + + while cursor != loop_start: + matches = [(idx, side) for idx, side in by_node.get(cursor, []) if idx in remaining] + if not matches: + loop_indices = [] + break + next_idx, next_side = matches[0] + remaining.remove(next_idx) + _, next_ent = drawable[next_idx] + loop_indices.append(next_idx) + cursor = _point_key(next_ent.get("start") if next_side == "end" else next_ent.get("end")) + + if not loop_indices: + continue + entity_indices = [drawable[idx][0] for idx in loop_indices] + bbox = _loop_bbox([entities[idx] for idx in entity_indices]) + loops.append({ + "entity_indices": entity_indices, + "is_closed": True, + "bbox_mm": bbox, + "bbox_area_mm2": _bbox_area_2d(bbox), + "source": "inferred_connected_loop", + }) + return loops + +def _loop_radius_candidates(loop: Dict[str, Any], entities: list[Dict[str, Any]]) -> list[float]: + radii: list[float] = [] + for idx in loop.get("entity_indices", []) or []: + if not isinstance(idx, int) or idx < 0 or idx >= len(entities): + continue + ent = entities[idx] + radius = ent.get("radius_mm") + if radius is not None: + radii.append(abs(float(radius))) + bbox = loop.get("bbox_mm") + if isinstance(bbox, list) and len(bbox) >= 4: + radii.append(abs(float(bbox[2]) - float(bbox[0])) / 2) + radii.append(abs(float(bbox[3]) - float(bbox[1])) / 2) + return [radius for radius in radii if radius > 1e-6 and math.isfinite(radius)] + +def _owned_profile_radii_mm(operation: Optional[Dict[str, Any]], sketch: Dict[str, Any]) -> list[float]: + if not isinstance(operation, dict): + return [] + radii: list[float] = [] + loop_radii: list[float] = [] + entities = sketch.get("entities") if isinstance(sketch, dict) else [] + sketch_loops = (sketch.get("profile_loops") or sketch.get("loops") or []) if isinstance(sketch, dict) else [] + for loop in sketch_loops: + loop_radii.extend(_loop_radius_candidates(loop, entities if isinstance(entities, list) else [])) + + def _matches_sketch_radius(value: float) -> bool: + return any(abs(value - radius) <= max(0.1, radius * 0.01) for radius in loop_radii) + + for face in operation.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + params = surface.get("cylinder_params") + if surface.get("is_cylinder") and isinstance(params, list) and len(params) >= 7: + radii.append(abs(float(params[6]) * 1000)) + continue + box = face.get("box_m") + area = face.get("area_m2") + if surface.get("is_plane") and isinstance(box, list) and len(box) >= 6 and area is not None: + sizes = [abs(float(box[i + 3]) - float(box[i])) * 1000 for i in range(3)] + non_zero_sizes = [size for size in sizes if size > 1e-4] + if len(non_zero_sizes) >= 2: + outer_radius = max(non_zero_sizes) / 2 + area_mm2 = abs(float(area)) * 1_000_000 + inner_sq = outer_radius * outer_radius - area_mm2 / math.pi + inner_radius = math.sqrt(inner_sq) if inner_sq > 0 else 0.0 + if _matches_sketch_radius(outer_radius): + radii.append(outer_radius) + if inner_radius > 1e-4 and _matches_sketch_radius(inner_radius): + radii.append(inner_radius) + + unique: list[float] = [] + for radius in sorted(radii): + if radius <= 1e-6 or not math.isfinite(radius): + continue + if not any(abs(radius - existing) <= max(0.05, existing * 0.002) for existing in unique): + unique.append(radius) + return unique + +def _loops_matching_owned_radii( + loops: list[Dict[str, Any]], + entities: list[Dict[str, Any]], + owned_radii: list[float], +) -> list[Dict[str, Any]]: + if not loops or not owned_radii: + return [] + matched: list[tuple[float, Dict[str, Any]]] = [] + for loop in loops: + candidates = _loop_radius_candidates(loop, entities) + if not candidates: + continue + best_radius = None + best_delta = float("inf") + for candidate in candidates: + for owned_radius in owned_radii: + delta = abs(candidate - owned_radius) + if delta < best_delta: + best_delta = delta + best_radius = candidate + if best_radius is None: + continue + if best_delta <= max(0.1, best_radius * 0.01): + matched.append((best_radius, loop)) + if not matched: + return [] + matched.sort(key=lambda item: item[0], reverse=True) + deduped: list[tuple[float, Dict[str, Any]]] = [] + seen_loop_keys: set[str] = set() + for radius, loop in matched: + bbox = loop.get("bbox_mm") + key = ",".join(f"{float(value):.4f}" for value in bbox[:4]) if isinstance(bbox, list) and len(bbox) >= 4 else str(loop.get("entity_indices")) + key = f"{radius:.4f}:{key}" + if key in seen_loop_keys: + continue + seen_loop_keys.add(key) + deduped.append((radius, loop)) + matched = deduped + annotated = [] + for index, (_, loop) in enumerate(matched): + loop_copy = dict(loop) + loop_copy["profile_mode"] = "add" if index == 0 else "subtract" + annotated.append(loop_copy) + return annotated + +def _loop_area_from_radii(loops: list[Dict[str, Any]], entities: list[Dict[str, Any]]) -> Optional[float]: + if not loops: + return None + area = 0.0 + for index, loop in enumerate(loops): + radii = _loop_radius_candidates(loop, entities) + if not radii: + return None + radius = max(radii) + mode = loop.get("profile_mode") + sign = -1 if mode == "subtract" or (mode is None and index > 0) else 1 + area += sign * math.pi * radius * radius + return abs(area) if area > 1e-6 else None + +def _aligned_workplane_for_owned_midplane( + sketch: Dict[str, Any], + operation: Optional[Dict[str, Any]], + loops: list[Dict[str, Any]], +) -> Dict[str, Any]: + workplane = dict(sketch.get("workplane") or {}) + if not isinstance(operation, dict) or operation.get("type") != "extrude_add": + return workplane + params = operation.get("parameters") if isinstance(operation.get("parameters"), dict) else {} + if not params.get("both_directions"): + return workplane + + entities = sketch.get("entities") if isinstance(sketch.get("entities"), list) else [] + profile_area = _loop_area_from_radii(loops, entities) + if profile_area is None: + return workplane + + normal = workplane.get("normal") or [0, 0, 1] + origin = workplane.get("origin_mm") or [0, 0, 0] + if not isinstance(normal, list) or not isinstance(origin, list) or len(normal) < 3 or len(origin) < 3: + return workplane + normal_vec = [float(v) for v in normal[:3]] + norm = math.sqrt(sum(v * v for v in normal_vec)) + if norm <= 1e-9: + return workplane + normal_vec = [v / norm for v in normal_vec] + + candidates: list[tuple[float, list[float]]] = [] + for face in operation.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + if not surface.get("is_plane"): + continue + area_m2 = face.get("area_m2") + plane_params = surface.get("plane_params") + if area_m2 is None or not isinstance(plane_params, list) or len(plane_params) < 6: + continue + face_area = abs(float(area_m2)) * 1_000_000 + if abs(face_area - profile_area) > max(0.5, profile_area * 0.02): + continue + plane_normal = [float(v) for v in plane_params[:3]] + plane_norm = math.sqrt(sum(v * v for v in plane_normal)) + if plane_norm <= 1e-9: + continue + plane_normal = [v / plane_norm for v in plane_normal] + alignment = abs(sum(plane_normal[i] * normal_vec[i] for i in range(3))) + if alignment < 0.98: + continue + plane_point = [float(v) * 1000 for v in plane_params[3:6]] + old_offset = sum(float(origin[i]) * normal_vec[i] for i in range(3)) + new_offset = sum(plane_point[i] * normal_vec[i] for i in range(3)) + delta = new_offset - old_offset + if abs(delta) <= 1e-6: + continue + moved_origin = [float(origin[i]) + normal_vec[i] * delta for i in range(3)] + candidates.append((abs(delta), moved_origin)) + if len(candidates) != 1: + return workplane + candidates.sort(key=lambda item: item[0]) + workplane["origin_mm"] = candidates[0][1] + return workplane + +def _project_owned_faces_to_sketch_bbox( + owned_faces: list[Dict[str, Any]], workplane: Dict[str, Any] +) -> Optional[list[float]]: + origin = workplane.get("origin_mm") or [0, 0, 0] + x_dir = workplane.get("x_dir") or [1, 0, 0] + y_dir = workplane.get("y_dir") or [0, 1, 0] + if len(origin) < 3 or len(x_dir) < 3 or len(y_dir) < 3: + return None + + projected: list[tuple[float, float]] = [] + for face in owned_faces: + box = face.get("box_m") if isinstance(face, dict) else None + if not isinstance(box, list) or len(box) < 6: + continue + mins = [float(box[i]) * 1000 for i in range(3)] + maxs = [float(box[i + 3]) * 1000 for i in range(3)] + for x in (mins[0], maxs[0]): + for y in (mins[1], maxs[1]): + for z in (mins[2], maxs[2]): + point = [x, y, z] + rel = [point[i] - float(origin[i]) for i in range(3)] + projected.append(( + sum(rel[i] * float(x_dir[i]) for i in range(3)), + sum(rel[i] * float(y_dir[i]) for i in range(3)), + )) + if not projected: + return None + return [ + min(point[0] for point in projected), + min(point[1] for point in projected), + max(point[0] for point in projected), + max(point[1] for point in projected), + ] + +def _active_profile_loops(sketch: Dict[str, Any], operation: Optional[Dict[str, Any]]) -> list[Dict[str, Any]]: + entities = sketch.get("entities", []) or [] + loops = sketch.get("loops", []) or _infer_closed_wire_loops(entities) + if not loops: + return [] + + op_type = operation.get("type") if isinstance(operation, dict) else None + if op_type == "extrude_cut" and len(loops) > 1: + owned_bbox = _project_owned_faces_to_sketch_bbox( + operation.get("source_owned_faces") or [], + sketch.get("workplane") or {}, + ) + if owned_bbox: + for inner in loops: + inner_bbox = inner.get("bbox_mm") + if _bbox_overlap_ratio_2d(inner_bbox, owned_bbox) < 0.85: + continue + containers = [ + outer for outer in loops + if outer is not inner + and _bbox_contains_2d(outer.get("bbox_mm"), inner_bbox, tolerance=1e-4) + and _bbox_area_2d(outer.get("bbox_mm")) > _bbox_area_2d(inner_bbox) * 1.05 + ] + if containers: + outer = min(containers, key=lambda loop: _bbox_area_2d(loop.get("bbox_mm"))) + outer_loop = dict(outer) + inner_loop = dict(inner) + outer_loop["profile_mode"] = "add" + inner_loop["profile_mode"] = "subtract" + return [outer_loop, inner_loop] + + active = [] + for loop in loops: + bbox = loop.get("bbox_mm") + area = float(loop.get("bbox_area_mm2") or _bbox_area_2d(bbox)) + contains_other = any( + other is not loop + and _bbox_contains_2d(bbox, other.get("bbox_mm")) + and area > float(other.get("bbox_area_mm2") or _bbox_area_2d(other.get("bbox_mm"))) * 1.05 + for other in loops + ) + if not contains_other: + active.append(loop) + if active: + return active + if op_type == "extrude_add" and len(loops) > 1: + owned_matched = _loops_matching_owned_radii(loops, entities, _owned_profile_radii_mm(operation, sketch)) + # Owned-face radii are useful for selecting circular profiles, but a + # rounded outer contour also contributes arc radii. Those radii can + # coincide with an inner circle and make the radius ranking label the + # inner loop as ADD and its containing outer loop as SUBTRACT. Such a + # profile is topologically impossible as a first additive sketch, so + # fall back to the complete contour nesting below. + owned_modes_conflict_with_nesting = any( + candidate.get("profile_mode") == "add" + and any( + container is not candidate + and container.get("profile_mode") == "subtract" + and _bbox_contains_2d( + container.get("bbox_mm"), + candidate.get("bbox_mm"), + tolerance=1e-4, + ) + and _bbox_area_2d(container.get("bbox_mm")) + > _bbox_area_2d(candidate.get("bbox_mm")) * 1.05 + for container in owned_matched + ) + for candidate in owned_matched + ) + if owned_modes_conflict_with_nesting: + owned_matched = [] + if owned_matched: + # Radius evidence cannot identify closed slot/polygon contours. + # Keep non-circular closed loops that lie inside an owned additive + # outer loop; they are material-removal islands in the same + # additive sketch. Circular unmatched loops remain excluded + # because they commonly belong to other features sharing a sketch. + matched_entity_keys = { + tuple(loop.get("entity_indices") or []) for loop in owned_matched + } + additive_outers = [ + loop for loop in owned_matched if loop.get("profile_mode") == "add" + ] + for loop in loops: + entity_indices = tuple(loop.get("entity_indices") or []) + if entity_indices in matched_entity_keys: + continue + profile_entities = [ + entities[index] + for index in entity_indices + if isinstance(index, int) and 0 <= index < len(entities) + ] + is_non_circular_profile = bool(profile_entities) and any( + entity.get("type") != "circle" + and not (entity.get("type") == "arc" and entity.get("is_circle")) + for entity in profile_entities + ) + if not is_non_circular_profile: + continue + if not any( + _bbox_contains_2d( + outer.get("bbox_mm"), loop.get("bbox_mm"), tolerance=1e-4 + ) + for outer in additive_outers + ): + continue + loop_copy = dict(loop) + loop_copy["profile_mode"] = "subtract" + owned_matched.append(loop_copy) + if len(owned_matched) == 1 and isinstance(operation, dict): + outer_loop = owned_matched[0] + outer_radii = _loop_radius_candidates(outer_loop, entities) + outer_radius = max(outer_radii) if outer_radii else 0.0 + outer_disk_area = math.pi * outer_radius * outer_radius if outer_radius > 0 else 0.0 + has_partial_cap = False + for face in operation.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + area_m2 = face.get("area_m2") + if surface.get("is_plane") and area_m2 is not None and outer_disk_area > 0: + face_area = abs(float(area_m2)) * 1_000_000 + if face_area < outer_disk_area * 0.9: + has_partial_cap = True + break + if has_partial_cap: + inner_candidates = [ + loop for loop in loops + if loop is not outer_loop + and _bbox_contains_2d(outer_loop.get("bbox_mm"), loop.get("bbox_mm"), tolerance=1e-4) + ] + if inner_candidates: + inner = max( + ( + loop + for loop in inner_candidates + if max(_loop_radius_candidates(loop, entities) or [0.0]) < outer_radius - 0.5 + ), + key=lambda loop: max(_loop_radius_candidates(loop, entities) or [0.0]), + default=None, + ) + if inner is None: + return owned_matched + inner_radii = _loop_radius_candidates(inner, entities) + inner_radius = max(inner_radii) if inner_radii else 0.0 + if inner_radius <= 0: + return owned_matched + outer_copy = dict(outer_loop) + inner_copy = dict(inner) + outer_copy["profile_mode"] = "add" + inner_copy["profile_mode"] = "subtract" + return [outer_copy, inner_copy] + return owned_matched + annotated = [] + for loop in loops: + bbox = loop.get("bbox_mm") + area = float(loop.get("bbox_area_mm2") or _bbox_area_2d(bbox)) + containers = [ + outer for outer in loops + if outer is not loop + and _bbox_contains_2d(outer.get("bbox_mm"), bbox, tolerance=1e-4) + and float(outer.get("bbox_area_mm2") or _bbox_area_2d(outer.get("bbox_mm"))) > area * 1.05 + ] + loop_copy = dict(loop) + loop_copy["profile_mode"] = "subtract" if containers else "add" + annotated.append(loop_copy) + return annotated + return loops + +def _generate_sketch(sketch: Dict[str, Any], references: Dict[str, Any], operation: Optional[Dict[str, Any]] = None) -> list[str]: + import math + + name = sketch.get("name", "Sketch") + op_type = operation.get("type") if isinstance(operation, dict) else None + workplane = sketch.get("workplane", {}) + entities = sketch.get("entities", []) + loops = _active_profile_loops(sketch, operation) + workplane = _aligned_workplane_for_owned_midplane(sketch, operation, loops) + code = [f" # Sketch: {name}"] + + origin = workplane.get("origin_mm", [0, 0, 0]) + x_dir = workplane.get("x_dir", [1, 0, 0]) + normal = workplane.get("normal", [0, 0, 1]) + + if origin != [0, 0, 0] or x_dir != [1, 0, 0] or normal != [0, 0, 1]: + code.append( + f" with BuildSketch(Plane(origin={_tuple3(origin)}, x_dir={_tuple3(x_dir)}, z_dir={_tuple3(normal)})) as sketch:" + ) + else: + code.append(" with BuildSketch() as sketch:") + + loop_entities = [] + processed_indices = set() + for loop in loops: + for idx in loop.get("entity_indices", []): + if idx < len(entities): + loop_entities.append(entities[idx]) + processed_indices.add(idx) + + append_unprocessed = not loops + for i, ent in enumerate(entities): + if append_unprocessed and i not in processed_indices: + loop_entities.append(ent) + + drawable_entities = [ent for ent in loop_entities if not ent.get("construction", False)] + circle_entities = [ + ent for ent in drawable_entities + if ent.get("type") in ("circle", "arc") and ent.get("is_circle", ent.get("type") == "circle") + ] + wire_entities = [ + ent for ent in drawable_entities + if ent not in circle_entities and ent.get("type") in ("line", "arc") + ] + wire_entities = _ordered_wire_entities(wire_entities) + + handled_circle_entities = set() + if not loops and len(circle_entities) > 1: + ranked_circles = sorted( + enumerate(circle_entities), + key=lambda item: float(item[1].get("radius_mm", 0) or 0), + reverse=True, + ) + outer_index, outer = ranked_circles[0] + outer_center = outer.get("center", [0, 0, 0]) + outer_radius = float(outer.get("radius_mm", 0) or 0) + contains_all = outer_radius > 0 + for _, inner in ranked_circles[1:]: + inner_center = inner.get("center", [0, 0, 0]) + inner_radius = float(inner.get("radius_mm", 0) or 0) + center_distance = math.hypot( + float(inner_center[0]) - float(outer_center[0]), + float(inner_center[1]) - float(outer_center[1]), + ) + if center_distance + inner_radius >= outer_radius - 1e-6: + contains_all = False + break + if contains_all: + code.append(f" with Locations(({outer_center[0]}, {outer_center[1]})):") + code.append(f" Circle({outer_radius})") + handled_circle_entities.add(outer_index) + for inner_index, inner in ranked_circles[1:]: + center = inner.get("center", [0, 0, 0]) + radius = inner.get("radius_mm", 1) + code.append(f" with Locations(({center[0]}, {center[1]})):") + code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") + handled_circle_entities.add(inner_index) + + def circle_is_inner_profile(ent: Dict[str, Any]) -> bool: + if op_type != "extrude_add" or not loops: + return False + center = ent.get("center", [0, 0]) + radius = float(ent.get("radius_mm", 0) or 0) + if radius <= 0 or len(center) < 2: + return False + bbox = [ + float(center[0]) - radius, + float(center[1]) - radius, + float(center[0]) + radius, + float(center[1]) + radius, + ] + return any(_bbox_contains_2d(loop.get("bbox_mm"), bbox, tolerance=1e-4) for loop in loops) + + if not loops: + for circle_index, ent in enumerate(circle_entities): + if circle_index in handled_circle_entities: + continue + center = ent.get("center", [0, 0, 0]) + radius = ent.get("radius_mm", 1) + code.append(f" with Locations(({center[0]}, {center[1]})):") + if circle_is_inner_profile(ent): + code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") + else: + code.append(f" Circle({radius})") + + def orient_wire_entities(profile_entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + """Orient contour segments into a continuous closed wire. + + SolidWorks contour arrays preserve membership but not necessarily each + segment's traversal direction. Reversing an arc must also invert its + curve axis; otherwise a short arc becomes its 270-degree complement. + """ + segments = [deepcopy(entity) for entity in profile_entities] + if len(segments) < 2: + return segments + + def endpoints(entity: Dict[str, Any]) -> tuple[Optional[list[float]], Optional[list[float]]]: + start = entity.get("start") + end = entity.get("end") + if not (isinstance(start, list) and isinstance(end, list) and len(start) >= 2 and len(end) >= 2): + return None, None + return [float(start[0]), float(start[1])], [float(end[0]), float(end[1])] + + def distance(left: list[float], right: list[float]) -> float: + return math.hypot(left[0] - right[0], left[1] - right[1]) + + def reverse(entity: Dict[str, Any]) -> Dict[str, Any]: + reversed_entity = deepcopy(entity) + reversed_entity["start"], reversed_entity["end"] = entity.get("end"), entity.get("start") + axis = reversed_entity.get("curve_axis") or (reversed_entity.get("raw") or {}).get("curve_axis") + if isinstance(axis, list) and len(axis) >= 3: + reversed_entity["curve_axis"] = [-float(value) for value in axis[:3]] + # 删除预置角度,强制代码生成时从翻转后的start/end重新计算 + reversed_entity.pop("start_angle_deg", None) + reversed_entity.pop("end_angle_deg", None) + reversed_entity.pop("arc_sweep_deg", None) + if entity.get("type") == "arc": + center = entity.get("center") or [0.0, 0.0] + start = reversed_entity.get("start") or [0.0, 0.0] + end = reversed_entity.get("end") or [0.0, 0.0] + start_angle = math.degrees(math.atan2(float(start[1]) - float(center[1]), float(start[0]) - float(center[0]))) + end_angle = math.degrees(math.atan2(float(end[1]) - float(center[1]), float(end[0]) - float(center[0]))) + reversed_sweep = end_angle - start_angle + if reversed_sweep <= -180: + reversed_sweep += 360 + elif reversed_sweep > 180: + reversed_sweep -= 360 + reversed_entity["arc_sweep_deg"] = reversed_sweep + return reversed_entity + + ordered = [segments.pop(0)] + while segments: + _, previous_end = endpoints(ordered[-1]) + if previous_end is None: + ordered.extend(segments) + break + candidates = [] + for index, candidate in enumerate(segments): + candidate_start, candidate_end = endpoints(candidate) + if candidate_start is None or candidate_end is None: + continue + candidates.append((distance(previous_end, candidate_start), index, candidate)) + candidates.append((distance(previous_end, candidate_end), index, reverse(candidate))) + if not candidates: + ordered.extend(segments) + break + _, selected_index, selected = min(candidates, key=lambda item: item[0]) + ordered.append(selected) + segments.pop(selected_index) + return ordered + + def append_wire_profile(profile_entities: list[Dict[str, Any]], make_face_mode: Optional[str] = None) -> None: + profile_entities = orient_wire_entities(profile_entities) + code.append(" with BuildLine():") + code.append(" pass") + emitted_wire = False + line_points = [] + for line_ent in profile_entities: + if line_ent.get("type") == "line": + line_points.extend([line_ent.get("start", [0, 0]), line_ent.get("end", [0, 0])]) + line_bbox = None + if line_points: + xs = [float(point[0]) for point in line_points] + ys = [float(point[1]) for point in line_points] + line_bbox = (min(xs), min(ys), max(xs), max(ys)) + for ent in profile_entities: + ent_type = ent.get("type", "") + if ent_type == "line": + start = ent.get("start", [0, 0, 0]) + end = ent.get("end", [0, 0, 0]) + if math.hypot(float(start[0]) - float(end[0]), float(start[1]) - float(end[1])) <= 1e-6: + code.append(" # Skip zero-length line") + continue + code.append(f" Line(({start[0]}, {start[1]}), ({end[0]}, {end[1]}))") + emitted_wire = True + elif ent_type == "arc": + center = ent.get("center", [0, 0, 0]) + radius = ent.get("radius_mm", 1) + if "start_angle_deg" in ent and "end_angle_deg" in ent: + start_angle = ent["start_angle_deg"] + end_angle = ent["end_angle_deg"] + else: + start = ent.get("start", [0, 0]) + end = ent.get("end", [0, 0]) + start_angle = math.degrees(math.atan2(start[1] - center[1], start[0] - center[0])) + end_angle = math.degrees(math.atan2(end[1] - center[1], end[0] - center[0])) + if ent.get("arc_sweep_deg") is not None: + arc_size = float(ent["arc_sweep_deg"]) + else: + curve_axis = ent.get("curve_axis") or ent.get("raw", {}).get("curve_axis") + if isinstance(curve_axis, list) and len(curve_axis) >= 3 and abs(float(curve_axis[2])) > 1e-9: + if float(curve_axis[2]) >= 0: + arc_size = (end_angle - start_angle) % 360 + else: + arc_size = -((start_angle - end_angle) % 360) + else: + arc_size = end_angle - start_angle + if arc_size <= 0: + arc_size += 360 + if arc_size > 180: + arc_size -= 360 + code.append(f" CenterArc(({center[0]}, {center[1]}), {radius}, {start_angle}, {arc_size})") + emitted_wire = True + else: + code.append(f" # TODO: entity type {ent_type}") + if not emitted_wire: + code.append(" # Skip empty wire profile") + return + if make_face_mode: + code.append(f" make_face(mode=Mode.{make_face_mode.upper()})") + else: + code.append(" make_face()") + + if loops: + ordered_loops = sorted( + enumerate(loops), + key=lambda item: (1 if item[1].get("profile_mode") == "subtract" else 0, item[0]), + ) + for loop_order_index, (loop_index, loop) in enumerate(ordered_loops): + profile_entities = [ + entities[idx] + for idx in loop.get("entity_indices", []) + if idx < len(entities) + and not entities[idx].get("construction", False) + and entities[idx].get("type") in ("line", "arc", "circle") + ] + circle_profile_entities = [ + ent for ent in profile_entities + if ent.get("type") == "circle" or (ent.get("type") == "arc" and ent.get("is_circle")) + ] + wire_profile_entities = [ + ent for ent in profile_entities + if ent.get("type") in ("line", "arc") and ent not in circle_profile_entities + ] + wire_profile_entities = _ordered_wire_entities(wire_profile_entities) + if not profile_entities: + continue + mode = loop.get("profile_mode") + if wire_profile_entities: + append_wire_profile(wire_profile_entities, mode if loop_order_index > 0 or mode else None) + else: + for ent in circle_profile_entities: + center = ent.get("center", [0, 0, 0]) + radius = ent.get("radius_mm", 1) + code.append(f" with Locations(({center[0]}, {center[1]})):") + if mode == "subtract": + code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") + else: + code.append(f" Circle({radius})") + elif wire_entities: + append_wire_profile(wire_entities) + + return code + +def _sketch_circle_radii_mm(sketch: Optional[Dict[str, Any]]) -> list[float]: + if not isinstance(sketch, dict): + return [] + radii = [] + for entity in sketch.get("entities", []) or []: + if entity.get("construction") or entity.get("type") != "circle": + continue + radius = float(entity.get("radius_mm") or 0) + if radius > 0: + radii.append(abs(radius)) + return radii + +def _flip_side_step_inner_radius_mm( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]], + operations: list[Dict[str, Any]], + sketches: Dict[str, Dict[str, Any]], +) -> Optional[float]: + outer_radii = _sketch_circle_radii_mm(sketch) + if not outer_radii: + return None + outer = max(outer_radii) + if len(outer_radii) > 1: + return min(outer_radii) + inner = None + try: + op_index = operations.index(op) + except ValueError: + op_index = len(operations) + for prev in operations[:op_index]: + if prev.get("type") != "extrude_cut": + continue + if not (prev.get("parameters") or {}).get("flip_side_to_cut"): + continue + prev_sketch = sketches.get(prev.get("sketch") or "", {}) + for radius in _sketch_circle_radii_mm(prev_sketch): + if radius < outer - 1e-6: + inner = radius if inner is None else max(inner, radius) + return inner + +def _flip_side_uses_step_ring( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]], + operations: list[Dict[str, Any]], + sketches: Dict[str, Dict[str, Any]], +) -> tuple[Optional[float], Optional[float]]: + outer_radii = _sketch_circle_radii_mm(sketch) + if not outer_radii: + return None, None + outer = max(outer_radii) + inner = _flip_side_step_inner_radius_mm(op, sketch, operations, sketches) + if inner is None or outer <= inner + 0.5: + return None, None + if outer < 35 and outer / inner < 1.5: + return None, None + return outer, inner + +def _effective_extrude_cut_depth_mm( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]], + distance_mm: float, +) -> float: + params = op.get("parameters") if isinstance(op.get("parameters"), dict) else {} + if not params.get("flip_side_to_cut"): + return distance_mm + workplane = (sketch or {}).get("workplane") or {} + origin = workplane.get("origin_mm") or [0.0, 0.0, 0.0] + normal = workplane.get("normal") or [0.0, 0.0, 1.0] + if not isinstance(origin, list) or not isinstance(normal, list) or len(origin) < 3 or len(normal) < 3: + return distance_mm + axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) + cut_amount = distance_mm if params.get("reverse_direction", False) else -abs(distance_mm) + cut_sign = -1.0 if cut_amount < 0 else 1.0 + owned_values = [] + for face in op.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + if not surface.get("is_plane"): + continue + box = face.get("box_m") + if not isinstance(box, list) or len(box) < 6: + continue + thicknesses = [abs(float(box[i + 3]) - float(box[i])) * 1000 for i in range(3)] + if min(thicknesses) > 0.5: + continue + owned_values.extend([float(box[axis]) * 1000, float(box[axis + 3]) * 1000]) + if not owned_values: + return distance_mm + transition = (min(owned_values) if cut_sign < 0 else max(owned_values)) + cut_sign * 1.0 + effective = abs(float(origin[axis]) - transition) + if effective <= 1e-6: + return distance_mm + if abs(effective - abs(distance_mm)) <= 0.25: + return distance_mm + # Guard: owned-face depth can be wrong when all owned faces + # are near the sketch plane (e.g., edge details), not at the + # real cut termination. Fall back to a through-cut distance + # so the invert-cutter extends past the entire body. + if effective < max(2.0, abs(distance_mm) * 0.15): + return max(distance_mm, THROUGH_CUT_AMOUNT_MM) + return effective + +def _owned_extrude_terminal_offsets_mm( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]], +) -> tuple[Optional[float], Optional[float]]: + """Return the nearest owned planar end faces along the sketch normal. + + SolidWorks can report a two-sided feature with a stale blind depth when one + side terminates on geometry. The feature-owned end face is the reliable + result geometry: its signed offset from the sketch plane identifies the + actual termination direction and distance. + """ + workplane = (sketch or {}).get("workplane") or {} + origin = workplane.get("origin_mm") or [] + normal = workplane.get("normal") or [] + if not (isinstance(origin, list) and isinstance(normal, list) and len(origin) >= 3 and len(normal) >= 3): + return None, None + magnitude = math.sqrt(sum(float(value) ** 2 for value in normal[:3])) + if magnitude <= 1e-9: + return None, None + unit_normal = [float(value) / magnitude for value in normal[:3]] + positive: list[float] = [] + negative: list[float] = [] + for face in op.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + if not surface.get("is_plane"): + continue + params = surface.get("plane_params") + if not isinstance(params, list) or len(params) < 6: + continue + point_mm = [float(value) * 1000 for value in params[3:6]] + offset = sum((point_mm[index] - float(origin[index])) * unit_normal[index] for index in range(3)) + if offset > 1e-4: + positive.append(offset) + elif offset < -1e-4: + negative.append(offset) + return (max(positive) if positive else None, min(negative) if negative else None) + +def _resolve_extrude_owned_termination( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + """Resolve an asymmetric two-sided add from its SolidWorks-owned end face.""" + params = op.get("parameters") if isinstance(op.get("parameters"), dict) else {} + if op.get("type") != "extrude_add" or not params.get("both_directions"): + return op + positive, negative = _owned_extrude_terminal_offsets_mm(op, sketch) + if (positive is None) == (negative is None): + return op + resolved = dict(op) + resolved_params = dict(params) + resolved_params["distance_mm"] = positive if positive is not None else abs(float(negative)) + resolved_params["reverse_distance_mm"] = 0 + resolved_params["both_directions"] = False + resolved_params["reverse_direction"] = negative is not None + resolved_params["owned_termination_resolved"] = True + resolved["parameters"] = resolved_params + return resolved + +def _generate_extrude( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]] = None, + operations: Optional[list[Dict[str, Any]]] = None, + sketches: Optional[Dict[str, Dict[str, Any]]] = None, +) -> list[str]: + params = op.get("parameters", {}) + distance = _effective_extrude_cut_depth_mm(op, sketch, float(params.get("distance_mm", 10) or 10)) + reverse_distance = params.get("reverse_distance_mm", 0) + op_type = op.get("type", "") + name = op.get("name", "") + both_directions = params.get("both_directions", False) + flip_side_to_cut = bool(params.get("flip_side_to_cut", False)) + end_condition_code = params.get("end_condition_code") + reverse_end_condition_code = params.get("reverse_end_condition_code") + end_condition = SW_END_CONDITIONS.get(end_condition_code, f"Unknown({end_condition_code})") + operations = operations or [] + sketches = sketches or {} + outer_radius, inner_radius = ( + _flip_side_uses_step_ring(op, sketch, operations, sketches) if flip_side_to_cut else (None, None) + ) + resolved_owned_termination = bool(params.get("owned_termination_resolved")) + + code = [f" # {op_type}: {name}"] + if resolved_owned_termination: + code.append(" # Use the owned planar end face to resolve SW's asymmetric termination") + preserve_visible = bool(op.get("source_owned_faces")) and op_type == "extrude_add" + if end_condition_code is not None: + code.append(f" # SW end condition: {end_condition}") + + owned_cylinder_faces = _owned_cylindrical_cut_faces(op, sketch or {}) + prefer_blind_sketch = _prefer_blind_sketch_extrude( + op, sketch or {}, distance, end_condition_code, owned_cylinder_faces + ) + if op_type == "extrude_cut" and owned_cylinder_faces and flip_side_to_cut: + code.append(" # Replay SW flip-side circular cut from owned cylindrical faces") + code.append(f" result = cut_owned_flip_side_cylindrical_faces(result, {repr(owned_cylinder_faces)})") + return code + + if op_type == "extrude_cut" and owned_cylinder_faces and not flip_side_to_cut and not prefer_blind_sketch: + code.append(" # Replay cut from SW owned cylindrical faces when start/end references are missing") + code.append(f" result = cut_owned_cylindrical_faces(result, {repr(owned_cylinder_faces)})") + return code + + owned_bbox = _owned_bbox_cut(op, sketch or {}, distance) + if op_type == "extrude_cut" and owned_bbox and not flip_side_to_cut and not prefer_blind_sketch: + code.append(" # Replay cut from SW owned face bbox when extrude start/end references are missing") + code.append(f" result = cut_owned_bbox(result, {repr(owned_bbox)})") + return code + + if distance == 0 and reverse_distance == 0: + if op_type == "extrude_cut" and end_condition_code not in (None, 0): + distance = THROUGH_CUT_AMOUNT_MM + both_directions = end_condition_code in (1, 2, 9) + code.append(f" # TODO: exact sw_extrude_cut_{end_condition}; using long cutter") + else: + code.append(" # Skip: zero distance") + return code + elif op_type == "extrude_add" and (end_condition_code in (6, 8) or reverse_end_condition_code in (6, 8)): + code.append(" # SW mid-plane/two-sided extrusion represented by this IR") + distance = distance / 2 + reverse_distance = distance + both_directions = True + elif op_type == "extrude_cut" and end_condition_code not in (None, 0): + distance = max(distance, reverse_distance, THROUGH_CUT_AMOUNT_MM) + both_directions = both_directions or end_condition_code in (1, 2, 9) + code.append(f" # TODO: exact sw_extrude_cut_{end_condition}; using long cutter") + + if both_directions: + amount = max(distance, reverse_distance) if reverse_distance > 0 else distance + if op_type == "extrude_cut": + code.append(f" cutter = extrude(sketch.sketch, amount={amount}, both=True)") + if flip_side_to_cut: + normal = (sketch or {}).get("workplane", {}).get("normal", [0, 0, 1]) + if outer_radius is not None and inner_radius is not None: + code.append( + " result = sw_flip_side_step_cut(" + f"result, cutter, normal={_tuple3(normal)}, " + f"outer_radius_mm={outer_radius}, inner_radius_mm={inner_radius})" + ) + else: + code.append(f" result = sw_inverted_profile_cut(result, cutter, normal={_tuple3(normal)})") + else: + code.append(" result = safe_subtract(result, cutter)") + else: + code.append(f" solid = extrude(sketch.sketch, amount={amount}, both=True)") + code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") + elif op_type == "extrude_cut": + if distance > 0: + cut_amount = distance if params.get("reverse_direction", False) else -distance + code.append(f" cutter = extrude(sketch.sketch, amount={cut_amount})") + # 当盲拉伸从不同于草图的起始面开始时,平移cutter到正确位置 + if prefer_blind_sketch: + face_offset = _blind_extrude_face_offset(op, sketch or {}) + if face_offset is not None: + code.append(f" cutter = cutter.locate(Location({_tuple3(face_offset)}))") + if flip_side_to_cut: + normal = (sketch or {}).get("workplane", {}).get("normal", [0, 0, 1]) + if outer_radius is not None and inner_radius is not None: + code.append( + " result = sw_flip_side_step_cut(" + f"result, cutter, normal={_tuple3(normal)}, " + f"outer_radius_mm={outer_radius}, inner_radius_mm={inner_radius})" + ) + else: + code.append(f" result = sw_inverted_profile_cut(result, cutter, normal={_tuple3(normal)})") + else: + code.append(" result = safe_subtract(result, cutter)") + else: + code.append(" # Skip: zero distance cut") + else: + add_amount = -distance if params.get("reverse_direction", False) else distance + code.append(f" solid = extrude(sketch.sketch, amount={add_amount})") + code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") + + return code + +def _owned_cylindrical_cut_faces(op: Dict[str, Any], sketch: Dict[str, Any]) -> list[Dict[str, Any]]: + if op.get("type") != "extrude_cut": + return [] + sketch_radii = [ + abs(float(entity.get("radius_mm") or 0)) + for entity in sketch.get("entities", []) or [] + if not entity.get("construction") and entity.get("type") == "circle" + ] + if not sketch_radii: + return [] + matched = [] + for face in op.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + params = surface.get("cylinder_params") + bbox = face.get("box_m") + if not (surface.get("is_cylinder") and isinstance(params, list) and len(params) >= 7): + continue + if not (isinstance(bbox, list) and len(bbox) >= 6): + continue + radius_mm = abs(float(params[6]) * 1000) + if not any(abs(radius_mm - sketch_radius) <= max(0.05, sketch_radius * 0.01) for sketch_radius in sketch_radii): + continue + matched.append(face) + return matched + +def _blind_extrude_face_offset( + op: Dict[str, Any], + sketch: Dict[str, Any], +) -> Optional[list[float]]: + """当盲拉伸从不同于草图的起始面开始时,计算cutter的3D平移向量。 + 返回None表示不需要平移。""" + faces = (op.get("source_owned_faces") or []) + if not faces: + return None + valid_bboxes = [] + for face in faces: + bm = face.get("box_m") + if isinstance(bm, list) and len(bm) >= 6: + valid_bboxes.append([float(v) * 1000 for v in bm[:6]]) + if not valid_bboxes: + return None + normal = (sketch.get("workplane") or {}).get("normal") + if not isinstance(normal, list) or len(normal) < 3: + return None + origin = (sketch.get("workplane") or {}).get("origin_mm") or [0, 0, 0] + # 确定主导轴 (extrude方向) + axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) + normal_sign = 1.0 if float(normal[axis]) >= 0 else -1.0 + sketch_coord = float(origin[axis]) if isinstance(origin, list) and len(origin) > axis else 0.0 + # 取离草图平面最近的面坐标,使cutter从面的最近点开始切入 + # 对于多个面,面可能在草图平面两侧。 + all_coords = [] + for b in valid_bboxes: + all_coords.append(b[axis]) + all_coords.append(b[axis + 3]) + if not all_coords: + return None + # 找离sketch_coord最近的面坐标 + face_coord = min(all_coords, key=lambda c: abs(c - sketch_coord)) + offset = face_coord - sketch_coord + if abs(offset) < 1e-3: + return None + # 返回3D平移向量(仅沿extrude方向) + result = [0.0, 0.0, 0.0] + result[axis] = offset + return result + +def _prefer_blind_sketch_extrude( + op: Dict[str, Any], + sketch: Dict[str, Any], + distance_mm: float, + end_condition_code: Optional[int], + owned_cylinder_faces: list[Dict[str, Any]], +) -> bool: + """优先使用盲拉伸而非 bbox 回退。对于矩形/圆等简单截面, + 盲拉伸比包围盒近似精确得多。含弧的复杂截面可能因方向问题 + 产生意外偏差,此时仍走 bbox 路径。""" + if owned_cylinder_faces: + return False + if end_condition_code not in (None, 0) or distance_mm <= 0: + return False + if not _sketch_has_buildable_profile(sketch): + return False + # 有 owned_faces 的矩形或纯圆截面: 盲拉伸比 bbox 更精确 + entities = sketch.get("entities", []) or [] + non_const = [e for e in entities if not e.get("construction", False)] + types = {e.get("type") for e in non_const if e.get("type") not in ("point", "text")} + # 排除point/text后仍是简单截面才用盲拉伸。 + # 但如果面位于不同平面,让_blind_extrude_face_offset处理 + is_simple = types <= {"line"} or types <= {"circle"} + if not is_simple: + return False + # 检查草图平面与面是否有关键偏移 - 只有当盲拉伸需要偏移修正时才使用 + faces = op.get("source_owned_faces") or [] + if faces and _blind_extrude_face_offset(op, sketch) is not None: + return True # 有面偏移,需要盲拉伸+offset修正 + # 无面偏移时,只有当start/end引用完整时才用盲拉伸 + if op.get("start_reference") or op.get("end_reference"): + return True + return False + +def _owned_bbox_cut(op: Dict[str, Any], sketch: Dict[str, Any], distance_mm: float) -> Optional[list[float]]: + if op.get("type") != "extrude_cut": + return None + faces = [ + face for face in (op.get("source_owned_faces") or []) + if isinstance(face, dict) and isinstance(face.get("box_m"), list) and len(face.get("box_m")) >= 6 + ] + if not faces: + return None + bboxes = [[float(value) * 1000 for value in face["box_m"][:6]] for face in faces] + bbox = [ + min(box[axis] for box in bboxes) if axis < 3 else max(box[axis] for box in bboxes) + for axis in range(6) + ] + normal = (sketch.get("workplane") or {}).get("normal") or [0, 0, 1] + if not isinstance(normal, list) or len(normal) < 3: + return None + axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) + extent = abs(bbox[axis + 3] - bbox[axis]) + origin = (sketch.get("workplane") or {}).get("origin_mm") or [0, 0, 0] + origin_coord = float(origin[axis]) if isinstance(origin, list) and len(origin) > axis else None + distance = abs(float(distance_mm or 0)) + origin_outside = ( + origin_coord is not None + and (origin_coord < min(bbox[axis], bbox[axis + 3]) - 1e-6 or origin_coord > max(bbox[axis], bbox[axis + 3]) + 1e-6) + ) + if extent <= distance * 1.25 and not origin_outside: + return None + return bbox + +def _generate_revolve(op: Dict[str, Any], sketch: Optional[Dict[str, Any]] = None) -> list[str]: + params = op.get("parameters", {}) + angle = params.get("angle_deg") + if angle is None and params.get("angle_rad") is not None: + angle = float(params.get("angle_rad")) * 180 / math.pi + if angle is None: + angle = 360 + if abs(angle - 360) < 1e-6: + angle = 360 + op_type = op.get("type", "") + name = op.get("name", "") + code = [f" # {op_type}: {name}"] + axis_expr = _revolve_axis_expr(params, sketch or {}) + code.append(f" revolve_axis = {axis_expr}") + if op_type == "revolve_cut": + code.append(f" cutter = revolve(sketch.sketch, axis=revolve_axis, revolution_arc={angle})") + code.append(" # Force OCCT to fully evaluate both solids before Boolean ops") + code.append(" _ = list(cutter.solids()); _ = cutter.is_valid; _ = cutter.volume") + code.append(" _ = list(result.solids()); _ = result.is_valid; _ = result.volume") + code.append(" # Use a single subtract and capture the result directly (avoids OCCT heisenbug)") + code.append(" result = safe_subtract(result, cutter)") + else: + code.append(f" solid = revolve(sketch.sketch, axis=revolve_axis, revolution_arc={angle})") + preserve_visible = bool(op.get("source_owned_faces")) + code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") + return code + +def _revolve_axis_expr(params: Dict[str, Any], sketch: Dict[str, Any]) -> str: + # 优先使用草图中的构造线作为旋转轴, + # 因为它保证位于草图平面上(SW 的 revolve 操作依赖于此) + construction_axis = _sketch_construction_axis(sketch) + if construction_axis: + origin, direction = construction_axis + return f"Axis({_tuple3(origin)}, {_tuple3(direction)})" + + axis_reference = params.get("axis_reference") or {} + if axis_reference.get("origin_mm") and axis_reference.get("direction"): + return f"Axis({_tuple3(axis_reference['origin_mm'])}, {_tuple3(axis_reference['direction'])})" + for candidate in params.get("axis_candidates") or []: + if candidate.get("model_start_mm") and candidate.get("model_direction"): + return f"Axis({_tuple3(candidate['model_start_mm'])}, {_tuple3(candidate['model_direction'])})" + + workplane = sketch.get("workplane", {}) + origin = workplane.get("origin_mm", [0, 0, 0]) + direction = workplane.get("x_dir", [1, 0, 0]) + return f"Axis({_tuple3(origin)}, {_tuple3(direction)})" + +def _sketch_construction_axis( + sketch: Dict[str, Any], +) -> Optional[tuple[list[float], list[float]]]: + workplane = sketch.get("workplane", {}) + origin = [float(v) for v in workplane.get("origin_mm", [0, 0, 0])] + x_dir = [float(v) for v in workplane.get("x_dir", [1, 0, 0])] + y_dir = [float(v) for v in workplane.get("y_dir", [0, 1, 0])] + + for entity in sketch.get("entities", []): + if entity.get("type") != "line" or not entity.get("construction"): + continue + start = entity.get("start") + end = entity.get("end") + if not start or not end: + continue + start_3d = _sketch_point_to_model_from_basis(origin, x_dir, y_dir, start) + end_3d = _sketch_point_to_model_from_basis(origin, x_dir, y_dir, end) + direction = [end_3d[i] - start_3d[i] for i in range(3)] + length = math.sqrt(sum(component * component for component in direction)) + if length <= 0: + continue + return start_3d, [component / length for component in direction] + return None + +def _sketch_point_to_model_from_basis( + origin: list[float], x_dir: list[float], y_dir: list[float], point: list[float] +) -> list[float]: + return [ + origin[i] + x_dir[i] * float(point[0]) + y_dir[i] * float(point[1]) + for i in range(3) + ] + +def _generate_fillet(op: Dict[str, Any]) -> list[str]: + params = op.get("parameters", {}) + radius = params.get("radius_mm") + selectors = op.get("selectors", []) + owned_faces = op.get("source_owned_faces") or [] + if not radius or float(radius) <= 0: + return [f" # Fillet skipped: source radius missing for {op.get('name', '')}"] + return [ + f" # Fillet: {op.get('name', '')}", + " result = fillet_selected(" + f"result, radius={radius}, selectors={repr(selectors)}, owned_faces={repr(owned_faces)})", + ] + +def _generate_chamfer(op: Dict[str, Any]) -> list[str]: + params = op.get("parameters", {}) + distance = params.get("distance_mm") + selectors = op.get("selectors", []) + owned_faces = op.get("source_owned_faces") or [] + if not distance or float(distance) <= 0: + return [f" # Chamfer skipped: source distance missing for {op.get('name', '')}"] + return [ + f" # Chamfer: {op.get('name', '')}", + " result = chamfer_selected_with_owned_faces(" + f"result, distance={distance}, selectors={repr(selectors)}, owned_faces={repr(owned_faces)})", + ] + +def _generate_move_face(op: Dict[str, Any]) -> list[str]: + data = (op.get("parameters") or {}).get("move_face_data") or {} + selected_faces = data.get("selected_faces") or [] + return [ + f" # MoveFace pure-JSON operation: {op.get('name', '')}", + " raise NotImplementedError(", + f" 'MoveFace native build123d replay is pending; captured selected_faces={len(selected_faces)}'", + " )", + ] + +def _hole_should_use_sw_cut_holes(params: Dict[str, Any], owned_cut_faces: list[Dict[str, Any]]) -> bool: + positions = params.get("positions") or [] + diameter = _hole_diameter_mm(params) + if not positions or diameter <= 0: + return False + if len(owned_cut_faces) <= 1: + return False + has_cone_owned = any((face.get("surface") or {}).get("is_cone") for face in owned_cut_faces) + drill_angle = _hole_drill_angle_rad(params) + if has_cone_owned and not (_hole_has_drill_tip(params) and drill_angle > 0): + return False + counterbore_diameter = _hole_counterbore_diameter_mm(params) + counterbore_depth = _hole_counterbore_depth_mm(params) + if counterbore_diameter > diameter and counterbore_depth > 0: + return True + return _hole_has_through_dimension(params) + +def _effective_hole_cut_depth_mm(params: Dict[str, Any]) -> float: + if _hole_has_through_dimension(params): + return THROUGH_CUT_AMOUNT_MM + return _hole_depth_mm(params) + +def _generate_hole(op: Dict[str, Any]) -> list[str]: + params = op.get("parameters", {}) + diameter = _hole_diameter_mm(params) + depth = _effective_hole_cut_depth_mm(params) + drill_angle = _hole_drill_angle_rad(params) + include_drill_tip = _hole_has_drill_tip(params) + countersink_diameter = _hole_countersink_diameter_mm(params) + countersink_angle = _hole_countersink_angle_rad(params) + counterbore_diameter = _hole_counterbore_diameter_mm(params) + counterbore_depth = _hole_counterbore_depth_mm(params) + positions = [pos.get("mm") for pos in params.get("positions", []) if pos.get("mm")] + host_face = params.get("host_face") or {} + owned_cut_faces = _hole_owned_cut_faces(op) + # Feature position sketches are occasionally incomplete in the plugin export + # (notably for wizard holes with multiple instances). The faces owned by the + # feature are the authoritative result from SolidWorks, including every hole + # location, counterbore, countersink, and drill tip. Prefer replaying those + # surfaces whenever they are available; fall back to the parametric cutter + # only when the exporter has no usable owned-face geometry. + if owned_cut_faces: + return [ + f" # Hole: {op.get('name', '')}", + " # Replay hole from SW owned cut faces to preserve side and axis", + f" result = cut_owned_cylindrical_faces(result, {repr(owned_cut_faces)})", + ] + return [ + f" # Hole: {op.get('name', '')}", + f" result = sw_cut_holes(result, positions={json.dumps(positions)}, host_face={json.dumps(host_face)}, diameter={diameter}, depth={depth}, drill_angle={drill_angle}, include_drill_tip={include_drill_tip}, countersink_diameter={countersink_diameter}, countersink_angle={countersink_angle}, counterbore_diameter={counterbore_diameter}, counterbore_depth={counterbore_depth})", + ] + +def _hole_owned_cut_faces(op: Dict[str, Any]) -> list[Dict[str, Any]]: + matched = [] + for face in op.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + bbox = face.get("box_m") + has_cylinder = ( + surface.get("is_cylinder") + and isinstance(surface.get("cylinder_params"), list) + and len(surface.get("cylinder_params") or []) >= 7 + ) + has_cone = ( + surface.get("is_cone") + and isinstance(surface.get("cone_params"), list) + and len(surface.get("cone_params") or []) >= 8 + ) + if not (has_cylinder or has_cone): + continue + if not (isinstance(bbox, list) and len(bbox) >= 6): + continue + matched.append(face) + return matched + +def _generate_linear_pattern( + op: Dict[str, Any], + operations: list[Dict[str, Any]], + sketches: Dict[str, Dict[str, Any]], + references: Dict[str, Any], +) -> list[str]: + params = op.get("parameters", {}) + source_features = params.get("source_features") or [] + offsets = _linear_pattern_offsets(op) + code = [f" # Linear pattern: {op.get('name', '')}"] + + if not source_features or not offsets: + code.append(" # Skip: no source features or pattern offsets") + return code + + for source_feature in source_features: + source_op = _find_operation_for_source_feature(operations, source_feature) + if not source_op: + code.append(f" # Skip: source feature not found {source_feature.get('name')}") + continue + + for offset_index, offset in enumerate(offsets, start=1): + copied_op = _translated_operation(source_op, offset) + copied_op["name"] = f"{source_op.get('name', '')} pattern copy {offset_index}" + op_type = copied_op.get("type") + + if op_type == "hole": + code.extend(_generate_hole(copied_op)) + elif op_type in ("extrude_cut", "extrude_add", "revolve_cut", "revolve_add"): + source_sketch_id = copied_op.get("sketch") + source_sketch = sketches.get(source_sketch_id or "") + if not source_sketch: + code.append(f" # Skip: source sketch not found for {copied_op.get('name')}") + continue + if not _sketch_has_buildable_profile(source_sketch): + code.append(f" # Skip: source sketch has no buildable profile for {copied_op.get('name')}") + continue + + copied_sketch = _translated_sketch(source_sketch, offset, f"{source_sketch_id}_pattern_{offset_index}") + code.extend(_generate_sketch(copied_sketch, references, copied_op)) + if op_type in ("extrude_cut", "extrude_add"): + code.extend(_generate_extrude(copied_op, copied_sketch, operations, sketches)) + else: + code.extend(_generate_revolve(copied_op, copied_sketch)) + else: + code.append(f" # TODO: pattern source type {op_type}") + + return code + +def _generate_mirror_pattern( + op: Dict[str, Any], + operations: list[Dict[str, Any]], + sketches: Dict[str, Dict[str, Any]], + references: Dict[str, Any], +) -> list[str]: + """生成镜像代码。SW MirrorPattern 镜像的是特征而非整体,因此必须先切掉镜像面负侧的实体,只保留正侧一半再镜像。""" + params = op.get("parameters", {}) + source_features = params.get("source_features") or [] + raw = op.get("raw_parameters", {}) + mirror_plane_info = raw.get("mirror_plane") or {} + + code = [f" # Mirror pattern: {op.get('name', '')}"] + + plane_origin = _extract_mirror_plane_origin(raw, mirror_plane_info) + plane_normal = _extract_mirror_plane_normal(raw, mirror_plane_info) + + mx = plane_origin[0] if plane_origin else 0.0 + my = plane_origin[1] if plane_origin else 0.0 + mz = plane_origin[2] if plane_origin else 0.0 + nx = plane_normal[0] if plane_normal else 0.0 + ny = plane_normal[1] if plane_normal else 0.0 + nz = plane_normal[2] if plane_normal else 1.0 + + code.append(f" mirror_plane = Plane(origin=({mx}, {my}, {mz}), z_dir=({nx}, {ny}, {nz}))") + code.append(f" mx, my, mz = {mx}, {my}, {mz}") + code.append(f" nx, ny, nz = {nx}, {ny}, {nz}") + code.append(f" try:") + code.append(f" bbox = result.bounding_box()") + code.append(f" margin = 10.0") + # Determine dominant axis and cut away the -normal side + adx, ady, adz = abs(nx), abs(ny), abs(nz) + if adx >= ady and adx >= adz: + if nx > 0: + code.append(f" cut_w = (mx - bbox.min.X) + margin") + code.append(f" cut_box = Solid.make_box(cut_w, bbox.max.Y - bbox.min.Y + 2*margin, bbox.max.Z - bbox.min.Z + 2*margin)") + code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") + else: + code.append(f" cut_w = (bbox.max.X - mx) + margin") + code.append(f" cut_box = Solid.make_box(cut_w, bbox.max.Y - bbox.min.Y + 2*margin, bbox.max.Z - bbox.min.Z + 2*margin)") + code.append(f" cut_box = cut_box.translate((mx, bbox.min.Y - margin, bbox.min.Z - margin))") + elif ady >= adx and ady >= adz: + if ny > 0: + code.append(f" cut_h = (my - bbox.min.Y) + margin") + code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, cut_h, bbox.max.Z - bbox.min.Z + 2*margin)") + code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") + else: + code.append(f" cut_h = (bbox.max.Y - my) + margin") + code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, cut_h, bbox.max.Z - bbox.min.Z + 2*margin)") + code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, my, bbox.min.Z - margin))") + else: + if nz > 0: + code.append(f" cut_d = (mz - bbox.min.Z) + margin") + code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, bbox.max.Y - bbox.min.Y + 2*margin, cut_d)") + code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") + else: + code.append(f" cut_d = (bbox.max.Z - mz) + margin") + code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, bbox.max.Y - bbox.min.Y + 2*margin, cut_d)") + code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, mz))") + code.append(f" half = result.cut(cut_box)") + code.append(f" mirrored = half.mirror(mirror_plane)") + code.append(f" result = half.fuse(mirrored).clean()") + code.append(f" except Exception as e:") + code.append(f" print(f'mirror failed: {{e}}')") + return code + +def _extract_mirror_plane_origin(raw: dict, mirror_plane_info: dict): + mir_origin = raw.get("mirror_plane_origin") + if mir_origin and isinstance(mir_origin, (list, tuple)) and len(mir_origin) >= 3: + return (float(mir_origin[0]), float(mir_origin[1]), float(mir_origin[2])) + origin_list = mirror_plane_info.get("origin_mm") or mirror_plane_info.get("origin") or [] + if origin_list and len(origin_list) >= 3: + return (float(origin_list[0]), float(origin_list[1]), float(origin_list[2])) + frame = mirror_plane_info.get("frame") + if isinstance(frame, dict): + origin_list = frame.get("origin") or [] + if origin_list and len(origin_list) >= 3: + return (float(origin_list[0]), float(origin_list[1]), float(origin_list[2])) + return None + +def _extract_mirror_plane_normal(raw: dict, mirror_plane_info: dict): + mir_normal = raw.get("mirror_plane_normal") + if mir_normal and isinstance(mir_normal, (list, tuple)) and len(mir_normal) >= 3: + return (float(mir_normal[0]), float(mir_normal[1]), float(mir_normal[2])) + normal_list = mirror_plane_info.get("normal") or [] + if normal_list and len(normal_list) >= 3: + return (float(normal_list[0]), float(normal_list[1]), float(normal_list[2])) + frame = mirror_plane_info.get("frame") + if isinstance(frame, dict): + normal_list = frame.get("normal") or [] + if normal_list and len(normal_list) >= 3: + return (float(normal_list[0]), float(normal_list[1]), float(normal_list[2])) + return None + +def _find_operation_for_source_feature( + operations: list[Dict[str, Any]], source_feature: Dict[str, Any] +) -> Optional[Dict[str, Any]]: + source_index = source_feature.get("index") + source_name = source_feature.get("name") + source_identity = source_feature.get("identity") if isinstance(source_feature.get("identity"), dict) else {} + source_stable_id = source_feature.get("stable_id") or source_identity.get("stable_id") + source_persistent_reference = source_feature.get("persistent_reference") or source_identity.get("persistent_reference") + for op in operations: + op_source = op.get("source_feature", {}) + if source_index is not None and op_source.get("index") == source_index: + return op + for op in operations: + op_source = op.get("source_feature", {}) + op_identity = op_source.get("identity") if isinstance(op_source.get("identity"), dict) else {} + if source_stable_id and ( + op_source.get("stable_id") == source_stable_id + or op_identity.get("stable_id") == source_stable_id + ): + return op + if source_persistent_reference and ( + op_source.get("persistent_reference") == source_persistent_reference + or op_identity.get("persistent_reference") == source_persistent_reference + ): + return op + for op in operations: + if source_name and op.get("name") == source_name: + return op + return None + +def _find_source_operation_for_pattern( + operations: list[Dict[str, Any]], source_features: list[Dict[str, Any]] +) -> Optional[Dict[str, Any]]: + for source_feature in source_features: + source_op = _find_operation_for_source_feature(operations, source_feature) + if source_op: + return source_op + return None + +def _linear_pattern_offsets(op: Dict[str, Any]) -> list[tuple[float, float, float]]: + params = op.get("parameters", {}) + raw = op.get("raw_parameters", {}) + explicit_offsets = raw.get("explicit_offsets_mm") + if isinstance(explicit_offsets, list) and explicit_offsets: + return [ + (float(offset[0]), float(offset[1]), float(offset[2])) + for offset in explicit_offsets + if isinstance(offset, list) and len(offset) >= 3 + ] + d1_count = int(raw.get("d1_total_instances") or params.get("total_instances") or 1) + d2_count = int(raw.get("d2_total_instances") or 1) + d1_spacing = float(raw.get("d1_spacing_mm") or params.get("spacing_mm") or 0) + d2_spacing = float(raw.get("d2_spacing_mm") or 0) + d1_vector = _pattern_direction_vector(raw.get("direction1") or params.get("direction1"), d1_spacing) + d2_vector = _pattern_direction_vector(raw.get("direction2") or params.get("direction2"), d2_spacing) + + offsets = [] + for i in range(d1_count): + for j in range(d2_count): + if i == 0 and j == 0: + continue + offsets.append(tuple(d1_vector[k] * i + d2_vector[k] * j for k in range(3))) + return offsets + +def _pattern_direction_vector(direction: Optional[Dict[str, Any]], spacing: float) -> tuple[float, float, float]: + if not direction or not spacing: + return (0.0, 0.0, 0.0) + direct_vector = direction.get("vector") + if isinstance(direct_vector, list) and len(direct_vector) >= 3: + vector = tuple(float(direct_vector[i]) for i in range(3)) + length = math.sqrt(sum(component * component for component in vector)) + if length <= 0: + return (0.0, 0.0, 0.0) + return tuple(component / length * spacing for component in vector) + start = direction.get("start", {}).get("mm") + end = direction.get("end", {}).get("mm") + if not start or not end: + return (0.0, 0.0, 0.0) + vector = tuple(float(end[i]) - float(start[i]) for i in range(3)) + length = math.sqrt(sum(component * component for component in vector)) + if length <= 0: + return (0.0, 0.0, 0.0) + return tuple(component / length * spacing for component in vector) + +def _translated_operation(op: Dict[str, Any], offset: tuple[float, float, float]) -> Dict[str, Any]: + copied = deepcopy(op) + params = copied.get("parameters") or {} + axis_reference = params.get("axis_reference") + if isinstance(axis_reference, dict) and isinstance(axis_reference.get("origin_mm"), list): + origin = list(axis_reference.get("origin_mm") or [0, 0, 0]) + origin = (origin + [0, 0, 0])[:3] + axis_reference["origin_mm"] = [float(origin[i]) + float(offset[i]) for i in range(3)] + + if copied.get("type") == "hole": + positions = params.get("positions") or [] + local_offset = _model_offset_to_host_local(offset, params.get("host_face") or {}) + for position in positions: + if position.get("mm"): + point = list(position.get("mm") or [0, 0, 0]) + point = (point + [0, 0, 0])[:3] + position["mm"] = [ + float(point[0]) + local_offset[0], + float(point[1]) + local_offset[1], + float(point[2]) + local_offset[2], + ] + if position.get("m"): + position["m"] = [value / 1000 for value in position.get("mm", [])] + if any(abs(float(offset[i])) > 1e-9 for i in range(3)): + owned_faces = copied.get("source_owned_faces") or [] + if owned_faces: + copied["source_owned_faces"] = _translate_owned_faces(owned_faces, offset) + return copied + +def _translate_owned_faces( + faces: list[Dict[str, Any]], + offset: tuple[float, float, float], +) -> list[Dict[str, Any]]: + translated = [] + shift_mm = (float(offset[0]), float(offset[1]), float(offset[2])) + shift_m = (shift_mm[0] / 1000.0, shift_mm[1] / 1000.0, shift_mm[2] / 1000.0) + for face in faces: + if not isinstance(face, dict): + continue + copied = deepcopy(face) + box = copied.get("box_m") + if isinstance(box, list) and len(box) >= 6: + copied["box_m"] = [ + float(box[0]) + shift_m[0], + float(box[1]) + shift_m[1], + float(box[2]) + shift_m[2], + float(box[3]) + shift_m[0], + float(box[4]) + shift_m[1], + float(box[5]) + shift_m[2], + ] + surface = copied.get("surface") + if isinstance(surface, dict): + for key in ("cylinder_params", "cone_params"): + params = surface.get(key) + if isinstance(params, list) and len(params) >= 3: + updated = list(params) + updated[0] = float(updated[0]) + shift_m[0] + updated[1] = float(updated[1]) + shift_m[1] + updated[2] = float(updated[2]) + shift_m[2] + surface[key] = updated + translated.append(copied) + return translated + +def _model_offset_to_host_local( + offset: tuple[float, float, float], + host_face: Dict[str, Any], +) -> tuple[float, float, float]: + frame = host_face.get("frame") if isinstance(host_face, dict) else {} + if not isinstance(frame, dict): + return offset + x_dir = frame.get("x_dir") + y_dir = frame.get("y_dir") + if not ( + isinstance(x_dir, list) + and len(x_dir) >= 3 + and isinstance(y_dir, list) + and len(y_dir) >= 3 + ): + return offset + local_x = sum(float(offset[i]) * float(x_dir[i]) for i in range(3)) + local_y = sum(float(offset[i]) * float(y_dir[i]) for i in range(3)) + return (local_x, local_y, 0.0) + +def _translated_sketch( + sketch: Dict[str, Any], offset: tuple[float, float, float], sketch_id: str +) -> Dict[str, Any]: + copied = deepcopy(sketch) + copied["id"] = sketch_id + copied["name"] = f"{sketch.get('name', sketch_id)} pattern copy" + workplane = copied.setdefault("workplane", {}) + origin = list(workplane.get("origin_mm") or [0, 0, 0]) + origin = (origin + [0, 0, 0])[:3] + workplane["origin_mm"] = [float(origin[i]) + float(offset[i]) for i in range(3)] + return copied + +def _translate_sketch_entities(sketch: Dict[str, Any], offset: tuple[float, float, float]) -> None: + dx, dy = offset[0], offset[1] + for entity in sketch.get("entities", []): + for key in ("start", "end", "center"): + point = entity.get(key) + if isinstance(point, list) and len(point) >= 2: + point[0] = float(point[0]) + dx + point[1] = float(point[1]) + dy + raw = entity.get("raw", {}) + for key in ("start", "end", "center"): + raw_point = raw.get(key) + if isinstance(raw_point, dict): + mm = raw_point.get("mm") + if isinstance(mm, list) and len(mm) >= 2: + mm[0] = float(mm[0]) + dx + mm[1] = float(mm[1]) + dy + raw_point["m"] = [value / 1000 for value in mm] + +def _hole_diameter_mm(params: Dict[str, Any]) -> float: + if params.get("diameter_mm"): + return float(params["diameter_mm"]) + diameters = params.get("diameters_m", {}) + for key in ( + "hole_diameter", + "thru_hole_diameter", + "tap_drill_diameter", + "thru_tap_drill_diameter", + "thread_diameter", + "diameter", + ): + value = diameters.get(key) + if value: + return float(value) * 1000 + return 0 + +def _hole_depth_mm(params: Dict[str, Any]) -> float: + if params.get("depth_mm"): + return float(params["depth_mm"]) + depths = params.get("depths_m", {}) + for key in ( + "hole_depth", + "thru_hole_depth", + "tap_drill_depth", + "thru_tap_drill_depth", + "thread_depth", + "depth", + ): + value = depths.get(key) + if value: + return float(value) * 1000 + return THROUGH_CUT_AMOUNT_MM + +def _hole_drill_angle_rad(params: Dict[str, Any]) -> float: + angle = params.get("angles_rad", {}).get("drill_angle") + return float(angle) if angle else 0 + +def _hole_countersink_angle_rad(params: Dict[str, Any]) -> float: + angle = params.get("angles_rad", {}).get("countersink_angle") + return float(angle) if angle else 0 + +def _hole_countersink_diameter_mm(params: Dict[str, Any]) -> float: + diameter = params.get("countersink_diameter_mm") + return float(diameter) if diameter else 0 + +def _hole_counterbore_diameter_mm(params: Dict[str, Any]) -> float: + diameter = params.get("counterbore_diameter_mm") + return float(diameter) if diameter else 0 + +def _hole_counterbore_depth_mm(params: Dict[str, Any]) -> float: + depth = params.get("counterbore_depth_mm") + return float(depth) if depth else 0 + +def _hole_has_drill_tip(params: Dict[str, Any]) -> bool: + depths = params.get("depths_m", {}) + angle = _hole_drill_angle_rad(params) + if angle <= 0: + return False + through_depth_keys = ( + "thru_hole_depth", + "thru_tap_drill_depth", + ) + if any(depths.get(key) for key in through_depth_keys): + return False + if params.get("depth_mm"): + return True + return any(depths.get(key) for key in ("hole_depth", "tap_drill_depth", "depth")) + +def _hole_has_through_dimension(params: Dict[str, Any]) -> bool: + names = " ".join(str(name).lower() for name in params.get("dimension_names", []) or []) + return any(token in names for token in ("通孔", "through", "thru")) diff --git a/backend/engine/cdsl_engine/translator/common.py b/backend/engine/cdsl_engine/translator/common.py new file mode 100644 index 00000000..728e9827 --- /dev/null +++ b/backend/engine/cdsl_engine/translator/common.py @@ -0,0 +1,194 @@ +"""Shared helpers for the SW-IR and code-generation sides of the translator. + +These small utilities are used by both ``ir`` (SolidWorks plugin JSON to +backend IR) and ``codegen`` (backend IR to build123d source). Anything used +by exactly one side lives in that side's module instead. +""" + +from __future__ import annotations + +import math +from typing import Any, Optional + +#: SolidWorks numeric end-condition codes, shared by IR conversion and codegen. +SW_END_CONDITIONS = { + 0: "Blind", + 1: "ThroughAll", + 2: "ThroughAllBoth", + 3: "UpToVertex", + 4: "UpToSurface", + 5: "OffsetFromSurface", + 6: "ThroughAllAndBlind", + 7: "UpToBody", + 8: "MidPlane", + 9: "ThroughNext", +} + +#: Generous through-cut length used when a termination reference is missing. +THROUGH_CUT_AMOUNT_MM = 200 + + +def _tuple3(values: Any) -> tuple[float, float, float]: + values = list(values or [0, 0, 0]) + values = (values + [0, 0, 0])[:3] + return tuple(values) + + +def _point_m_to_mm(point: Any) -> tuple[float, float, float]: + values = list(point or [0, 0, 0]) + values = (values + [0, 0, 0])[:3] + return tuple(float(value) * 1000 for value in values) + + +def _scale_point(point: Any) -> list[float]: + values = [0 if value is None else float(value) for value in (point or [0, 0])] + return [_scale_length(value) for value in values[:2]] + + +def _scale_length(value: Any) -> float: + value = 0 if value is None else float(value) + return value * 1000 if abs(value) <= 10 else value + + +def _to_degrees(value: Any) -> float: + value = 0 if value is None else float(value) + return value * 180 / 3.141592653589793 if abs(value) <= 6.283185307179586 else value + + +def _unit3(vector: list[Any]) -> list[float]: + raw = [float(vector[i]) for i in range(3)] + length = math.sqrt(sum(v * v for v in raw)) + if length <= 0: + return [0.0, 0.0, 0.0] + return [v / length for v in raw] + + +def _points_bbox(points: list[list[float]]) -> Optional[list[float]]: + if not points: + return None + return [ + min(point[0] for point in points), + min(point[1] for point in points), + min(point[2] for point in points), + max(point[0] for point in points), + max(point[1] for point in points), + max(point[2] for point in points), + ] + + +def _point_key(point: Any, places: int = 5) -> tuple[float, float] | None: + if not isinstance(point, list) or len(point) < 2: + return None + return (round(float(point[0]), places), round(float(point[1]), places)) + + +def _rounded_point_key(point: list[Any], digits: int = 5) -> tuple[float, float, float]: + z = point[2] if len(point) > 2 else 0 + return (round(float(point[0]), digits), round(float(point[1]), digits), round(float(z), digits)) + + +def _dedupe_points(points: list[list[float]]) -> list[list[float]]: + result = [] + seen = set() + for point in points: + key = _rounded_point_key(point) + if key in seen: + continue + seen.add(key) + result.append(point) + return result + + +def _is_near_origin(point: list[float], tolerance: float = 1e-6) -> bool: + return math.sqrt(sum(float(component) * float(component) for component in point[:3])) <= tolerance + + +def _similar_bbox_size(a: list[float], b: list[float], tolerance: float = 0.05) -> bool: + return all(abs(float(a[i]) - float(b[i])) <= tolerance for i in range(3)) + + +def _translated_bbox(bbox: list[float], offset: list[float]) -> list[float]: + return [ + bbox[0] + offset[0], + bbox[1] + offset[1], + bbox[2] + offset[2], + bbox[3] + offset[0], + bbox[4] + offset[1], + bbox[5] + offset[2], + ] + + +def _bbox_overflow_score(candidate: list[float], source: list[float]) -> float: + score = 0.0 + for axis in range(3): + score += max(source[axis] - candidate[axis], 0) + score += max(candidate[axis + 3] - source[axis + 3], 0) + return score + + +def _bbox_center_distance_score(candidate: list[float], source: list[float]) -> float: + score = 0.0 + for axis in range(3): + source_center = (source[axis] + source[axis + 3]) / 2 + candidate_center = (candidate[axis] + candidate[axis + 3]) / 2 + axis_size = max(source[axis + 3] - source[axis], 1.0) + score += abs(candidate_center - source_center) / axis_size + return score + + +def _bbox_area_2d(bbox: Optional[list[float]]) -> float: + if not isinstance(bbox, list) or len(bbox) < 4: + return 0.0 + return max(0.0, float(bbox[2]) - float(bbox[0])) * max(0.0, float(bbox[3]) - float(bbox[1])) + + +def _bbox_contains_2d(outer: Optional[list[float]], inner: Optional[list[float]], tolerance: float = 1e-6) -> bool: + if not isinstance(outer, list) or not isinstance(inner, list) or len(outer) < 4 or len(inner) < 4: + return False + return ( + float(outer[0]) <= float(inner[0]) + tolerance + and float(outer[1]) <= float(inner[1]) + tolerance + and float(outer[2]) >= float(inner[2]) - tolerance + and float(outer[3]) >= float(inner[3]) - tolerance + ) + + +def _bbox_overlap_ratio_2d(a: Optional[list[float]], b: Optional[list[float]]) -> float: + if not isinstance(a, list) or not isinstance(b, list) or len(a) < 4 or len(b) < 4: + return 0.0 + ix0 = max(float(a[0]), float(b[0])) + iy0 = max(float(a[1]), float(b[1])) + ix1 = min(float(a[2]), float(b[2])) + iy1 = min(float(a[3]), float(b[3])) + intersection = max(0.0, ix1 - ix0) * max(0.0, iy1 - iy0) + smaller = min(_bbox_area_2d(a), _bbox_area_2d(b)) + if smaller <= 1e-9: + return 0.0 + return intersection / smaller + + +def _loop_bbox(entities: list[dict[str, Any]]) -> Optional[list[float]]: + points = [] + for ent in entities: + if not isinstance(ent, dict): + continue + if ent.get("type") == "circle": + center = ent.get("center") + radius = ent.get("radius_mm") + if isinstance(center, list) and len(center) >= 2 and radius is not None: + radius_value = abs(float(radius)) + points.append([float(center[0]) - radius_value, float(center[1]) - radius_value]) + points.append([float(center[0]) + radius_value, float(center[1]) + radius_value]) + continue + for key in ("start", "end", "center"): + point = ent.get(key) + if isinstance(point, list) and len(point) >= 2: + points.append(point) + if not points: + return None + return [ + min(float(point[0]) for point in points), + min(float(point[1]) for point in points), + max(float(point[0]) for point in points), + max(float(point[1]) for point in points), + ] diff --git a/backend/engine/cdsl_engine/translator/ir.py b/backend/engine/cdsl_engine/translator/ir.py new file mode 100644 index 00000000..6517d727 --- /dev/null +++ b/backend/engine/cdsl_engine/translator/ir.py @@ -0,0 +1,1959 @@ +"""SolidWorks plugin JSON to backend-IR conversion.""" + +from __future__ import annotations + +import json +import math +import os +from copy import deepcopy +from typing import Any, Dict, Optional + +from .common import ( + SW_END_CONDITIONS, + THROUGH_CUT_AMOUNT_MM, + _point_m_to_mm, + _scale_point, + _scale_length, + _to_degrees, + _unit3, + _points_bbox, + _rounded_point_key, + _dedupe_points, + _is_near_origin, + _similar_bbox_size, + _translated_bbox, + _bbox_overflow_score, + _bbox_center_distance_score, + _bbox_area_2d, + _loop_bbox, +) + + +def normalize_to_ir(data: Dict[str, Any]) -> Dict[str, Any]: + """Normalize supported input formats to the backend internal IR.""" + if "operations" in data and "sketches" in data: + return enrich_rebuild_parameters(data) + + if "features" in data: + return enrich_rebuild_parameters(convert_sw_plugin_json_to_ir(data)) + + raise ValueError("Unsupported JSON format: expected internal IR or SW plugin features JSON") + +def enrich_rebuild_parameters(data: Dict[str, Any]) -> Dict[str, Any]: + """Add a generic editable-parameter index without changing feature history. + + The returned rebuild JSON remains the source of truth for execution. The + `editable_parameters` section is an index of JSON paths that a UI or caller + can modify safely while preserving the original feature order and links. + """ + enriched = dict(data) + enriched["editable_parameters"] = extract_editable_parameters(enriched) + enriched["parameterization_status"] = analyze_parameterization_status(enriched) + return enriched + +def analyze_parameterization_status(data: Dict[str, Any]) -> Dict[str, Any]: + issues = [] + + for sketch in data.get("sketches", []): + host_reference = sketch.get("host_reference", {}) + reference = host_reference.get("reference") or {} + if reference.get("kind") == "face" and not reference.get("owner_feature"): + issues.append({ + "kind": "missing_stable_face_owner", + "sketch": {"id": sketch.get("id"), "name": sketch.get("name")}, + "message": ( + "Sketch is attached to a face geometry, but the JSON does not identify " + "the owning feature/face id. Parameter edits may require updating this " + "sketch workplane manually unless the plugin exports stable face ownership." + ), + }) + + for op in data.get("operations", []): + if op.get("type") in ("unsupported", "unknown"): + sw_type = op.get("parameters", {}).get("sw_type") or op.get("type") + issues.append({ + "kind": "unsupported_geometry_feature", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": sw_type}, + "message": ( + f"SolidWorks feature '{sw_type}' is present in the history, but the " + "core build123d translator has no generic implementation for it. " + "The feature is retained in IR and must not be treated as a complete rebuild." + ), + }) + + if op.get("type") == "hole": + host_face = op.get("parameters", {}).get("host_face") or {} + if host_face and not host_face.get("frame"): + issues.append({ + "kind": "missing_hole_host_frame", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "message": ( + "Hole feature has a host face, but the JSON does not include the " + "face-local x/y axes. The translator can infer common axis-aligned " + "cases, but the plugin should export the sketch/face frame for exact " + "generic hole placement." + ), + }) + + if op.get("type") == "extrude_cut": + end_code = op.get("parameters", {}).get("end_condition_code") + if end_code in (3, 4, 5, 7, 9): + params = op.get("parameters", {}) + has_termination_reference = any( + params.get(key) + for key in ( + "end_condition_reference", + "reverse_end_condition_reference", + "termination_reference", + ) + ) + kind = ( + "sw_end_condition_requires_exact_translator" + if has_termination_reference + else "missing_extrude_termination_reference" + ) + issues.append({ + "kind": kind, + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "end_condition_code": end_code, + "end_condition": SW_END_CONDITIONS.get(end_code), + "message": ( + "This SW cut uses a non-blind end condition. ThroughAll can be " + "replayed generically, but ThroughNext/UpTo-style rebuilds need the " + "selected terminating face/body/reference from the plugin for exact 1:1." + ), + }) + + if op.get("type") in ("revolve_cut", "revolve_add"): + axis_reference = op.get("parameters", {}).get("axis_reference") + if not axis_reference or not ( + isinstance(axis_reference, dict) + and axis_reference.get("origin_mm") + and axis_reference.get("direction") + ): + axis_candidates = op.get("parameters", {}).get("axis_candidates") or [] + if axis_candidates: + issues.append({ + "kind": "revolve_axis_inferred_from_candidate", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "message": ( + "Revolve feature lacks the original SolidWorks selected axis, but " + "the translator can use a construction-line candidate. For exact " + "auditability the plugin should still export the selected axis " + "reference and selection mark." + ), + }) + continue + issues.append({ + "kind": "missing_revolve_axis_reference", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "message": ( + "Revolve feature does not include the SolidWorks selected axis. " + "The translator can only infer an axis from the sketch workplane, " + "which is not reliable enough for exact 1:1 rebuild." + ), + }) + + if op.get("type") in ("linear_pattern", "pattern_linear"): + params = op.get("parameters", {}) + if not params.get("source_features") or not _linear_pattern_offsets(op): + issues.append({ + "kind": "linear_pattern_missing_source_or_direction", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "message": ( + "This SW linear pattern lacks source-feature selection or direction data. " + "The translator can replay patterns when source features and offsets are " + "available; otherwise the plugin should export the selected feature list " + "and pattern direction references." + ), + }) + + if op.get("type") in ("fillet", "chamfer"): + selectors = op.get("selectors") or [] + if selectors and not any(_selector_has_persistent_reference(selector) for selector in selectors): + issues.append({ + "kind": "missing_original_feature_selection", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "message": ( + "This feature only has final-geometry edge signatures. For exact replay " + "the plugin should export the original SolidWorks feature selections " + "including persistent references and selection marks." + ), + }) + + return { + "safe_to_edit": not issues, + "issues": issues, + } + +def _selector_has_persistent_reference(selector: Dict[str, Any]) -> bool: + stack = [selector] + while stack: + value = stack.pop() + if isinstance(value, dict): + if value.get("persistent_reference"): + return True + stack.extend(value.values()) + elif isinstance(value, list): + stack.extend(value) + return False + +def extract_editable_parameters(data: Dict[str, Any]) -> list[Dict[str, Any]]: + parameters: list[Dict[str, Any]] = [] + sketches = {sketch.get("id"): sketch for sketch in data.get("sketches", [])} + + for op_index, op in enumerate(data.get("operations", [])): + op_type = op.get("type", "") + op_name = op.get("name", op.get("id", f"operation_{op_index}")) + op_path = f"/operations/{op_index}" + params = op.get("parameters", {}) + + if op_type in ("extrude_add", "extrude_cut") and "distance_mm" in params: + semantic = "body_length" if op_type == "extrude_add" else "cut_depth" + parameters.append(_editable_param( + id=f"{op.get('id', op_index)}.distance_mm", + label=f"{op_name} distance", + semantic=semantic, + unit="mm", + value=params.get("distance_mm"), + path=f"{op_path}/parameters/distance_mm", + feature=op, + )) + + if "reverse_distance_mm" in params: + parameters.append(_editable_param( + id=f"{op.get('id', op_index)}.reverse_distance_mm", + label=f"{op_name} reverse distance", + semantic="reverse_depth", + unit="mm", + value=params.get("reverse_distance_mm"), + path=f"{op_path}/parameters/reverse_distance_mm", + feature=op, + )) + + if op_type in ("fillet", "chamfer"): + key = "radius_mm" if op_type == "fillet" else "distance_mm" + if key in params: + parameters.append(_editable_param( + id=f"{op.get('id', op_index)}.{key}", + label=f"{op_name} {key}", + semantic="fillet_radius" if op_type == "fillet" else "chamfer_distance", + unit="mm", + value=params.get(key), + path=f"{op_path}/parameters/{key}", + feature=op, + )) + + sketch_id = op.get("sketch") + sketch = sketches.get(sketch_id) + if sketch: + parameters.extend(_extract_sketch_parameters(sketch, sketch_id, op, op_index, data)) + + return parameters + +def _extract_sketch_parameters( + sketch: Dict[str, Any], + sketch_id: str, + op: Dict[str, Any], + op_index: int, + data: Dict[str, Any], +) -> list[Dict[str, Any]]: + parameters: list[Dict[str, Any]] = [] + sketch_index = next((i for i, item in enumerate(data.get("sketches", [])) if item.get("id") == sketch_id), None) + if sketch_index is None: + return parameters + + op_type = op.get("type", "") + entities = sketch.get("entities", []) + drawable = [entity for entity in entities if not entity.get("construction", False)] + + for entity_index, entity in enumerate(entities): + entity_type = entity.get("type") + entity_path = f"/sketches/{sketch_index}/entities/{entity_index}" + + if entity_type in ("circle", "arc") and entity.get("is_circle", entity_type == "circle"): + center = entity.get("center", [0, 0, 0]) + radius = entity.get("radius_mm") + semantic = "hole" if op_type == "extrude_cut" else "circle_profile" + if radius is not None: + parameters.append(_editable_param( + id=f"{sketch_id}.entity{entity_index}.radius_mm", + label=f"{sketch.get('name', sketch_id)} circle radius", + semantic=f"{semantic}_radius", + unit="mm", + value=radius, + path=f"{entity_path}/radius_mm", + feature=op, + )) + for axis, value in zip(("x", "y"), center[:2]): + parameters.append(_editable_param( + id=f"{sketch_id}.entity{entity_index}.center_{axis}", + label=f"{sketch.get('name', sketch_id)} {semantic} center {axis}", + semantic=f"{semantic}_center_{axis}", + unit="mm", + value=value, + path=f"{entity_path}/center/{0 if axis == 'x' else 1}", + feature=op, + )) + + bounds = _sketch_bounds(drawable) + if bounds: + min_x, min_y, max_x, max_y = bounds + center_x = (min_x + max_x) / 2 + center_y = (min_y + max_y) / 2 + width = max_x - min_x + height = max_y - min_y + semantic_prefix = "slot" if op_type == "extrude_cut" else "profile" + for suffix, value, semantic in ( + ("center_x", center_x, f"{semantic_prefix}_center_x"), + ("center_y", center_y, f"{semantic_prefix}_center_y"), + ("width", width, f"{semantic_prefix}_width"), + ("height", height, f"{semantic_prefix}_height"), + ): + parameters.append(_editable_param( + id=f"{sketch_id}.{suffix}", + label=f"{sketch.get('name', sketch_id)} {suffix}", + semantic=semantic, + unit="mm", + value=value, + path=f"/sketches/{sketch_index}", + feature=op, + editable=False, + note="Derived from sketch entity bounds; edit underlying entities to change this safely.", + )) + + workplane = sketch.get("workplane", {}) + origin = workplane.get("origin_mm") + if origin: + for axis, value in zip(("x", "y", "z"), origin[:3]): + parameters.append(_editable_param( + id=f"{sketch_id}.workplane_origin_{axis}", + label=f"{sketch.get('name', sketch_id)} workplane origin {axis}", + semantic=f"sketch_plane_origin_{axis}", + unit="mm", + value=value, + path=f"/sketches/{sketch_index}/workplane/origin_mm/{'xyz'.index(axis)}", + feature=op, + )) + + return parameters + +def _sketch_bounds(entities: list[Dict[str, Any]]) -> Optional[tuple[float, float, float, float]]: + points: list[tuple[float, float]] = [] + for entity in entities: + for key in ("start", "end", "center"): + point = entity.get(key) + if point and len(point) >= 2: + points.append((float(point[0]), float(point[1]))) + radius = entity.get("radius_mm") + center = entity.get("center") + if radius is not None and center and len(center) >= 2: + cx, cy = float(center[0]), float(center[1]) + r = float(radius) + points.extend([(cx - r, cy - r), (cx + r, cy + r)]) + if not points: + return None + xs = [point[0] for point in points] + ys = [point[1] for point in points] + return min(xs), min(ys), max(xs), max(ys) + +def _editable_param( + *, + id: str, + label: str, + semantic: str, + unit: str, + value: Any, + path: str, + feature: Dict[str, Any], + editable: bool = True, + note: Optional[str] = None, +) -> Dict[str, Any]: + result = { + "id": id, + "label": label, + "semantic": semantic, + "unit": unit, + "value": value, + "path": path, + "editable": editable, + "feature": { + "id": feature.get("id"), + "name": feature.get("name"), + "type": feature.get("type"), + "source_index": feature.get("source_feature", {}).get("index"), + }, + } + if note: + result["note"] = note + return result + +def convert_sw_plugin_json_to_ir(data: Dict[str, Any]) -> Dict[str, Any]: + """Convert the current SW plugin feature dump into the backend IR.""" + features = data.get("features", []) + sketches = [] + operations = [] + last_sketch_id = None + last_build_op = None + references = [] + source_bbox = _source_bbox_from_plugin_json(data) + + if data.get("document_kind") == "assembly" and isinstance(data.get("assembly_data"), dict): + operations.append(_convert_sw_assembly(data)) + part_name = data.get("part_name", "part") + return { + "version": "ir-0.1", + "metadata": { + "source": { + "format": "sw-plugin-json", + "file_name": f"{part_name}.sldasm", + "sw_version": data.get("sw_version"), + } + }, + "sketches": sketches, + "operations": operations, + "references": references, + "validation_hints": data.get("validation_hints", {}), + "geometry_inventory": data.get("geometry_inventory", {}), + "rebuild_contract": data.get("rebuild_contract", {}), + } + + for index, feature in enumerate(features): + if feature.get("is_suppressed"): + continue + + feature_type = feature.get("type", "") + type_name = feature.get("type_name", "") + feature_id = feature.get("id") or f"feat_{index:03d}" + feature_name = feature.get("name", feature_id) + + if feature_type in ("refplane", "refaxis"): + references.append(_convert_sw_reference(feature, index)) + elif feature_type == "sketch": + sketch_id = f"sketch_{len(sketches):03d}" + sketches.append(_convert_sw_sketch(feature, sketch_id, index)) + last_sketch_id = sketch_id + elif feature_type in ("extrude", "ice", "cut") and isinstance(feature.get("extrude_data"), dict): + sketch_ref = _append_feature_source_sketches(feature, sketches, index) or last_sketch_id + op = _convert_sw_extrude(feature, type_name, sketch_ref, index) + operations.append(op) + last_build_op = op + elif feature_type == "revolve": + sketch_ref = _append_feature_source_sketches(feature, sketches, index) or last_sketch_id + op = _convert_sw_revolve(feature, type_name, sketch_ref, index) + operations.append(op) + last_build_op = op + elif feature_type == "hole": + op = _convert_sw_hole(feature, index) + operations.append(op) + last_build_op = op + elif feature_type == "pattern_linear": + data_block = feature.get("linear_pattern_data", {}) + source_op = _find_source_operation_for_pattern(operations, data_block.get("source_features") or []) + source_frame = _source_pattern_frame(source_op or last_build_op, sketches) + operations.append(_convert_sw_linear_pattern(feature, index, source_op or last_build_op, source_frame, sketches, source_bbox)) + elif feature_type == "pattern_mirror": + data_block = feature.get("mirror_data") or {} + src_features = data_block.get("source_features") or [] + mirror_origin = data_block.get("mirror_plane_origin") + mirror_normal = data_block.get("mirror_plane_normal") + operations.append({ + "id": feature_id, + "name": feature_name, + "type": "pattern_mirror", + "parameters": {"source_features": src_features}, + "raw_parameters": { + "mirror_plane_origin": mirror_origin, + "mirror_plane_normal": mirror_normal, + }, + "source_feature": _source_feature(feature, index), + }) + elif feature_type == "fillet": + data_block = feature.get("fillet_data", {}) + radius_mm = data_block.get("radius") or _feature_length_dimension_mm(feature) + operations.append({ + "id": feature_id, + "name": feature_name, + "type": "fillet", + "parameters": {"radius_mm": radius_mm}, + "selectors": _feature_selection_selectors(feature, data_block), + "selection_source": _feature_selection_source(feature, data_block), + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + }) + elif feature_type == "chamfer": + data_block = feature.get("chamfer_data", {}) + distance_mm = data_block.get("distance") or _feature_length_dimension_mm(feature) + operations.append({ + "id": feature_id, + "name": feature_name, + "type": "chamfer", + "parameters": {"distance_mm": distance_mm}, + "selectors": _feature_selection_selectors(feature, data_block), + "selection_source": _feature_selection_source(feature, data_block), + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + }) + elif _is_imported_body_feature(feature): + op = _convert_sw_imported_body(feature, index) + operations.append(op) + last_build_op = op + elif feature_type == "moveface": + data_block = feature.get("move_face_data") if isinstance(feature.get("move_face_data"), dict) else {} + op = { + "id": feature_id, + "name": feature_name, + "type": "move_face", + "parameters": {"sw_type": type_name or feature_type, "move_face_data": data_block}, + "source_feature": _source_feature(feature, index), + } + operations.append(op) + last_build_op = op + elif feature_type not in _SW_METADATA_FEATURE_TYPES: + operations.append({ + "id": feature_id, + "name": feature_name, + "type": "unsupported", + "parameters": {"sw_type": type_name or feature_type}, + "source_feature": _source_feature(feature, index), + }) + + part_name = data.get("part_name", "part") + return { + "version": "ir-0.1", + "metadata": { + "source": { + "format": "sw-plugin-json", + "file_name": f"{part_name}.sldprt", + "sw_version": data.get("sw_version"), + } + }, + "sketches": sketches, + "operations": operations, + "references": references, + "validation_hints": data.get("validation_hints", {}), + "geometry_inventory": data.get("geometry_inventory", {}), + "rebuild_contract": data.get("rebuild_contract", {}), + } + +def _is_imported_body_feature(feature: Dict[str, Any]) -> bool: + feature_type = str(feature.get("type") or "").lower() + type_name = str(feature.get("type_name") or "").lower() + return bool(feature.get("imported_body_data")) or feature_type in { + "mbimport", + "savedextbody", + "importedbody", + "imported", + "stock", + } or type_name in {"mbimport", "savedextbody", "importedbody"} + +def _convert_sw_imported_body(feature: Dict[str, Any], index: int) -> Dict[str, Any]: + data_block = feature.get("imported_body_data") if isinstance(feature.get("imported_body_data"), dict) else {} + solid_bodies = data_block.get("solid_bodies") or [] + solid_body_stats = data_block.get("solid_body_stats") or [] + source_name = feature.get("name") + parameters = { + "sw_type": feature.get("type_name") or feature.get("type"), + "source_name": source_name, + "history_status": data_block.get("history_status"), + "body_count": len(solid_bodies) if isinstance(solid_bodies, list) else len(solid_body_stats), + "solid_body_stats": solid_body_stats, + "solid_bodies": solid_bodies, + } + return { + "id": feature.get("id") or f"feat_{index:03d}", + "name": feature.get("name") or f"imported_body_{index:03d}", + "type": "imported_body", + "parameters": parameters, + "source_feature": _source_feature(feature, index), + "source_imported_body": data_block, + } + +def _convert_sw_assembly(data: Dict[str, Any]) -> Dict[str, Any]: + assembly_data = data.get("assembly_data") or {} + components = [] + for index, component in enumerate(assembly_data.get("components") or []): + if component.get("is_suppressed") or component.get("is_hidden"): + continue + path = component.get("path") or "" + component_name = component.get("name") or f"component_{index:03d}" + base_name = os.path.splitext(os.path.basename(str(path).replace("\\", "/")))[0] or component_name + components.append({ + "index": index, + "name": component_name, + "component_id": base_name, + "source_path": path, + "component_json": f"{base_name}.solidworks_rebuild_extract.json", + "transform": component.get("transform") or {}, + }) + return { + "id": "assembly_000", + "name": data.get("part_name") or "assembly", + "type": "assembly_compose", + "parameters": { + "components": components, + }, + "source_feature": {"index": 0, "name": data.get("part_name"), "type": "assembly"}, + } + +def _append_feature_source_sketches(feature: Dict[str, Any], sketches: list[Dict[str, Any]], index: int) -> Optional[str]: + """Promote feature-owned SW sketches into the rebuild sketch table.""" + source_sketches = [] + for block_name in ("extrude_data", "revolve_data"): + block = feature.get(block_name) + if isinstance(block, dict): + source_sketches.extend(sketch for sketch in (block.get("source_sketches") or []) if isinstance(sketch, dict)) + + if not source_sketches: + return None + + last_id = None + for sketch_data in source_sketches: + sketch_id = f"sketch_{len(sketches):03d}" + sketch_feature = dict(feature) + sketch_feature["sketch_data"] = sketch_data + if sketch_data.get("name"): + sketch_feature["name"] = sketch_data.get("name") + sketches.append(_convert_sw_sketch(sketch_feature, sketch_id, index)) + last_id = sketch_id + return last_id + +def _hole_dimension_value(data_block: Dict[str, Any], tokens: tuple[str, ...]) -> Optional[float]: + for dim in data_block.get("dimensions", []) or []: + name = str(dim.get("name") or "").lower() + if all(token.lower() in name for token in tokens) and dim.get("value") not in (None, ""): + return float(dim.get("value")) + return None + +def _feature_length_dimension_mm(feature: Dict[str, Any]) -> Optional[float]: + candidates: list[tuple[int, float]] = [] + for dim in feature.get("dimensions") or []: + if not isinstance(dim, dict): + continue + name = str(dim.get("name") or "") + system_value = dim.get("system_value_m") + if system_value not in (None, ""): + length_mm = abs(float(system_value)) * 1000 + elif dim.get("value") not in (None, ""): + length_mm = abs(float(dim.get("value"))) + else: + continue + if length_mm <= 1e-9 or length_mm > 500: + continue + priority = 0 if name.startswith("D1@") else 1 + candidates.append((priority, length_mm)) + if not candidates: + return None + candidates.sort(key=lambda item: (item[0], item[1])) + return candidates[0][1] + +def _feature_selection_selectors( + feature: Dict[str, Any], + data_block: Optional[Dict[str, Any]] = None, +) -> list[Dict[str, Any]]: + selectors: list[Dict[str, Any]] = [] + seen: set[str] = set() + sources = [] + if isinstance(data_block, dict): + sources.extend(data_block.get("selections") or []) + sources.extend(feature.get("selections") or []) + + for selection in sources: + if not isinstance(selection, dict) or selection.get("kind") != "selection": + continue + geometry = selection.get("object") + if not isinstance(geometry, dict): + continue + kind = geometry.get("kind") + if kind not in ("edge", "face"): + continue + identity = geometry.get("identity") if isinstance(geometry.get("identity"), dict) else {} + stable_key = ( + geometry.get("stable_id") + or geometry.get("persistent_reference") + or identity.get("stable_id") + or identity.get("persistent_reference") + or json.dumps(geometry, sort_keys=True, ensure_ascii=False, default=str) + ) + if stable_key in seen: + continue + seen.add(str(stable_key)) + selectors.append({ + "kind": kind, + "geometry": geometry, + "mark": selection.get("mark"), + "source_feature": { + "name": selection.get("feature_name"), + "type_name": selection.get("feature_type_name"), + }, + }) + if selectors: + return selectors + + for face in feature.get("owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + cylinder_params = surface.get("cylinder_params") + if not (surface.get("is_cylinder") and isinstance(cylinder_params, list) and len(cylinder_params) >= 7): + continue + radius_mm = abs(float(cylinder_params[6]) * 1000) + line_params = [ + float(cylinder_params[0]), + float(cylinder_params[1]), + float(cylinder_params[2]), + float(cylinder_params[3]), + float(cylinder_params[4]), + float(cylinder_params[5]), + ] + stable_key = f"owned_cylinder:{','.join(f'{value:.9g}' for value in line_params)}:{radius_mm:.6g}" + if stable_key in seen: + continue + seen.add(stable_key) + selectors.append({ + "kind": "edge", + "geometry": { + "kind": "edge", + "curve": { + "kind": "curve", + "is_line": True, + "line_params": line_params, + }, + "bbox_mm": [float(value) * 1000 for value in face.get("box_m", [])[:6]] + if isinstance(face.get("box_m"), list) and len(face.get("box_m")) >= 6 + else None, + }, + "tolerance_mm": max(0.5, radius_mm * 2.5), + "source": "owned_cylindrical_face_axis", + }) + for face in feature.get("owned_faces") or []: + if not isinstance(face, dict): + continue + box_m = face.get("box_m") + if not (isinstance(box_m, list) and len(box_m) >= 6): + continue + bbox_mm = [float(value) * 1000 for value in box_m[:6]] + if any(not math.isfinite(value) for value in bbox_mm): + continue + sizes = [abs(bbox_mm[i + 3] - bbox_mm[i]) for i in range(3)] + stable_key = f"owned_face_bbox:{','.join(f'{value:.9g}' for value in bbox_mm)}" + if stable_key in seen: + continue + seen.add(stable_key) + selectors.append({ + "kind": "edge", + "geometry": { + "kind": "edge", + "bbox_mm": bbox_mm, + }, + "tolerance_mm": max(0.5, min(max(sizes), 10.0) * 0.35), + "source": "owned_face_bbox", + }) + return selectors + +def _feature_selection_source( + feature: Dict[str, Any], + data_block: Optional[Dict[str, Any]] = None, +) -> str: + sources = [] + if isinstance(data_block, dict): + sources.extend(data_block.get("selections") or []) + sources.extend(feature.get("selections") or []) + if any(isinstance(item, dict) and item.get("kind") == "selection" for item in sources): + return "solidworks_original_selection" + if feature.get("owned_faces"): + return "post_feature_owned_face_inference" + return "missing" + +def _hole_dimension_value_excluding( + data_block: Dict[str, Any], + tokens: tuple[str, ...], + excluded: tuple[str, ...] = (), +) -> Optional[float]: + for dim in data_block.get("dimensions", []) or []: + name = str(dim.get("name") or "").lower() + if excluded and any(token.lower() in name for token in excluded): + continue + if all(token.lower() in name for token in tokens) and dim.get("value") not in (None, ""): + return float(dim.get("value")) + return None + +def _hole_primary_dimension_fallback(data_block: Dict[str, Any], prefer_small: bool) -> Optional[float]: + values = [] + for dim in data_block.get("dimensions", []) or []: + name = str(dim.get("name") or "").lower() + if not any(token in name for token in ("孔", "hole", "螺", "thread")): + continue + if any(token in name for token in ("沉头", "锥", "counter", "csk", "导头", "angle", "角度")): + continue + value = dim.get("value") + if value in (None, ""): + continue + number = abs(float(value)) + if 0 < number < 200: + values.append(number) + if not values: + return None + return min(values) if prefer_small else max(values) + +def _hole_primary_diameter_mm(data_block: Dict[str, Any]) -> float: + diameter = ( + _hole_dimension_value_excluding(data_block, ("tap", "drill", "dia"), ("depth", "angle")) + or _hole_dimension_value_excluding(data_block, ("tap", "drill", "diameter"), ("depth", "angle")) + or _hole_dimension_value_excluding(data_block, ("螺纹孔钻头", "直径"), ("深度", "角度")) + or _hole_dimension_value_excluding(data_block, ("钻头", "直径"), ("深度", "角度")) + or _hole_dimension_value_excluding(data_block, ("通孔", "孔直径"), ("沉头", "锥", "counter", "csk", "角度", "深度")) + or _hole_dimension_value_excluding(data_block, ("孔直径",), ("沉头", "锥", "counter", "csk", "角度", "深度")) + or _hole_dimension_value_excluding(data_block, ("hole", "diameter"), ("counter", "csk", "angle", "depth")) + or _hole_dimension_value_excluding(data_block, ("thread", "diameter"), ("counter", "csk", "angle", "depth")) + or _hole_dimension_value_excluding(data_block, ("螺纹",), ("深度", "depth", "角度", "angle")) + or _hole_primary_dimension_fallback(data_block, prefer_small=True) + ) + return abs(float(diameter)) if diameter else 0 + +def _hole_primary_depth_mm(data_block: Dict[str, Any]) -> float: + depth = ( + _hole_dimension_value_excluding(data_block, ("通孔", "孔深度"), ("沉头", "锥", "counter", "csk", "角度", "直径")) + or _hole_dimension_value_excluding(data_block, ("孔深度",), ("沉头", "锥", "counter", "csk", "角度", "直径")) + or _hole_dimension_value_excluding(data_block, ("螺纹孔钻头", "深度"), ("直径", "角度")) + or _hole_dimension_value_excluding(data_block, ("通孔", "螺纹孔钻头", "深度"), ("直径", "角度")) + or _hole_dimension_value_excluding(data_block, ("tap", "drill", "depth"), ("diameter", "angle")) + or _hole_dimension_value_excluding(data_block, ("hole", "depth"), ("counter", "csk", "angle", "diameter")) + or _hole_dimension_value_excluding(data_block, ("thread", "depth"), ("counter", "csk", "angle", "diameter")) + ) + if depth: + return abs(float(depth)) + return THROUGH_CUT_AMOUNT_MM + +def _hole_counterbore_dimension_mm(data_block: Dict[str, Any]) -> Optional[float]: + return ( + _hole_dimension_value(data_block, ("柱形沉头", "直径")) + or _hole_dimension_value(data_block, ("柱形沉头孔", "直径")) + or _hole_dimension_value(data_block, ("沉头孔", "直径")) + or _hole_dimension_value(data_block, ("counterbore", "diameter")) + or _hole_dimension_value(data_block, ("counter", "bore", "diameter")) + ) + +def _hole_counterbore_depth_dimension_mm(data_block: Dict[str, Any]) -> Optional[float]: + return ( + _hole_dimension_value(data_block, ("柱形沉头", "深度")) + or _hole_dimension_value(data_block, ("柱形沉头孔", "深度")) + or _hole_dimension_value(data_block, ("沉头孔", "深度")) + or _hole_dimension_value(data_block, ("counterbore", "depth")) + or _hole_dimension_value(data_block, ("counter", "bore", "depth")) + ) + +def _hole_angle_dimension_rad(data_block: Dict[str, Any], tokens: tuple[str, ...]) -> Optional[float]: + for dim in data_block.get("dimensions", []) or []: + name = str(dim.get("name") or "").lower() + if all(token.lower() in name for token in tokens): + if dim.get("system_value_m") not in (None, ""): + return float(dim.get("system_value_m")) + if dim.get("value") not in (None, ""): + value = float(dim.get("value")) + return value / 1000 if value > math.tau else value + return None + +def _extract_edge_selector_points(op: Dict[str, Any]) -> list[list[tuple[float, float, float]]]: + selector_points = [] + for selector in op.get("selectors", []): + geometry = selector.get("geometry") or {} + start = geometry.get("start_vertex") or {} + start_point = start.get("point_m") if isinstance(start, dict) else None + end = geometry.get("end_vertex") or {} + end_point = end.get("point_m") if isinstance(end, dict) else None + if start_point and end_point: + selector_points.append([_point_m_to_mm(start_point), _point_m_to_mm(end_point)]) + return selector_points + +_SW_METADATA_FEATURE_TYPES = { + "commentsfolder", + "favoritefolder", + "historyfolder", + "selectionsetfolder", + "sensorfolder", + "docsfolder", + "detailcabinet", + "surfacebodyfolder", + "solidbodyfolder", + "envfolder", + "inkmarkupfolder", + "eqnfolder", + "materialfolder", + "configtablefolder", + "ftrfolder", +} + +def _source_feature(feature: Dict[str, Any], index: int) -> Dict[str, Any]: + source = feature.get("source_feature") if isinstance(feature.get("source_feature"), dict) else {} + identity = source.get("identity") if isinstance(source.get("identity"), dict) else {} + return { + "index": source.get("index", index), + "id": feature.get("id"), + "name": feature.get("name"), + "type": feature.get("type"), + "type_name": feature.get("type_name"), + "stable_id": source.get("stable_id") or identity.get("stable_id"), + "persistent_reference": source.get("persistent_reference") or identity.get("persistent_reference"), + "identity": identity or None, + } + +def _source_owned_faces(feature: Dict[str, Any]) -> list[Dict[str, Any]]: + faces = feature.get("owned_faces") + if not isinstance(faces, list): + return [] + summarized = [] + for face in faces: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + summarized.append( + { + "box_m": face.get("box_m"), + "area_m2": face.get("area_m2"), + "surface": { + "is_plane": bool(surface.get("is_plane")), + "is_cylinder": bool(surface.get("is_cylinder")), + "is_cone": bool(surface.get("is_cone")), + "is_sphere": bool(surface.get("is_sphere")), + "is_torus": bool(surface.get("is_torus")), + "cylinder_params": surface.get("cylinder_params"), + "cone_params": surface.get("cone_params"), + "plane_params": surface.get("plane_params"), + }, + } + ) + return summarized + +def _convert_sw_reference(feature: Dict[str, Any], index: int) -> Dict[str, Any]: + snapshot = feature.get("definition_snapshot", {}) + return { + "id": feature.get("id") or f"reference_{index:03d}", + "name": feature.get("name"), + "type": feature.get("type"), + "sw_type": feature.get("type_name"), + "definition": snapshot.get("values", {}), + "source_feature": _source_feature(feature, index), + } + +def _convert_sw_sketch(feature: Dict[str, Any], sketch_id: str, index: int) -> Dict[str, Any]: + sketch_data = feature.get("sketch_data", {}) + raw_entities = sketch_data.get("entities", []) + raw_converted_entities = [_convert_sw_sketch_entity(entity) for entity in raw_entities] + converted_entities = [] + raw_to_converted_index: dict[int, int] = {} + stable_id_to_raw_index: dict[str, int] = {} + for raw_index, (raw_entity, converted_entity) in enumerate(zip(raw_entities, raw_converted_entities)): + for stable_id in _selectable_stable_ids(raw_entity): + stable_id_to_raw_index.setdefault(stable_id, raw_index) + if converted_entity is None: + continue + raw_to_converted_index[raw_index] = len(converted_entities) + converted_entities.append(converted_entity) + loops = [] + for contour in sketch_data.get("sketch_contours", []) or sketch_data.get("contours", []) or []: + if not isinstance(contour, dict): + continue + entity_indices = contour.get("entity_indices") or contour.get("segment_indices") or [] + if not entity_indices: + entity_indices = _contour_entity_indices_from_segments(contour, stable_id_to_raw_index) + if not entity_indices: + continue + normalized_indices = [ + raw_to_converted_index[int(idx)] + for idx in entity_indices + if isinstance(idx, (int, float)) and int(idx) in raw_to_converted_index + ] + if not normalized_indices: + continue + bbox = _loop_bbox([ + converted_entities[idx] + for idx in normalized_indices + if 0 <= idx < len(converted_entities) + ]) + loops.append({ + "id": contour.get("contour_id"), + "entity_indices": normalized_indices, + "is_closed": contour.get("is_closed"), + "bbox_mm": bbox or contour.get("bbox_mm"), + "bbox_area_mm2": _bbox_area_2d(bbox) if bbox else contour.get("bbox_area_mm2"), + "source": "solidworks_sketch_contour", + }) + workplane = sketch_data.get("workplane") or {} + if not workplane: + workplane = {"name": sketch_data.get("plane"), "origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]} + return { + "id": sketch_id, + "name": feature.get("name", sketch_id), + "workplane": workplane, + "host_reference": sketch_data.get("host_reference"), + "entities": converted_entities, + "loops": loops, + "sketch_regions": sketch_data.get("sketch_regions", []), + "constraints": sketch_data.get("constraints", []), + "inferred_constraints": sketch_data.get("inferred_constraints", []), + "dimensions": sketch_data.get("dimensions", []), + "feature_dimensions": sketch_data.get("feature_dimensions", []), + "source_feature": _source_feature(feature, index), + } + +def _selectable_stable_ids(value: Any) -> list[str]: + if not isinstance(value, dict): + return [] + candidates = [value.get("stable_id")] + identity = value.get("identity") + if isinstance(identity, dict): + candidates.append(identity.get("stable_id")) + return [str(candidate) for candidate in candidates if candidate] + +def _contour_entity_indices_from_segments(contour: Dict[str, Any], stable_id_to_raw_index: dict[str, int]) -> list[int]: + indices: list[int] = [] + seen: set[int] = set() + for segment in contour.get("sketch_segments") or []: + for stable_id in _selectable_stable_ids(segment): + raw_index = stable_id_to_raw_index.get(stable_id) + if raw_index is None or raw_index in seen: + continue + seen.add(raw_index) + indices.append(raw_index) + break + return indices + +def _convert_sw_sketch_entity(entity: Dict[str, Any]) -> Optional[Dict[str, Any]]: + entity_type = str(entity.get("canonical_entity_type") or entity.get("entity_type", "")).lower() + curve = entity.get("curve") if isinstance(entity.get("curve"), dict) else {} + if ( + entity_type == "circle_or_arc" + or curve.get("is_circle") is True + or entity.get("curve_entity_type") == "circle_or_arc" + ): + center = entity.get("curve_center_mm") or entity.get("center_mm") + radius_mm_value = entity.get("curve_radius_mm") or entity.get("radius_mm") + radius_raw_value = entity.get("radius") + start = entity.get("start_mm") + end = entity.get("end_mm") + start_2d = [float(start[0]), float(start[1])] if isinstance(start, list) and len(start) >= 2 else None + end_2d = [float(end[0]), float(end[1])] if isinstance(end, list) and len(end) >= 2 else None + center_2d = [float(center[0]), float(center[1])] if isinstance(center, list) and len(center) >= 2 else [0.0, 0.0] + radius_mm = float(radius_mm_value) if radius_mm_value is not None else _scale_length(radius_raw_value or 0) + if start_2d and end_2d and math.hypot(start_2d[0] - end_2d[0], start_2d[1] - end_2d[1]) > 1e-6: + # 计算 sweep 方向 + import math as _math + sa = _math.degrees(_math.atan2(start_2d[1] - center_2d[1], start_2d[0] - center_2d[0])) + ea = _math.degrees(_math.atan2(end_2d[1] - center_2d[1], end_2d[0] - center_2d[0])) + sweep = round(ea - sa, 10) + while sweep <= -180: + sweep += 360 + while sweep > 180: + sweep -= 360 + result = { + "type": "arc", + "center": center_2d, + "start": start_2d, + "end": end_2d, + "radius_mm": radius_mm, + "start_angle_deg": round(sa, 10), + "end_angle_deg": round(ea, 10), + "arc_sweep_deg": round(sweep, 10), + "construction": bool(entity.get("construction")), + "raw": entity, + } + curve_axis = entity.get("curve_axis") + if isinstance(curve_axis, list) and len(curve_axis) >= 3: + result["curve_axis"] = [float(v) for v in curve_axis[:3]] + return result + return { + "type": "circle", + "center": center_2d, + "radius_mm": radius_mm, + "construction": bool(entity.get("construction")), + "raw": entity, + } + if "line" in entity_type: + return { + "type": "line", + "start": _sketch_point_mm(entity, "start"), + "end": _sketch_point_mm(entity, "end"), + "construction": bool(entity.get("construction")), + "raw": entity, + } + if "circle" in entity_type: + return { + "type": "circle", + "center": _sketch_point_mm(entity, "center"), + "radius_mm": _sketch_radius_mm(entity), + "construction": bool(entity.get("construction")), + "raw": entity, + } + if "arc" in entity_type: + return { + "type": "arc", + "center": _sketch_point_mm(entity, "center"), + "start": _sketch_point_mm(entity, "start"), + "end": _sketch_point_mm(entity, "end"), + "radius_mm": _sketch_radius_mm(entity), + "start_angle_deg": _to_degrees(entity.get("start_angle", 0)), + "end_angle_deg": _to_degrees(entity.get("end_angle", 360)), + "construction": bool(entity.get("construction")), + "raw": entity, + } + if entity_type == "point": + point = entity.get("point_mm") or [float(entity.get("x", 0)) * 1000, float(entity.get("y", 0)) * 1000, 0] + return {"type": "point", "point": point[:2], "point_mm": point, "construction": bool(entity.get("construction")), "raw": entity} + return None + +def _sketch_point_mm(entity: Dict[str, Any], key: str) -> list[float]: + point = entity.get(f"{key}_mm") + if isinstance(point, list) and len(point) >= 2: + return [float(point[0]), float(point[1])] + return _scale_point(entity.get(key, [0, 0])) + +def _sketch_radius_mm(entity: Dict[str, Any]) -> float: + for key in ("radius_mm", "major_radius_mm", "major_radius"): + if entity.get(key) is not None: + return _scale_length(entity.get(key)) + if entity.get("radius") is not None: + return _scale_length(entity.get("radius")) + start = _sketch_point_mm(entity, "start") + center = _sketch_point_mm(entity, "center") + if start and center: + return math.hypot(float(start[0]) - float(center[0]), float(start[1]) - float(center[1])) + return 1.0 + +def _convert_sw_extrude(feature: Dict[str, Any], type_name: str, sketch_id: Optional[str], index: int) -> Dict[str, Any]: + data_block = feature.get("extrude_data", {}) + op_type = "extrude_cut" if _is_cut_feature(feature, type_name) else "extrude_add" + distance = _best_extrude_depth_mm(feature, data_block) + reverse_end_condition_code = data_block.get("reverse_end_condition_code") + reverse_distance = abs(data_block.get("reverse_depth") or 0) + both_directions = bool(data_block.get("both_directions", False)) + reverse_direction = data_block.get("is_reverse") + if reverse_direction is None: + reverse_direction = data_block.get("definition_snapshot", {}).get("ReverseDirection") + if reverse_direction is None: + reverse_direction = feature.get("definition_snapshot", {}).get("values", {}).get("ReverseDirection", False) + if reverse_end_condition_code in (None, 0) and data_block.get("effective_depth_source") == "feature_dimension": + spans_both_sides = _extrude_owned_faces_span_sketch_plane(feature, data_block) + if spans_both_sides and bool(reverse_direction): + both_directions = True + reverse_distance = reverse_distance or distance + else: + both_directions = False + reverse_distance = 0 + raw_depth = abs(data_block.get("depth") or data_block.get("blind_depth") or 0) + uses_reverse_depth_only = ( + op_type == "extrude_cut" + and + feature.get("type") == "ice" + and raw_depth <= 1e-9 + and reverse_distance > 0 + ) + if uses_reverse_depth_only: + reverse_direction = not bool(reverse_direction) if False else bool(reverse_direction) + return { + "id": feature.get("id"), + "name": feature.get("name"), + "type": op_type, + "sketch": sketch_id, + "parameters": { + "distance_mm": distance, + "reverse": bool(reverse_direction), + "reverse_direction": bool(reverse_direction), + "reverse_distance_mm": reverse_distance, + "both_directions": False if uses_reverse_depth_only else both_directions, + "end_condition": data_block.get("end_condition"), + "end_condition_code": data_block.get("end_condition_code"), + "reverse_end_condition_code": reverse_end_condition_code, + "flip_side_to_cut": bool(data_block.get("flip_side_to_cut", False)), + "start_condition_reference": _clean_null_reference(data_block.get("start_condition_reference")), + "end_condition_reference": _clean_null_reference(data_block.get("end_condition_reference")), + "reverse_end_condition_reference": _clean_null_reference(data_block.get("reverse_end_condition_reference")), + "draft_angle_rad": data_block.get("draft_angle_rad"), + "reverse_draft_angle_rad": data_block.get("reverse_draft_angle_rad"), + }, + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + } + +def _extrude_owned_faces_span_sketch_plane(feature: Dict[str, Any], data_block: Dict[str, Any]) -> bool: + sketches = data_block.get("source_sketches") or [] + workplane = sketches[0].get("workplane") if sketches and isinstance(sketches[0], dict) else None + if not isinstance(workplane, dict): + return bool(data_block.get("both_directions")) and (data_block.get("reverse_depth") not in (None, 0)) + + origin = workplane.get("origin_mm") or [0, 0, 0] + normal = workplane.get("normal") or [0, 0, 1] + if not isinstance(origin, list) or not isinstance(normal, list) or len(origin) < 3 or len(normal) < 3: + return False + + nx, ny, nz = (float(normal[0]), float(normal[1]), float(normal[2])) + length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0 + nx, ny, nz = nx / length, ny / length, nz / length + ox, oy, oz = float(origin[0]), float(origin[1]), float(origin[2]) + + min_distance = math.inf + max_distance = -math.inf + for face in feature.get("owned_faces") or []: + box = face.get("box_m") if isinstance(face, dict) else None + if not isinstance(box, list) or len(box) < 6: + continue + xs = [float(box[0]) * 1000, float(box[3]) * 1000] + ys = [float(box[1]) * 1000, float(box[4]) * 1000] + zs = [float(box[2]) * 1000, float(box[5]) * 1000] + for x in xs: + for y in ys: + for z in zs: + distance_to_plane = (x - ox) * nx + (y - oy) * ny + (z - oz) * nz + min_distance = min(min_distance, distance_to_plane) + max_distance = max(max_distance, distance_to_plane) + + if math.isinf(min_distance) or math.isinf(max_distance): + return False + tolerance = 1e-4 + return min_distance < -tolerance and max_distance > tolerance + +def _convert_sw_revolve(feature: Dict[str, Any], type_name: str, sketch_id: Optional[str], index: int) -> Dict[str, Any]: + data_block = feature.get("revolve_data", {}) + op_type = "revolve_cut" if _is_cut_feature(feature, type_name) else "revolve_add" + selected_axis = _axis_reference_from_feature_selections(data_block.get("selections")) + owned_face_axis = _axis_reference_from_owned_faces(feature) + extracted_axis = _extract_axis_reference(data_block.get("axis_reference")) + axis_reference = selected_axis or owned_face_axis + if not axis_reference and not _is_weak_inferred_axis(extracted_axis): + axis_reference = extracted_axis + return { + "id": feature.get("id"), + "name": feature.get("name"), + "type": op_type, + "sketch": sketch_id, + "parameters": { + "angle_deg": abs(data_block.get("angle") or 360), + "angle_rad": data_block.get("angle_rad"), + "reverse": data_block.get("is_reverse", False), + "end_condition": data_block.get("end_condition"), + "end_condition_code": data_block.get("end_condition_code"), + "axis_reference": axis_reference, + "axis_candidates": data_block.get("axis_candidates", []), + }, + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + } + +def _axis_reference_from_owned_faces(feature: Dict[str, Any]) -> Optional[Dict[str, Any]]: + candidates: list[tuple[float, Dict[str, Any]]] = [] + for face in feature.get("owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + params = None + if surface.get("is_cylinder") and isinstance(surface.get("cylinder_params"), list): + params = surface.get("cylinder_params") + elif surface.get("is_cone") and isinstance(surface.get("cone_params"), list): + params = surface.get("cone_params") + if not isinstance(params, list) or len(params) < 6: + continue + direction = [float(value) for value in params[3:6]] + norm = math.sqrt(sum(value * value for value in direction)) + if norm <= 1e-9: + continue + candidates.append(( + float(face.get("area_m2") or 0.0), + { + "origin_mm": [float(value) * 1000 for value in params[:3]], + "direction": [value / norm for value in direction], + "source": "owned_face_axis", + }, + )) + if not candidates: + return None + candidates.sort(key=lambda item: item[0], reverse=True) + return candidates[0][1] + +def _is_weak_inferred_axis(axis_reference: Optional[Dict[str, Any]]) -> bool: + if not isinstance(axis_reference, dict): + return False + return str(axis_reference.get("source") or "") in {"construction_line_candidate", "construction_line"} + +def _convert_sw_hole(feature: Dict[str, Any], index: int) -> Dict[str, Any]: + data_block = feature.get("hole_data", {}) + positions = [] + host_face = _host_face_from_feature_selections(data_block.get("selections")) or {} + position_sketches = _hole_position_sketches(data_block.get("position_sketches", []) or []) + for sketch in position_sketches: + workplane = sketch.get("workplane") or {} + if not host_face and workplane: + host_face = _host_face_from_workplane(workplane) + for point in _hole_position_points(sketch): + positions.append({"mm": [float(point[0]), float(point[1]), float(point[2] if len(point) > 2 else 0)]}) + diameter_mm = abs(data_block.get("diameter") or 0) or _hole_primary_diameter_mm(data_block) + depth_mm = abs(data_block.get("depth") or 0) or _hole_primary_depth_mm(data_block) + return { + "id": feature.get("id"), + "name": feature.get("name"), + "type": "hole", + "parameters": { + "diameter_mm": diameter_mm, + "depth_mm": depth_mm, + "counterbore_diameter_mm": _hole_counterbore_dimension_mm(data_block), + "counterbore_depth_mm": _hole_counterbore_depth_dimension_mm(data_block), + "countersink_diameter_mm": _hole_dimension_value(data_block, ("锥形沉头", "直径")) + or _hole_dimension_value(data_block, ("近端锥形沉头", "直径")) + or _hole_dimension_value(data_block, ("锥坑", "直径")) + or _hole_dimension_value(data_block, ("countersink", "diameter")) + or _hole_dimension_value(data_block, ("csk", "diameter")), + "angles_rad": { + "countersink_angle": _hole_angle_dimension_rad(data_block, ("锥形沉头", "角度")) + or _hole_angle_dimension_rad(data_block, ("近端锥形沉头", "角度")) + or _hole_angle_dimension_rad(data_block, ("锥坑", "角度")) + or _hole_angle_dimension_rad(data_block, ("countersink", "angle")) + or _hole_angle_dimension_rad(data_block, ("csk", "angle")), + "drill_angle": _hole_angle_dimension_rad(data_block, ("导头", "角度")) + or _hole_angle_dimension_rad(data_block, ("drill", "angle")) + or _hole_angle_dimension_rad(data_block, ("tip", "angle")), + }, + "positions": positions, + "host_face": host_face, + "hole_type": data_block.get("hole_type"), + "standard": data_block.get("standard"), + "size": data_block.get("size"), + "dimension_names": [ + str(dim.get("name") or "") + for dim in data_block.get("dimensions", []) or [] + if isinstance(dim, dict) + ], + }, + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + } + +def _hole_position_sketches(sketches: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + point_only = [] + for sketch in sketches: + entities = sketch.get("entities") or [] + if not entities: + continue + if _is_hole_profile_sketch(sketch): + continue + point_count = sum(1 for entity in entities if _is_sketch_point_entity(entity)) + drawable_segment_count = sum( + 1 + for entity in entities + if not _is_sketch_point_entity(entity) and not entity.get("construction") + ) + if point_count > 0 and drawable_segment_count == 0: + point_only.append(sketch) + return point_only or sketches[:1] + +def _is_hole_profile_sketch(sketch: Dict[str, Any]) -> bool: + tokens = ( + "孔直径", + "孔深度", + "沉头", + "导头", + "螺纹孔钻头", + "tap drill", + "drill", + "counterbore", + "countersink", + "hole diameter", + "hole depth", + ) + dimension_sources = [] + dimension_sources.extend(sketch.get("dimensions") or []) + dimension_sources.extend(sketch.get("feature_dimensions") or []) + for dim in dimension_sources: + if not isinstance(dim, dict): + continue + name = str(dim.get("name") or "").lower() + if any(token in name for token in tokens): + return True + return False + +def _is_sketch_point_entity(entity: Dict[str, Any]) -> bool: + entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() + return entity_type == "point" + +def _hole_position_entity_flags(entity: Dict[str, Any]) -> tuple[Optional[bool], bool]: + raw = entity.get("raw") if isinstance(entity.get("raw"), dict) else entity + candidate = raw.get("hole_position_candidate") + if candidate is None: + candidate = entity.get("hole_position_candidate") + if isinstance(candidate, bool): + candidate_flag: Optional[bool] = candidate + else: + candidate_flag = None + construction_reference = bool( + raw.get("construction_endpoint_reference") or entity.get("construction_endpoint_reference") + ) + return candidate_flag, construction_reference + +def _construction_endpoint_degrees(sketch: Dict[str, Any]) -> dict[tuple[float, float, float], int]: + degrees: dict[tuple[float, float, float], int] = {} + for entity in sketch.get("entities") or []: + if not entity.get("construction"): + continue + entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() + if "line" not in entity_type: + continue + for key in ("start_mm", "end_mm"): + endpoint = entity.get(key) + if isinstance(endpoint, list) and len(endpoint) >= 2: + point_key = _rounded_point_key(endpoint) + degrees[point_key] = degrees.get(point_key, 0) + 1 + return degrees + +def _hole_position_points(sketch: Dict[str, Any]) -> list[list[float]]: + """Return only real Hole Wizard placement points from a position sketch. + + SolidWorks Hole Wizard position sketches often include construction + segments whose endpoints are reference geometry, not hole centers. Older + parser JSON exposes those endpoints as ordinary sketch points, so we filter + them generically here instead of letting every point become a hole. + """ + entities = sketch.get("entities") or [] + point_entities: list[tuple[list[float], Optional[bool], bool]] = [] + construction_endpoints: set[tuple[float, float, float]] = set() + + for entity in entities: + point = entity.get("point_mm") + if _is_sketch_point_entity(entity) and isinstance(point, list) and len(point) >= 2: + candidate_flag, construction_reference = _hole_position_entity_flags(entity) + point_entities.append( + ( + [float(point[0]), float(point[1]), float(point[2] if len(point) > 2 else 0)], + candidate_flag, + construction_reference, + ) + ) + continue + if not entity.get("construction"): + continue + entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() + if "line" not in entity_type: + continue + for key in ("start_mm", "end_mm"): + endpoint = entity.get(key) + if isinstance(endpoint, list) and len(endpoint) >= 2: + construction_endpoints.add(_rounded_point_key(endpoint)) + + if not point_entities: + return [] + + explicit_candidates = [ + point for point, candidate_flag, _ in point_entities if candidate_flag is True + ] + if explicit_candidates: + return _dedupe_points(explicit_candidates) + + endpoint_degrees = _construction_endpoint_degrees(sketch) + if endpoint_degrees: + filtered = [] + for point, candidate_flag, construction_reference in point_entities: + point_key = _rounded_point_key(point) + degree = endpoint_degrees.get(point_key, 0) + if candidate_flag is False and construction_reference and degree <= 1: + continue + if degree >= 2 or not construction_reference: + filtered.append(point) + filtered = _dedupe_points(filtered) + non_origin_filtered = [point for point in filtered if not _is_near_origin(point)] + if non_origin_filtered: + return _dedupe_points(non_origin_filtered) + if filtered: + return filtered + + raw_points = _dedupe_points([point for point, _, _ in point_entities]) + if not raw_points or not construction_endpoints: + return raw_points + + legacy_filtered = [point for point in raw_points if _rounded_point_key(point) not in construction_endpoints] + non_origin_raw = [point for point in raw_points if not _is_near_origin(point)] + non_origin_filtered = [point for point in legacy_filtered if not _is_near_origin(point)] + if non_origin_filtered: + return _dedupe_points(non_origin_filtered) + if non_origin_raw: + return _dedupe_points(non_origin_raw) + return _dedupe_points(legacy_filtered or raw_points) + +def _convert_sw_linear_pattern( + feature: Dict[str, Any], + index: int, + previous_build_op: Optional[Dict[str, Any]], + source_frame: Optional[Dict[str, Any]] = None, + sketches: Optional[list[Dict[str, Any]]] = None, + source_bbox: Optional[list[float]] = None, +) -> Dict[str, Any]: + data_block = feature.get("linear_pattern_data", {}) + source_features = data_block.get("source_features") or [] + if not source_features and previous_build_op: + source_features = [previous_build_op.get("source_feature", {})] + spacing_1 = data_block.get("spacing_1") + spacing_2 = data_block.get("spacing_2") + direction_1 = _pattern_direction_from_plugin(data_block.get("direction_1"), axis="x", source_frame=source_frame) + direction_2 = _pattern_direction_from_plugin(data_block.get("direction_2"), axis="y", source_frame=source_frame) + direction_1 = _pattern_direction_from_reference(direction_1, data_block.get("direction_1_reference"), source_frame) + direction_2 = _pattern_direction_from_reference(direction_2, data_block.get("direction_2_reference"), source_frame) + if data_block.get("direction_1_reverse") is True: + direction_1 = _reverse_pattern_direction(direction_1) + if data_block.get("direction_2_reverse") is True: + direction_2 = _reverse_pattern_direction(direction_2) + source_op_bbox = _operation_profile_bbox(previous_build_op, sketches or []) + if data_block.get("direction_1") is None: + direction_1 = _choose_pattern_direction_sign( + direction_1, + spacing_1 or 0, + int(data_block.get("pattern_count_1") or 1), + source_op_bbox, + source_bbox, + ) + if data_block.get("direction_2") is None: + direction_2 = _choose_pattern_direction_sign( + direction_2, + spacing_2 or 0, + int(data_block.get("pattern_count_2") or 1), + source_op_bbox, + source_bbox, + ) + explicit_offsets = _owned_face_pattern_offsets(previous_build_op, feature) + return { + "id": feature.get("id"), + "name": feature.get("name"), + "type": "linear_pattern", + "parameters": { + "source_features": source_features, + "total_instances": data_block.get("pattern_count_1") or 1, + "spacing_mm": spacing_1 or 0, + "direction1": direction_1, + "direction2": direction_2, + }, + "raw_parameters": { + "d1_total_instances": data_block.get("pattern_count_1") or 1, + "d2_total_instances": data_block.get("pattern_count_2") or 1, + "d1_spacing_mm": spacing_1 or 0, + "d2_spacing_mm": spacing_2 or 0, + "direction1": direction_1, + "direction2": direction_2, + "explicit_offsets_mm": explicit_offsets, + }, + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + } + +def _owned_face_pattern_offsets( + source_op: Optional[Dict[str, Any]], + pattern_feature: Dict[str, Any], +) -> list[list[float]]: + if not source_op: + return [] + source_faces = _owned_face_signatures(source_op.get("source_owned_faces") or []) + pattern_faces = _owned_face_signatures(_source_owned_faces(pattern_feature)) + if not source_faces or not pattern_faces: + return [] + + votes: Dict[tuple[float, float, float], int] = {} + for pattern_face in pattern_faces: + for source_face in source_faces: + if pattern_face["kind"] != source_face["kind"]: + continue + if not _similar_bbox_size(pattern_face["size"], source_face["size"]): + continue + offset = tuple( + round(pattern_face["center"][axis] - source_face["center"][axis], 3) + for axis in range(3) + ) + if math.sqrt(sum(component * component for component in offset)) < 1e-6: + continue + votes[offset] = votes.get(offset, 0) + 1 + + if not votes: + return [] + threshold = max(1, min(2, len(source_faces))) + offsets = [offset for offset, count in votes.items() if count >= threshold] + offsets.sort(key=lambda offset: (offset[0] * offset[0] + offset[1] * offset[1] + offset[2] * offset[2], offset)) + return [[float(value) for value in offset] for offset in offsets] + +def _owned_face_signatures(faces: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + signatures = [] + for face in faces: + if not isinstance(face, dict): + continue + box = face.get("box_m") + if not isinstance(box, list) or len(box) < 6: + continue + box_mm = [float(value) * 1000 for value in box[:6]] + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + kind = "other" + if surface.get("is_cylinder"): + kind = "cylinder" + elif surface.get("is_cone"): + kind = "cone" + elif surface.get("is_plane"): + kind = "plane" + signatures.append( + { + "kind": kind, + "center": [(box_mm[i] + box_mm[i + 3]) / 2 for i in range(3)], + "size": [abs(box_mm[i + 3] - box_mm[i]) for i in range(3)], + } + ) + return signatures + +def _source_pattern_frame( + previous_build_op: Optional[Dict[str, Any]], + sketches: list[Dict[str, Any]], +) -> Optional[Dict[str, Any]]: + if not previous_build_op: + return None + params = previous_build_op.get("parameters") or {} + host_frame = ((params.get("host_face") or {}).get("frame") or {}) + if host_frame.get("x_dir") and host_frame.get("y_dir"): + return host_frame + sketch_id = previous_build_op.get("sketch") + for sketch in sketches: + if sketch.get("id") == sketch_id: + workplane = sketch.get("workplane") or {} + if workplane.get("x_dir") and workplane.get("y_dir"): + return workplane + return None + +def _source_bbox_from_plugin_json(data: Dict[str, Any]) -> Optional[list[float]]: + bbox = (data.get("validation_hints") or {}).get("part_box_m") + if isinstance(bbox, list) and len(bbox) >= 6: + return [float(v) * 1000 for v in bbox[:6]] + return None + +def _operation_profile_bbox( + op: Optional[Dict[str, Any]], + sketches: list[Dict[str, Any]], +) -> Optional[list[float]]: + if not op: + return None + if op.get("type") == "hole": + host_face = (op.get("parameters") or {}).get("host_face") or {} + positions = [ + _hole_position_to_model(pos.get("mm"), host_face) + for pos in (op.get("parameters") or {}).get("positions", []) + if isinstance(pos.get("mm"), list) and len(pos.get("mm")) >= 3 + ] + if positions: + return _points_bbox(positions) + sketch_id = op.get("sketch") + sketch = next((item for item in sketches if item.get("id") == sketch_id), None) + if not sketch: + return None + points = [] + workplane = sketch.get("workplane") or {} + origin = workplane.get("origin_mm") or [0, 0, 0] + x_dir = workplane.get("x_dir") or [1, 0, 0] + y_dir = workplane.get("y_dir") or [0, 1, 0] + for entity in sketch.get("entities", []) or []: + if entity.get("type") == "circle": + center = entity.get("center") or [0, 0] + radius = float(entity.get("radius_mm") or 0) + for dx, dy in ((-radius, -radius), (-radius, radius), (radius, -radius), (radius, radius)): + points.append(_sketch_point_to_model_bbox(origin, x_dir, y_dir, [float(center[0]) + dx, float(center[1]) + dy])) + for key in ("start", "end", "center", "point"): + point = entity.get(key) + if isinstance(point, list) and len(point) >= 2: + points.append(_sketch_point_to_model_bbox(origin, x_dir, y_dir, point)) + return _points_bbox(points) + +def _sketch_point_to_model_bbox(origin: list[Any], x_dir: list[Any], y_dir: list[Any], point: list[Any]) -> list[float]: + return [ + float(origin[i]) + float(x_dir[i]) * float(point[0]) + float(y_dir[i]) * float(point[1]) + for i in range(3) + ] + +def _hole_position_to_model(point: list[Any], host_face: Dict[str, Any]) -> list[float]: + frame = host_face.get("frame") if isinstance(host_face, dict) else {} + if not isinstance(frame, dict): + return [float(v) for v in (point + [0, 0, 0])[:3]] + origin = frame.get("origin_mm") or [0, 0, 0] + x_dir = frame.get("x_dir") or [1, 0, 0] + y_dir = frame.get("y_dir") or [0, 1, 0] + values = [float(v) for v in (point + [0, 0, 0])[:3]] + return [ + float(origin[i]) + float(x_dir[i]) * values[0] + float(y_dir[i]) * values[1] + for i in range(3) + ] + +def _choose_pattern_direction_sign( + direction: Dict[str, Any], + spacing: float, + count: int, + source_op_bbox: Optional[list[float]], + source_bbox: Optional[list[float]], +) -> Dict[str, Any]: + vector = direction.get("vector") + if ( + not isinstance(vector, list) + or len(vector) < 3 + or not spacing + or count <= 1 + or not source_op_bbox + or not source_bbox + ): + return direction + unit = _unit3(vector) + distance = float(spacing) * (count - 1) + positive = [component * distance for component in unit] + negative = [-component * distance for component in unit] + positive_score = _bbox_overflow_score(_translated_bbox(source_op_bbox, positive), source_bbox) + negative_score = _bbox_overflow_score(_translated_bbox(source_op_bbox, negative), source_bbox) + if abs(positive_score - negative_score) <= 1e-9: + positive_score += _bbox_center_distance_score(_translated_bbox(source_op_bbox, positive), source_bbox) + negative_score += _bbox_center_distance_score(_translated_bbox(source_op_bbox, negative), source_bbox) + copied = dict(direction) + if negative_score + 1e-9 < positive_score: + copied["vector"] = [-component for component in unit] + copied["source"] = f"{direction.get('source', 'missing_direction')}_sign_from_source_bbox" + return copied + copied["vector"] = unit + if positive_score + 1e-9 < negative_score: + copied["source"] = f"{direction.get('source', 'missing_direction')}_sign_from_source_bbox" + return copied + +def _is_cut_feature(feature: Dict[str, Any], type_name: str) -> bool: + text = f"{type_name} {feature.get('name', '')}".lower() + return "cut" in text or "切除" in text or "revcut" in text + +def _best_extrude_depth_mm(feature: Dict[str, Any], data_block: Dict[str, Any]) -> float: + for key in ("depth", "blind_depth"): + value = data_block.get(key) + if value: + return abs(float(value)) + owned_face_depth = _extrude_depth_from_owned_faces(feature, data_block) + effective_depth = abs(float(data_block.get("effective_depth") or 0)) + if ( + owned_face_depth + and _is_cut_feature(feature, str(feature.get("type_name") or feature.get("type") or "")) + and data_block.get("effective_depth_source") == "feature_dimension" + and not data_block.get("depth") + and not data_block.get("blind_depth") + and not data_block.get("reverse_depth") + and effective_depth > owned_face_depth * 2 + ): + return owned_face_depth + owner_name = feature.get("name") + for dim in data_block.get("dimensions", []) or []: + name = dim.get("name") or "" + if owner_name and f"@{owner_name}@" in name and dim.get("value") not in (None, 0): + return abs(float(dim.get("value"))) + for dim in data_block.get("dimensions", []) or []: + if dim.get("owner") == owner_name and dim.get("value") not in (None, 0): + return abs(float(dim.get("value"))) + if data_block.get("reverse_depth") not in (None, 0): + return abs(float(data_block.get("reverse_depth"))) + if data_block.get("effective_depth") not in (None, 0): + return abs(float(data_block.get("effective_depth"))) + return 0.0 + +def _extrude_depth_from_owned_faces(feature: Dict[str, Any], data_block: Dict[str, Any]) -> Optional[float]: + sketches = data_block.get("source_sketches") or [] + workplane = sketches[0].get("workplane") if sketches and isinstance(sketches[0], dict) else None + if not isinstance(workplane, dict): + return None + normal = workplane.get("normal") or [0, 0, 1] + if not isinstance(normal, list) or len(normal) < 3: + return None + axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) + values: list[float] = [] + for face in feature.get("owned_faces") or []: + if not isinstance(face, dict): + continue + box = face.get("box_m") + if isinstance(box, list) and len(box) >= 6: + values.extend([float(box[axis]) * 1000, float(box[axis + 3]) * 1000]) + if not values: + return None + extent = max(values) - min(values) + return abs(extent) if extent > 1e-6 else None + +def _host_face_from_workplane(workplane: Dict[str, Any]) -> Dict[str, Any]: + origin = workplane.get("origin_mm") or [0, 0, 0] + normal = workplane.get("normal") or [0, 0, 1] + x_dir = workplane.get("x_dir") or [1, 0, 0] + y_dir = workplane.get("y_dir") or [0, 1, 0] + return { + "surface": {"plane_params": [*normal[:3], *(float(v) / 1000 for v in origin[:3])]}, + "frame": {"origin_mm": origin[:3], "x_dir": x_dir[:3], "y_dir": y_dir[:3], "normal": normal[:3]}, + } + +def _pattern_direction_from_plugin( + direction: Any, + axis: str, + source_frame: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + if isinstance(direction, dict): + return direction + if source_frame: + key = "y_dir" if axis == "y" else "x_dir" + vector = source_frame.get(key) + if isinstance(vector, list) and len(vector) >= 3: + return {"vector": vector[:3], "source": f"source_feature_frame_{key}"} + if axis == "y": + return {"vector": [0, 1, 0], "source": "default_y_when_plugin_direction_missing"} + return {"vector": [1, 0, 0], "source": "default_x_when_plugin_direction_missing"} + +def _pattern_direction_from_reference( + fallback: Dict[str, Any], + reference: Any, + source_frame: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + axis = _extract_axis_reference(reference) + if not axis: + return fallback + vector = axis.get("direction") + if not isinstance(vector, list) or len(vector) < 3: + return fallback + model_vector = _sketch_vector_to_model(vector[:3], source_frame) or vector[:3] + model_origin = _sketch_point_to_model(axis.get("origin_mm"), source_frame) or axis.get("origin_mm") + return { + "vector": _unit3(model_vector), + "origin_mm": model_origin, + "source": axis.get("source") or "direction_reference", + } + +def _sketch_vector_to_model( + vector: list[Any], + source_frame: Optional[Dict[str, Any]], +) -> Optional[list[float]]: + if not source_frame: + return None + x_dir = source_frame.get("x_dir") + y_dir = source_frame.get("y_dir") + normal = source_frame.get("normal") + if not ( + isinstance(x_dir, list) + and len(x_dir) >= 3 + and isinstance(y_dir, list) + and len(y_dir) >= 3 + ): + return None + if not (isinstance(normal, list) and len(normal) >= 3): + normal = [ + float(x_dir[1]) * float(y_dir[2]) - float(x_dir[2]) * float(y_dir[1]), + float(x_dir[2]) * float(y_dir[0]) - float(x_dir[0]) * float(y_dir[2]), + float(x_dir[0]) * float(y_dir[1]) - float(x_dir[1]) * float(y_dir[0]), + ] + values = [float(v) for v in (vector + [0, 0, 0])[:3]] + return [ + values[0] * float(x_dir[i]) + values[1] * float(y_dir[i]) + values[2] * float(normal[i]) + for i in range(3) + ] + +def _sketch_point_to_model( + point: Any, + source_frame: Optional[Dict[str, Any]], +) -> Optional[list[float]]: + if not isinstance(point, list) or len(point) < 3 or not source_frame: + return None + origin = source_frame.get("origin_mm") + vector = _sketch_vector_to_model(point[:3], source_frame) + if not (isinstance(origin, list) and len(origin) >= 3 and vector): + return None + return [float(origin[i]) + vector[i] for i in range(3)] + +def _reverse_pattern_direction(direction: Dict[str, Any]) -> Dict[str, Any]: + vector = direction.get("vector") + if not isinstance(vector, list) or len(vector) < 3: + return direction + copied = dict(direction) + copied["vector"] = [-float(vector[0]), -float(vector[1]), -float(vector[2])] + copied["source"] = f"{direction.get('source', 'direction')}_reversed" + return copied + +def _clean_null_reference(reference: Any) -> Optional[Dict[str, Any]]: + if not isinstance(reference, dict): + return None + if reference.get("kind") == "null": + return None + obj = reference.get("object") + if isinstance(obj, dict) and obj.get("kind") == "null": + return None + return reference + +def _extract_axis_reference(reference: Any) -> Optional[Dict[str, Any]]: + if not isinstance(reference, dict): + return None + if reference.get("origin_mm") and reference.get("direction"): + return { + "origin_mm": [float(v) for v in reference.get("origin_mm", [])[:3]], + "direction": [float(v) for v in reference.get("direction", [])[:3]], + "source": reference.get("source") or "axis_reference", + } + obj = reference.get("object") if isinstance(reference.get("object"), dict) else reference + if obj.get("kind") == "null": + return None + + line_params = obj.get("line_params") + if isinstance(line_params, list) and len(line_params) >= 6: + return { + "origin_mm": [float(v) * 1000 for v in line_params[:3]], + "direction": [float(v) for v in line_params[3:6]], + "source": reference.get("source") or "selection_line_params", + } + + curve = obj.get("curve") if isinstance(obj.get("curve"), dict) else {} + curve_line_params = curve.get("line_params") + if isinstance(curve_line_params, list) and len(curve_line_params) >= 6: + return { + "origin_mm": [float(v) * 1000 for v in curve_line_params[:3]], + "direction": [float(v) for v in curve_line_params[3:6]], + "source": reference.get("source") or "selection_curve_line_params", + } + return None + +def _selection_objects(selections: Any) -> list[Dict[str, Any]]: + objects: list[Dict[str, Any]] = [] + if not isinstance(selections, list): + return objects + for selection in selections: + if not isinstance(selection, dict): + continue + obj = selection.get("object") + if isinstance(obj, dict) and obj.get("kind") != "null": + objects.append(obj) + return objects + +def _axis_reference_from_feature_selections(selections: Any) -> Optional[Dict[str, Any]]: + for obj in _selection_objects(selections): + axis = _extract_axis_reference(obj) + if axis: + axis["source"] = "feature_selection_axis" + return axis + return None + +def _host_face_from_feature_selections(selections: Any) -> Optional[Dict[str, Any]]: + for obj in _selection_objects(selections): + if obj.get("kind") != "face": + continue + surface = obj.get("surface") if isinstance(obj.get("surface"), dict) else {} + frame = obj.get("frame") if isinstance(obj.get("frame"), dict) else {} + if not frame: + continue + normal = frame.get("normal") or (surface.get("plane_params") or [0, 0, 1])[:3] + origin = frame.get("origin_mm") + if not origin: + plane_params = surface.get("plane_params") + if isinstance(plane_params, list) and len(plane_params) >= 6: + origin = [float(v) * 1000 for v in plane_params[3:6]] + if not origin: + origin = [0, 0, 0] + x_dir = frame.get("x_dir") or [1, 0, 0] + y_dir = frame.get("y_dir") or [0, 1, 0] + origin_values = list(origin) + x_values = list(x_dir) + y_values = list(y_dir) + normal_values = list(normal) + return { + "surface": surface, + "frame": { + "origin_mm": [float(v) for v in (origin_values + [0, 0, 0])[:3]], + "x_dir": [float(v) for v in (x_values + [0, 0, 0])[:3]], + "y_dir": [float(v) for v in (y_values + [0, 0, 0])[:3]], + "normal": [float(v) for v in (normal_values + [0, 0, 1])[:3]], + }, + "source": "feature_selection_face", + } + return None diff --git a/backend/engine/cdsl_engine/translator/runtime_lib.py b/backend/engine/cdsl_engine/translator/runtime_lib.py new file mode 100644 index 00000000..06497db7 --- /dev/null +++ b/backend/engine/cdsl_engine/translator/runtime_lib.py @@ -0,0 +1,949 @@ +"""Frozen runtime library embedded into generated build123d scripts. + +These lines are appended after the per-model header (imports plus source +volume/area constants) in every generated script. The library is stable: +safe boolean wrappers, selector-based edge matching, and owned-face cutters. +Changes here affect every translator-generated rebuild. +""" + +from __future__ import annotations + +RUNTIME_LIB_LINES: list[str] = [ + "", + "def _dist(a, b):", + " return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))", + "", + "def _owned_face_match_score(shape, expected_faces):", + " if not expected_faces:", + " return 0.0", + " try:", + " available = list(shape.faces())", + " except Exception:", + " return 1e99", + " total = 0.0", + " for expected in expected_faces:", + " bbox_m = expected.get('box_m')", + " if not bbox_m or len(bbox_m) < 6 or not available:", + " total += 1e6", + " continue", + " target_box = [float(v) * 1000 for v in bbox_m[:6]]", + " surface = expected.get('surface') or {}", + " target_type = next((name for name in ('plane', 'cylinder', 'cone', 'sphere', 'torus') if surface.get('is_' + name)), '')", + " target_area = float(expected.get('area_m2') or 0) * 1_000_000", + " ranked = []", + " for index, face in enumerate(available):", + " try:", + " fb = face.bounding_box()", + " face_box = [fb.min.X, fb.min.Y, fb.min.Z, fb.max.X, fb.max.Y, fb.max.Z]", + " geom = face.geom_type() if callable(face.geom_type) else face.geom_type", + " geom_name = getattr(geom, 'name', str(geom)).lower()", + " type_penalty = 0.0 if not target_type or target_type in geom_name else 1000.0", + " bbox_penalty = sum(abs(face_box[i] - target_box[i]) for i in range(6))", + " area_penalty = abs(float(face.area) - target_area) / max(math.sqrt(abs(target_area)), 1.0) if target_area else 0.0", + " ranked.append((type_penalty + bbox_penalty + area_penalty, index))", + " except Exception:", + " continue", + " if not ranked:", + " total += 1e6", + " continue", + " best, index = min(ranked, key=lambda item: item[0])", + " total += best", + " available.pop(index)", + " return total / max(len(expected_faces), 1)", + "", + "def _candidate_score(shape, expected_faces=None):", + " # Owned faces describe this exact SW history step. Final-part mass properties", + " # must not be used to choose an intermediate feature candidate.", + " if expected_faces:", + " return _owned_face_match_score(shape, expected_faces)", + " score = 0", + " if SOURCE_VOLUME_MM3 is not None:", + " try:", + " score += abs(float(shape.volume) - SOURCE_VOLUME_MM3)", + " except Exception:", + " score += 1e99", + " if SOURCE_AREA_MM2 is not None:", + " try:", + " score += abs(float(shape.area) - SOURCE_AREA_MM2) * 0.01", + " except Exception:", + " score += 1e99", + " score += _owned_face_match_score(shape, expected_faces)", + " return score", + "", + "def _edge_endpoints(edge):", + " vertices = [v.to_tuple() for v in edge.vertices()]", + " if len(vertices) != 2:", + " center = edge.center().to_tuple()", + " return center, center", + " return vertices[0], vertices[1]", + "", + "def _edge_match_score(edge, start, end):", + " a, b = _edge_endpoints(edge)", + " endpoint_score = min(_dist(a, start) + _dist(b, end), _dist(a, end) + _dist(b, start))", + " containment_score = edge.distance_to(start) + edge.distance_to(end)", + " return min(endpoint_score, containment_score)", + "", + "def select_edges_by_endpoints(part, selector_points, tolerance=0.5):", + " edges = list(part.edges())", + " selected = []", + " used = set()", + " for selector in selector_points:", + " start, end = selector", + " ranked = sorted(((_edge_match_score(edge, start, end), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " score, index, edge = ranked[0]", + " if score > tolerance:", + " raise ValueError(f\"No edge matched selector {selector}; best score={score:.4f} mm\")", + " if index not in used:", + " selected.append(edge)", + " used.add(index)", + " return selected", + "", + "def _bbox_match_score(edge, bbox_mm):", + " if not bbox_mm or len(bbox_mm) < 6:", + " return float('inf')", + " try:", + " a, b = _edge_endpoints(edge)", + " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", + " mins = tuple(float(bbox_mm[i]) for i in range(3))", + " maxs = tuple(float(bbox_mm[i + 3]) for i in range(3))", + " diag = math.sqrt(sum((maxs[i] - mins[i]) ** 2 for i in range(3)))", + " pad = max(0.25, diag * 0.15)", + " def point_score(point):", + " total = 0.0", + " for axis in range(3):", + " if point[axis] < mins[axis] - pad:", + " total += mins[axis] - pad - point[axis]", + " elif point[axis] > maxs[axis] + pad:", + " total += point[axis] - maxs[axis] - pad", + " return total", + " return min(point_score(mid), (point_score(a) + point_score(b)) / 2)", + " except Exception:", + " return float('inf')", + "", + "def _circle_match_score(edge, circle_params):", + " if not circle_params or len(circle_params) < 7:", + " return float('inf')", + " try:", + " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", + " geom_name = getattr(geom_type, 'name', str(geom_type))", + " if 'CIRCLE' not in geom_name:", + " return float('inf')", + " target_center = tuple(float(v) * 1000 for v in circle_params[:3])", + " target_radius = float(circle_params[6]) * 1000", + " edge_center = edge.arc_center.to_tuple()", + " return _dist(edge_center, target_center) + abs(edge.radius - target_radius)", + " except Exception:", + " return float('inf')", + "", + "def _line_match_score(edge, line_params):", + " if not line_params or len(line_params) < 6:", + " return float('inf')", + " try:", + " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", + " geom_name = getattr(geom_type, 'name', str(geom_type))", + " if 'LINE' not in geom_name:", + " return float('inf')", + " target_point = tuple(float(v) * 1000 for v in line_params[:3])", + " target_dir = tuple(float(v) for v in line_params[3:6])", + " a, b = _edge_endpoints(edge)", + " edge_dir_raw = tuple(b[i] - a[i] for i in range(3))", + " length = math.sqrt(sum(v * v for v in edge_dir_raw))", + " if length <= 0:", + " return float('inf')", + " edge_dir = tuple(v / length for v in edge_dir_raw)", + " parallel = 1 - abs(sum(edge_dir[i] * target_dir[i] for i in range(3)))", + " distance = edge.distance_to(target_point)", + " return distance + parallel * 10", + " except Exception:", + " return float('inf')", + "", + "def select_edges_by_selectors(part, selectors, tolerance=0.5):", + " if part is None:", + " return []", + " edges = list(part.edges())", + " selected = []", + " used = set()", + " for selector in selectors or []:", + " geometry = selector.get('geometry', {})", + " start_vertex = geometry.get('start_vertex')", + " end_vertex = geometry.get('end_vertex')", + " start = start_vertex.get('point_m') if start_vertex else None", + " end = end_vertex.get('point_m') if end_vertex else None", + " bbox_mm = geometry.get('bbox_mm')", + " if start and end:", + " start_mm = tuple(float(v) * 1000 for v in start)", + " end_mm = tuple(float(v) * 1000 for v in end)", + " line_params = geometry.get('curve', {}).get('line_params')", + " if line_params:", + " ranked = sorted(((min(_edge_match_score(edge, start_mm, end_mm), _line_match_score(edge, line_params)) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " else:", + " ranked = sorted(((_edge_match_score(edge, start_mm, end_mm) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " else:", + " line_params = geometry.get('curve', {}).get('line_params')", + " circle_params = geometry.get('curve', {}).get('circle_params')", + " if line_params:", + " ranked = sorted(((_line_match_score(edge, line_params) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " elif bbox_mm:", + " ranked = sorted(((_bbox_match_score(edge, bbox_mm), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " else:", + " ranked = sorted(((_circle_match_score(edge, circle_params), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " score, index, edge = ranked[0]", + " selector_tolerance = float(selector.get('tolerance_mm') or tolerance)", + " if score > selector_tolerance:", + " # Skip edges that don't match well enough", + " continue", + " if index not in used:", + " selected.append(edge)", + " used.add(index)", + " return selected", + "", + "def _point_inside_bbox(point, bbox_mm, pad=0.25):", + " return all(float(bbox_mm[i]) - pad <= point[i] <= float(bbox_mm[i + 3]) + pad for i in range(3))", + "", + "def fillet_edges_from_owned_surface_bbox(part, selectors):", + " if part is None:", + " return []", + " boxes = []", + " seen_boxes = set()", + " for selector in selectors or []:", + " if selector.get('source') not in ('owned_cylindrical_face_axis', 'owned_face_bbox'):", + " continue", + " bbox = (selector.get('geometry') or {}).get('bbox_mm')", + " if bbox and len(bbox) >= 6:", + " normalized = [float(v) for v in bbox[:6]]", + " key = tuple(round(v, 6) for v in normalized)", + " if key not in seen_boxes:", + " seen_boxes.add(key)", + " boxes.append(normalized)", + " if len(boxes) < 2:", + " return []", + " selected = []", + " used_keys = set()", + " for box in boxes:", + " diag = math.sqrt(sum((box[i + 3] - box[i]) ** 2 for i in range(3)))", + " pad = max(0.25, diag * 0.08)", + " sizes = [abs(box[i + 3] - box[i]) for i in range(3)]", + " thin_axes = [i for i, size in enumerate(sizes) if size <= max(1.5, diag * 0.08)]", + " circle_candidates = []", + " if thin_axes:", + " thin_axis = thin_axes[0]", + " for edge in part.edges():", + " try:", + " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", + " geom_name = getattr(geom_type, 'name', str(geom_type))", + " if 'CIRCLE' not in geom_name:", + " continue", + " eb = edge.bounding_box()", + " edge_box = [eb.min.X, eb.min.Y, eb.min.Z, eb.max.X, eb.max.Y, eb.max.Z]", + " ok = True", + " score = 0.0", + " for axis in range(3):", + " if axis == thin_axis:", + " plane_delta = min(abs(edge_box[axis] - box[axis]), abs(edge_box[axis] - box[axis + 3]), abs(edge_box[axis + 3] - box[axis]), abs(edge_box[axis + 3] - box[axis + 3]))", + " if plane_delta > pad:", + " ok = False", + " break", + " score += plane_delta", + " else:", + " if edge_box[axis] < box[axis] - pad or edge_box[axis + 3] > box[axis + 3] + pad:", + " ok = False", + " break", + " score += abs(edge_box[axis] - box[axis]) + abs(edge_box[axis + 3] - box[axis + 3])", + " if not ok:", + " continue", + " key = tuple(round(v, 5) for v in edge_box)", + " circle_candidates.append((score, key, edge))", + " except Exception:", + " continue", + " if circle_candidates:", + " circle_candidates.sort(key=lambda item: item[0])", + " for _, key, edge in circle_candidates:", + " if key in used_keys:", + " continue", + " used_keys.add(key)", + " selected.append(edge)", + " break", + " continue", + " box_candidates = []", + " for edge in part.edges():", + " try:", + " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", + " geom_name = getattr(geom_type, 'name', str(geom_type))", + " if 'LINE' not in geom_name:", + " continue", + " a, b = _edge_endpoints(edge)", + " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", + " if not (_point_inside_bbox(a, box, pad) and _point_inside_bbox(b, box, pad) and _point_inside_bbox(mid, box, pad)):", + " continue", + " key = tuple(round(v, 5) for point in (a, b) for v in point)", + " box_candidates.append((float(edge.length), key, edge))", + " except Exception:", + " continue", + " if not box_candidates:", + " continue", + " box_candidates.sort(key=lambda item: item[0], reverse=True)", + " for _, key, edge in box_candidates:", + " reverse_key = key[3:] + key[:3]", + " if key in used_keys or reverse_key in used_keys:", + " continue", + " used_keys.add(key)", + " selected.append(edge)", + " break", + " if selected:", + " return selected", + " union_bbox = [", + " min(box[i] for box in boxes) if i < 3 else max(box[i] for box in boxes)", + " for i in range(6)", + " ]", + " diag = math.sqrt(sum((union_bbox[i + 3] - union_bbox[i]) ** 2 for i in range(3)))", + " pad = max(0.25, diag * 0.05)", + " candidates = []", + " for edge in part.edges():", + " try:", + " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", + " geom_name = getattr(geom_type, 'name', str(geom_type))", + " if 'LINE' not in geom_name:", + " continue", + " a, b = _edge_endpoints(edge)", + " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", + " if not (_point_inside_bbox(a, union_bbox, pad) and _point_inside_bbox(b, union_bbox, pad) and _point_inside_bbox(mid, union_bbox, pad)):", + " continue", + " candidates.append((float(edge.length), edge))", + " except Exception:", + " continue", + " if not candidates:", + " return []", + " candidates.sort(key=lambda item: item[0], reverse=True)", + " return [candidates[0][1]]", + "", + "def fillet_with_tolerance(edges, radius):", + " radii = [float(radius)]", + " shrink = max(0.001, abs(float(radius)) * 0.001)", + " if float(radius) > shrink:", + " radii.append(float(radius) - shrink)", + " radii.append(float(radius) * 0.99)", + " last_error = None", + " for candidate_radius in radii:", + " if candidate_radius <= 0:", + " continue", + " try:", + " return fillet(edges, radius=candidate_radius)", + " except Exception as exc:", + " last_error = exc", + " continue", + " if last_error:", + " raise last_error", + " return fillet(edges, radius=radius)", + "", + "def fillet_selected(part, radius, selectors, owned_faces=None):", + " if part is None:", + " return part", + " if not selectors:", + " # No edge selectors - skip fillet to avoid failing on all edges", + " return part", + " candidates = []", + " owned_edges = fillet_edges_from_owned_surface_bbox(part, selectors)", + " if owned_edges:", + " try:", + " candidates.append(fillet_with_tolerance(owned_edges, radius))", + " except Exception:", + " pass", + " try:", + " target_edges = select_edges_by_selectors(part, selectors)", + " if target_edges:", + " candidates.append(fillet_with_tolerance(target_edges, radius))", + " except Exception:", + " pass", + " result = part", + " applied_any = False", + " for selector in selectors:", + " edges = select_edges_by_selectors(result, [selector])", + " if not edges:", + " continue # Skip selectors that don't match any edge", + " try:", + " result = fillet_with_tolerance([edges[0]], radius)", + " applied_any = True", + " except Exception:", + " # OCC fillets are fragile: one invalid edge/radius should not abort the whole rebuild.", + " continue", + " if applied_any:", + " candidates.append(result)", + " variants = []", + " for selector in selectors:", + " edges = select_edges_by_selectors(part, [selector])", + " if not edges:", + " continue", + " try:", + " variants.append(fillet_with_tolerance([edges[0]], radius))", + " except Exception:", + " continue", + " if variants:", + " try:", + " union_result = part", + " for variant in variants:", + " union_result = union_result + variant", + " candidates.append(union_result)", + " except Exception:", + " pass", + " try:", + " intersection_result = part", + " for variant in variants:", + " intersection_result = intersection_result & variant", + " candidates.append(intersection_result)", + " except Exception:", + " pass", + " if candidates:", + " return sorted(candidates, key=lambda shape: _candidate_score(shape, owned_faces))[0]", + " return part", + "", + "def chamfer_selected(part, distance, selectors, owned_faces=None):", + " if part is None:", + " return part", + " if not selectors:", + " # No edge selectors available - chamfer would fail on all edges", + " return part", + " candidates = []", + " owned_edges = fillet_edges_from_owned_surface_bbox(part, selectors)", + " if owned_edges:", + " try:", + " candidates.append(chamfer(owned_edges, length=distance))", + " except Exception:", + " pass", + " target_edges = select_edges_by_selectors(part, selectors)", + " if target_edges:", + " try:", + " candidates.append(chamfer(target_edges, length=distance))", + " except Exception:", + " pass", + " result = part", + " applied_any = False", + " for selector in selectors:", + " edges = select_edges_by_selectors(result, [selector])", + " if not edges:", + " continue", + " try:", + " result = chamfer([edges[0]], length=distance)", + " applied_any = True", + " except Exception:", + " continue", + " if applied_any:", + " candidates.append(result)", + " if candidates:", + " return sorted(candidates, key=lambda shape: _candidate_score(shape, owned_faces))[0]", + " return part", + "", + "def is_internal_cone_face(face, part):", + " try:", + " bbox_m = face.get('box_m')", + " surface = face.get('surface') or {}", + " if not (bbox_m and len(bbox_m) >= 6 and surface.get('is_cone')):", + " return False", + " params = surface.get('cone_params')", + " if not params or len(params) < 6:", + " return False", + " direction = tuple(float(v) for v in params[3:6])", + " axis = max(range(3), key=lambda i: abs(direction[i]))", + " radial_axes = tuple(i for i in range(3) if i != axis)", + " part_bbox = part.bounding_box()", + " part_min = part_bbox.min.to_tuple()", + " part_max = part_bbox.max.to_tuple()", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " tol = 0.25", + " touches_outer = any(", + " abs(mins[i] - part_min[i]) <= tol or abs(maxs[i] - part_max[i]) <= tol", + " for i in radial_axes", + " )", + " return not touches_outer", + " except Exception:", + " return False", + "", + "def is_external_cone_face(face, part):", + " try:", + " bbox_m = face.get('box_m')", + " surface = face.get('surface') or {}", + " if not (bbox_m and len(bbox_m) >= 6 and surface.get('is_cone')):", + " return False", + " params = surface.get('cone_params')", + " if not params or len(params) < 6:", + " return False", + " direction = tuple(float(v) for v in params[3:6])", + " axis = max(range(3), key=lambda i: abs(direction[i]))", + " radial_axes = tuple(i for i in range(3) if i != axis)", + " part_bbox = part.bounding_box()", + " part_min = part_bbox.min.to_tuple()", + " part_max = part_bbox.max.to_tuple()", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " tol = 0.25", + " return any(", + " abs(mins[i] - part_min[i]) <= tol or abs(maxs[i] - part_max[i]) <= tol", + " for i in radial_axes", + " )", + " except Exception:", + " return False", + "", + "def make_owned_external_cone_chamfer_cutter(face):", + " surface = face.get('surface') or {}", + " params = surface.get('cone_params')", + " bbox_m = face.get('box_m')", + " if not params or len(params) < 8 or not bbox_m or len(bbox_m) < 6:", + " return None", + " origin = tuple(float(v) * 1000 for v in params[:3])", + " direction = tuple(float(v) for v in params[3:6])", + " norm = math.sqrt(sum(v * v for v in direction))", + " base_radius = abs(float(params[6]) * 1000)", + " half_angle = abs(float(params[7]))", + " if norm <= 1e-9 or base_radius <= 1e-9 or half_angle <= 1e-9:", + " return None", + " direction = tuple(v / norm for v in direction)", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " projections = []", + " for x in (mins[0], maxs[0]):", + " for y in (mins[1], maxs[1]):", + " for z in (mins[2], maxs[2]):", + " delta = (x - origin[0], y - origin[1], z - origin[2])", + " axial = sum(delta[i] * direction[i] for i in range(3))", + " projections.append(axial)", + " start = min(projections)", + " end = max(projections)", + " height = max(0.001, end - start)", + " r1 = max(0.0, base_radius - math.tan(half_angle) * start)", + " r2 = max(0.0, base_radius - math.tan(half_angle) * end)", + " outer_radius = max(r1, r2) + 0.001", + " center_offset = (start + end) / 2", + " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", + " if r1 <= 1e-9:", + " r1 = 1e-6", + " if r2 <= 1e-9:", + " r2 = 1e-6", + " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", + " Cylinder(outer_radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", + " Cone(r1, r2, height + 0.002, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT)", + " return cutter_part.part", + "", + "def chamfer_owned_external_cones(part, faces):", + " if part is None:", + " return part, False", + " result = part", + " applied = False", + " for face in faces or []:", + " if not is_external_cone_face(face, result):", + " continue", + " cutter = make_owned_external_cone_chamfer_cutter(face)", + " new_result = safe_subtract(result, cutter)", + " if new_result is not result:", + " result = new_result", + " applied = True", + " return result, applied", + "", + "def chamfer_owned_internal_cones(part, faces):", + " if part is None:", + " return part, False", + " result = part", + " applied = False", + " for face in faces or []:", + " if not is_internal_cone_face(face, result):", + " continue", + " cutter = make_owned_cone_cutter(face)", + " new_result = safe_subtract(result, cutter)", + " if new_result is not result:", + " result = new_result", + " applied = True", + " return result, applied", + "", + "def chamfer_selected_with_owned_faces(part, distance, selectors, owned_faces):", + " cone_faces = [face for face in (owned_faces or []) if (face.get('surface') or {}).get('is_cone')]", + " if len(cone_faces) == 1 and is_internal_cone_face(cone_faces[0], part):", + " result, applied = chamfer_owned_internal_cones(part, cone_faces)", + " if applied:", + " return result", + " if len(cone_faces) == 1 and is_external_cone_face(cone_faces[0], part):", + " result, applied = chamfer_owned_external_cones(part, cone_faces)", + " if applied:", + " return result", + " return chamfer_selected(part, distance, selectors, owned_faces)", + "", + "def safe_subtract(part, cutter):", + " if part is None or cutter is None:", + " return part", + " try:", + " vol_before = float(part.volume)", + " except Exception:", + " vol_before = -1", + " try:", + " cut = part - cutter", + " if cut is None:", + " print(f' SUBTRACT: cutter resulted in None, keeping original (vol={vol_before:.0f})')", + " return part", + " # Accept the cut even when solids() reports 0 – can happen", + " # for valid boolean results with non-standard structures.", + " try:", + " nb_solids = len(list(cut.solids()))", + " if nb_solids == 0:", + " print(f' SUBTRACT: cut produced 0 solids (still accepting) vol={vol_before:.0f}')", + " except Exception:", + " pass", + " return cut", + " except Exception as e:", + " print(f' SUBTRACT: exception {type(e).__name__}: {e}, keeping original (vol={vol_before:.0f})')", + " return part", + "", + "def _project_bbox_along_direction(bbox, origin, direction):", + " mins = tuple(float(bbox[i]) for i in range(3))", + " maxs = tuple(float(bbox[i + 3]) for i in range(3))", + " projections = []", + " for x in (mins[0], maxs[0]):", + " for y in (mins[1], maxs[1]):", + " for z in (mins[2], maxs[2]):", + " projections.append(sum(((x, y, z)[i] - origin[i]) * direction[i] for i in range(3)))", + " return min(projections), max(projections)", + "", + "def make_owned_cylinder_cutter(face, target_part=None):", + " surface = face.get('surface') or {}", + " params = surface.get('cylinder_params')", + " bbox_m = face.get('box_m')", + " if not params or len(params) < 7 or not bbox_m or len(bbox_m) < 6:", + " return None", + " origin = tuple(float(v) * 1000 for v in params[:3])", + " direction = tuple(float(v) for v in params[3:6])", + " norm = math.sqrt(sum(v * v for v in direction))", + " radius = abs(float(params[6]) * 1000)", + " if norm <= 1e-9 or radius <= 1e-9:", + " return None", + " direction = tuple(v / norm for v in direction)", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " start, end = _project_bbox_along_direction((*mins, *maxs), origin, direction)", + " if target_part is not None:", + " try:", + " part_bbox = target_part.bounding_box()", + " part_box = (*part_bbox.min.to_tuple(), *part_bbox.max.to_tuple())", + " part_start, part_end = _project_bbox_along_direction(part_box, origin, direction)", + " through_tolerance = max(1.0, radius * 0.12)", + " if abs(start - part_start) <= through_tolerance:", + " start = part_start", + " if abs(end - part_end) <= through_tolerance:", + " end = part_end", + " except Exception:", + " pass", + " height = max(0.001, end - start)", + " center_offset = (start + end) / 2", + " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", + " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", + " Cylinder(radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", + " return cutter_part.part", + "", + "def make_owned_cone_cutter(face):", + " surface = face.get('surface') or {}", + " params = surface.get('cone_params')", + " bbox_m = face.get('box_m')", + " if not params or len(params) < 8 or not bbox_m or len(bbox_m) < 6:", + " return None", + " origin = tuple(float(v) * 1000 for v in params[:3])", + " direction = tuple(float(v) for v in params[3:6])", + " norm = math.sqrt(sum(v * v for v in direction))", + " base_radius = abs(float(params[6]) * 1000)", + " half_angle = abs(float(params[7]))", + " if norm <= 1e-9 or base_radius <= 1e-9 or half_angle <= 1e-9:", + " return None", + " direction = tuple(v / norm for v in direction)", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " projections = []", + " for x in (mins[0], maxs[0]):", + " for y in (mins[1], maxs[1]):", + " for z in (mins[2], maxs[2]):", + " projections.append(sum(((x, y, z)[i] - origin[i]) * direction[i] for i in range(3)))", + " start = min(projections)", + " end = max(projections)", + " # Keep a tiny overlap for the boolean while preserving blind-hole depth.", + " height = max(0.001, end - start) + 0.001", + " # SolidWorks ConeParams stores the radius at the cone origin; along the axis", + " # direction the radius tapers rather than expands for hole drill tips.", + " r1 = max(0.0, base_radius - math.tan(half_angle) * start)", + " r2 = max(0.0, base_radius - math.tan(half_angle) * end)", + " if max(r1, r2) <= 1e-9:", + " return None", + " if r1 <= 1e-9:", + " r1 = 1e-6", + " if r2 <= 1e-9:", + " r2 = 1e-6", + " center_offset = (start + end) / 2", + " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", + " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", + " Cone(r1, r2, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", + " return cutter_part.part", + "", + "def make_owned_face_cutter(face, target_part=None):", + " surface = face.get('surface') or {}", + " if surface.get('is_cylinder'):", + " return make_owned_cylinder_cutter(face, target_part)", + " if surface.get('is_cone'):", + " return make_owned_cone_cutter(face)", + " return None", + "", + "def cut_owned_cylindrical_faces(part, faces):", + " result = part", + " for face in faces or []:", + " cutter = make_owned_face_cutter(face, result)", + " result = safe_subtract(result, cutter)", + " return result", + "", + "def make_owned_flip_side_ring_cutter(face, target_part):", + " surface = face.get('surface') or {}", + " params = surface.get('cylinder_params')", + " bbox_m = face.get('box_m')", + " if target_part is None or not params or len(params) < 7 or not bbox_m or len(bbox_m) < 6:", + " return None", + " origin = tuple(float(v) * 1000 for v in params[:3])", + " direction = tuple(float(v) for v in params[3:6])", + " norm = math.sqrt(sum(v * v for v in direction))", + " inner_radius = abs(float(params[6]) * 1000)", + " if norm <= 1e-9 or inner_radius <= 1e-9:", + " return None", + " direction = tuple(v / norm for v in direction)", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " start, end = _project_bbox_along_direction((*mins, *maxs), origin, direction)", + " height = max(0.001, end - start)", + " center_offset = (start + end) / 2", + " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", + " try:", + " part_bbox = target_part.bounding_box()", + " part_min = part_bbox.min.to_tuple()", + " part_max = part_bbox.max.to_tuple()", + " radial = []", + " for x in (part_min[0], part_max[0]):", + " for y in (part_min[1], part_max[1]):", + " for z in (part_min[2], part_max[2]):", + " delta = (x - origin[0], y - origin[1], z - origin[2])", + " axial = sum(delta[i] * direction[i] for i in range(3))", + " perp = tuple(delta[i] - axial * direction[i] for i in range(3))", + " radial.append(math.sqrt(sum(v * v for v in perp)))", + " outer_radius = max(radial) + max(1.0, inner_radius * 0.05)", + " except Exception:", + " outer_radius = inner_radius + 100.0", + " if outer_radius <= inner_radius + 1e-6:", + " return None", + " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", + " Cylinder(outer_radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", + " Cylinder(inner_radius, height + 0.002, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT)", + " return cutter_part.part", + "", + "def cut_owned_flip_side_cylindrical_faces(part, faces):", + " result = part", + " for face in faces or []:", + " cutter = make_owned_flip_side_ring_cutter(face, result)", + " result = safe_subtract(result, cutter)", + " return result", + "", + "def cut_owned_bbox(part, bbox_mm):", + " if part is None or not bbox_mm or len(bbox_mm) < 6:", + " return part", + " mins = tuple(float(bbox_mm[i]) for i in range(3))", + " maxs = tuple(float(bbox_mm[i + 3]) for i in range(3))", + " size = tuple(max(0.001, maxs[i] - mins[i]) for i in range(3))", + " center = tuple((mins[i] + maxs[i]) / 2 for i in range(3))", + " cutter = Pos(center) * Box(size[0], size[1], size[2])", + " return safe_subtract(part, cutter)", + "", + "def shape_face_count(shape):", + " if shape is None:", + " return 0", + " try:", + " return len(list(shape.faces()))", + " except Exception:", + " return 0", + "", + "def safe_union(part, solid, preserve_visible=False):", + " if part is None:", + " return solid", + " if solid is None:", + " return part", + " try:", + " fused = part + solid", + " # OCCT fuse succeeded; always return the fused result.", + " # is_valid() can return False for edge cases where the geometry", + " # is actually correct (e.g. touching-at-faces). Accept it.", + " return fused", + " except Exception as e:", + " print(f' UNION: fuse threw {type(e).__name__}: {e}')", + " pass", + " try:", + " compound = Compound.make_composite([part, solid])", + " fused = compound.fuse()", + " try:", + " if len(list(fused.solids())) > 0:", + " print(f' UNION: compound.fuse() worked, {len(list(fused.solids()))} solids')", + " return fused", + " except Exception:", + " pass", + " except Exception as e:", + " print(f' UNION: compound.fuse() threw {type(e).__name__}: {e}')", + " pass", + " shapes = []", + " try:", + " shapes.extend(list(part.solids()))", + " except Exception:", + " shapes.append(part)", + " try:", + " shapes.extend(list(solid.solids()))", + " except Exception:", + " shapes.append(solid)", + " return Compound.make_composite(shapes)", + "", + "def sw_inverted_profile_cut(part, profile_solid, normal):", + " if part is None or profile_solid is None:", + " return part", + " try:", + " part_bbox = part.bounding_box()", + " profile_bbox = profile_solid.bounding_box()", + " n = tuple(float(v) for v in normal)", + " axis = max(range(3), key=lambda i: abs(n[i]))", + " part_min = part_bbox.min.to_tuple()", + " part_max = part_bbox.max.to_tuple()", + " prof_min = profile_bbox.min.to_tuple()", + " prof_max = profile_bbox.max.to_tuple()", + " margin = 5.0", + " mins = [part_min[i] - margin for i in range(3)]", + " maxs = [part_max[i] + margin for i in range(3)]", + " mins[axis] = prof_min[axis] - margin * 0.05", + " maxs[axis] = prof_max[axis] + margin * 0.05", + " center = tuple((mins[i] + maxs[i]) / 2 for i in range(3))", + " size = tuple(max(0.001, maxs[i] - mins[i]) for i in range(3))", + " envelope = Pos(center) * Box(size[0], size[1], size[2])", + " outside_profile = safe_subtract(envelope, profile_solid)", + " return safe_subtract(part, outside_profile)", + " except Exception:", + " return part", + "", + "def sw_flip_side_step_cut(part, profile_solid, normal, outer_radius_mm, inner_radius_mm):", + " part = sw_inverted_profile_cut(part, profile_solid, normal)", + " if part is None or profile_solid is None:", + " return part", + " try:", + " outer_radius = abs(float(outer_radius_mm))", + " inner_radius = abs(float(inner_radius_mm))", + " except Exception:", + " return part", + " if outer_radius <= inner_radius + 1e-6:", + " return part", + " try:", + " profile_bbox = profile_solid.bounding_box()", + " prof_min = profile_bbox.min.to_tuple()", + " prof_max = profile_bbox.max.to_tuple()", + " center = tuple((prof_min[i] + prof_max[i]) / 2 for i in range(3))", + " n = tuple(float(v) for v in normal)", + " axis = max(range(3), key=lambda i: abs(n[i]))", + " span_xy = max(prof_max[0] - prof_min[0], prof_max[1] - prof_min[1])", + " margin_xy = max(2.0, span_xy * 0.05)", + " margin_z = 0.1", + " size = tuple(", + " max(0.001, prof_max[i] - prof_min[i] + (margin_xy if i < 2 else margin_z))", + " for i in range(3)", + " )", + " plane = Plane(", + " origin=center,", + " x_dir=(1.0, 0.0, 0.0) if axis != 0 else (0.0, 1.0, 0.0),", + " z_dir=n,", + " )", + " cut_extent = prof_max[axis] - prof_min[axis]", + " cut_amount = -abs(cut_extent) if n[axis] < 0 else abs(cut_extent)", + " with BuildSketch(plane) as ring_sketch:", + " Circle(outer_radius)", + " Circle(inner_radius, mode=Mode.SUBTRACT)", + " ring = extrude(ring_sketch.sketch, amount=cut_amount)", + " return safe_union(part, ring)", + " except Exception:", + " return part", + "", + "def sw_cut_holes(part, positions, host_face, diameter, depth, drill_angle=0, include_drill_tip=False, countersink_diameter=0, countersink_angle=0, counterbore_diameter=0, counterbore_depth=0):", + " if part is None:", + " return part", + " if not positions or diameter <= 0 or depth <= 0:", + " return part", + " plane = host_face.get('surface', {}).get('plane_params') or [0, 0, 1, 0, 0, 0]", + " frame = host_face.get('frame') or {}", + " normal = tuple(float(v) for v in plane[:3])", + " plane_point = tuple(float(v) * 1000 for v in plane[3:6])", + " origin = tuple(float(v) for v in frame.get('origin_mm', plane_point))", + " x_dir = tuple(float(v) for v in frame.get('x_dir', (0, 0, 0)))", + " y_dir = tuple(float(v) for v in frame.get('y_dir', (0, 0, 0)))", + " has_frame = sum(abs(v) for v in x_dir) > 0 and sum(abs(v) for v in y_dir) > 0", + " bbox = part.bounding_box()", + " part_center = tuple((bbox.min.to_tuple()[i] + bbox.max.to_tuple()[i]) / 2 for i in range(3))", + " toward_center = tuple(part_center[i] - plane_point[i] for i in range(3))", + " dot = sum(toward_center[i] * normal[i] for i in range(3))", + " inward = normal if dot >= 0 else tuple(-v for v in normal)", + " axis = max(range(3), key=lambda i: abs(inward[i]))", + " rotation = (0, 0, 0)", + " if axis == 0:", + " rotation = (0, 90, 0) if inward[0] >= 0 else (0, -90, 0)", + " elif axis == 1:", + " rotation = (-90, 0, 0) if inward[1] >= 0 else (90, 0, 0)", + " elif inward[2] < 0:", + " rotation = (180, 0, 0)", + " tip_depth = 0", + " if include_drill_tip and drill_angle > 0:", + " tip_depth = (diameter / 2) / math.tan(drill_angle / 2)", + " countersink_depth = 0", + " if countersink_diameter > diameter and countersink_angle > 0:", + " countersink_depth = ((countersink_diameter - diameter) / 2) / math.tan(countersink_angle / 2)", + " result = part", + " for pos in positions:", + " x, y = float(pos[0]), float(pos[1])", + " if has_frame:", + " start = tuple(origin[i] + x_dir[i] * x + y_dir[i] * y for i in range(3))", + " elif axis == 0:", + " start = (plane_point[0], x, y)", + " elif axis == 1:", + " start = (x, plane_point[1], -y)", + " else:", + " start = (x, y, plane_point[2])", + " cut_depth = depth", + " if depth >= 199:", + " part_min = bbox.min.to_tuple()", + " part_max = bbox.max.to_tuple()", + " corners = []", + " for ci in range(2):", + " for cj in range(2):", + " for ck in range(2):", + " corners.append((", + " part_min[0] if ci else part_max[0],", + " part_min[1] if cj else part_max[1],", + " part_min[2] if ck else part_max[2],", + " ))", + " cut_depth = max(", + " sum((corner[i] - start[i]) * inward[i] for i in range(3))", + " for corner in corners", + " ) + 2.0", + " cutters = []", + " cb_depth = counterbore_depth if counterbore_diameter > diameter and counterbore_depth > 0 else 0", + " cs_depth = countersink_depth if countersink_depth > 0 else 0", + " hole_start = cs_depth", + " hole_depth = max(0.001, cut_depth - hole_start - cb_depth)", + " if hole_depth > 0:", + " hole_center = tuple(start[i] + inward[i] * (hole_start + cb_depth + hole_depth / 2) for i in range(3))", + " cutters.append(Pos(hole_center) * Cylinder(diameter / 2, hole_depth, rotation=rotation))", + " if cb_depth > 0:", + " cb_center = tuple(start[i] + inward[i] * (hole_start + cb_depth / 2) for i in range(3))", + " cutters.append(Pos(cb_center) * Cylinder(counterbore_diameter / 2, cb_depth, rotation=rotation))", + " if cs_depth > 0:", + " cs_center = tuple(start[i] + inward[i] * cs_depth / 2 for i in range(3))", + " cs = Pos(cs_center) * Cone(countersink_diameter / 2, diameter / 2, cs_depth, rotation=rotation)", + " cutters.append(cs)", + " if tip_depth > 0:", + " base = tuple(start[i] + inward[i] * cut_depth for i in range(3))", + " tip_center = tuple(base[i] + inward[i] * tip_depth / 2 for i in range(3))", + " tip = Pos(tip_center) * Cone(diameter / 2, 0, tip_depth, rotation=rotation)", + " cutters.append(tip)", + " if len(cutters) == 1:", + " cutter = cutters[0]", + " else:", + " cutter = Compound.make_composite(cutters)", + " result = safe_subtract(result, cutter)", + " return result", + "", +] diff --git a/backend/tests/test_engine_extrude_draft_contract.py b/backend/tests/test_engine_extrude_draft_contract.py index 62e8da92..606f90cb 100644 --- a/backend/tests/test_engine_extrude_draft_contract.py +++ b/backend/tests/test_engine_extrude_draft_contract.py @@ -1,41 +1,17 @@ -"""#2 draft 假接受陷阱:extrudeParams.draft 必须被显式拒绝,而不是静默忽略。 +"""Extrude draft contracts distinguish executable and unsupported extents. 中文说明 -------- 这个文件在测试什么(issue #2「draft 被 schema 接受但 runtime 未执行」的回归测试): - 1. 背景:三方合同错位—— - - 机器契约 cdsl_schema.json(extrudeParams,约 101 行)允许 - "draft": {"type": "object"},且是空 object(无任何子字段约束), - 即"文档格式假接受"; - - 人读契约 profile_schema.json 的 extrude_add_blind / - extrude_add_two_sided / extrude_cut_blind 均**未**声明 draft - 为 optional_params; - - runtime(build123d_adapter.extrude 仅调 Solid.extrude,无锥形 - 拉伸)也完全没有 draft 实现,遇到 draft 就静默忽略。 - - 隐患:importer(translator.py 已解析 SolidWorks 的 - draft_angle_rad / reverse_draft_angle_rad)一旦把拔模角写进 - CDSL params,runtime 会静默产出**无拔模角的直壁实体**—— - 注塑件/压铸件丢失脱模斜度,脱模卡死、分型面配合错误,且全程 - 无警告(与 #1 y_dir / #3 revolve.reverse 同族的静默错误)。 + CADFS lowering now maps a single-sided blind draft to + ``Build123dGeometryAdapter.extrude_taper``. The CDSL machine schema uses + ``angle_deg`` plus ``pull_direction`` for that executable contract. - 2. 修复策略:因为 build123d 内核没有锥形拉伸能力、且人读契约未声明 - draft,正确的合同是"显式拒绝"而不是"实现拔模"—— - capabilities.py 对携带 draft 的 extrude 特征报 unsupported_draft - blocker(与 unsupported_extent 同模式)。schema 字段保留(文档格式 - 契约,importer 未来可能产出),能力层明确划界。 - - 3. 本测试套件把"draft 必须显式拒绝"固定下来: - - 主契约:带 draft 的 extrude 特征 → analyze 报 unsupported_draft - blocker,runtime_eligible=False; - - 回归护栏:不带 draft 的 extrude 特征 → 仍 runtime_eligible; - - 文档格式契约:带 draft 的文档仍能通过 cdsl_schema.json(拒绝 - 发生在能力层,不是 schema 层); - - 覆盖:extrude_add_blind / extrude_add_two_sided / extrude_cut_blind - 三种原子都报同一 blocker(同一检查全类生效); - - 端到端:rebuild_cdsl(strict)带 draft → 抛 ValueError(大声失败), - analyze_document → runtime_eligible=False 且不 built(批量重建 - 不被静默污染)。 + A two-sided or non-blind draft has no defined neutral-plane semantics in + the current CDSL runtime. It must remain in the input document but stop + with ``unsupported_draft_extent`` before execution. Non-canonical draft + fields are protocol errors and are rejected by the machine schema. 4. sys.path 说明:把 backend/engine 加入搜索路径,是为了直接 import cdsl_engine 包做端到端测试(与 test_engine_revolve_reverse.py 风格 @@ -97,15 +73,16 @@ def _rectangle(minimum: list[float], maximum: list[float]) -> dict: def _extrude_cdsl(*, atomic_id: str = "extrude_add_blind", with_draft: bool = True) -> dict: """构造最小 extrude 文档。 - - with_draft=True 时 params 携带 draft 对象(任意非空 object 即可, - 因为 cdsl_schema.json 对 draft 没有子字段约束); + - with_draft=True 时 params 携带可执行的单侧盲向 draft 对象; - with_draft=False 时完全不写 draft 字段(回归护栏用)。 - atomic_id 可切换 extrude_add_blind / extrude_add_two_sided / extrude_cut_blind 三种原子,验证同一 blocker 检查对全类生效。 """ params: dict = {"distance_mm": 10.0} + if atomic_id == "extrude_add_two_sided": + params["reverse_distance_mm"] = 10.0 if with_draft: - params["draft"] = {"angle_deg": 5.0, "direction": "toward_sketch"} + params["draft"] = {"angle_deg": 5.0, "pull_direction": True} return { "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "draft-contract", "meta": {"unit": "mm"}, @@ -116,7 +93,7 @@ def _extrude_cdsl(*, atomic_id: str = "extrude_add_blind", with_draft: bool = Tr }]}, "features": [{ "id": "base_add", "atomic_id": atomic_id, "depends_on": [], "sketch_id": "base", - "params": params, + "params": params, "execution_status": "supported", }], } @@ -134,20 +111,13 @@ def _validate_against_cdsl_schema(doc: dict) -> None: class ExtrudeDraftContractTests(unittest.TestCase): """draft 假接受陷阱「文档格式-能力边界-运行时」三方合同的回归测试。""" - def test_draft_extrude_is_explicitly_blocked(self) -> None: - """主契约:带 draft 的 extrude 特征必须被显式拒绝,而不是静默通过。 - - 修复前(当前):capabilities 对 extrude 只检查 end_condition, - draft 字段完全无人过问 → analyze 报 runtime_eligible=True, - rebuild 静默产出直壁实体 → 本测试红灯。 - 修复后:capabilities 报 unsupported_draft blocker → - runtime_eligible=False → 绿灯。 - """ + def test_blind_draft_extrude_is_runtime_eligible(self) -> None: + """A canonical single-sided blind draft reaches the taper executor.""" analysis = analyze_cdsl(_extrude_cdsl(with_draft=True)) - self.assertFalse(analysis.runtime_eligible) + self.assertTrue(analysis.runtime_eligible) result = next(item for item in analysis.feature_results if item.feature_id == "base_add") - self.assertIn("unsupported_draft", [blocker.code for blocker in result.blockers]) + self.assertNotIn("unsupported_draft_extent", [blocker.code for blocker in result.blockers]) def test_draft_free_extrude_stays_eligible(self) -> None: """回归护栏:不带 draft 的 extrude 特征仍必须 runtime_eligible。 @@ -160,52 +130,38 @@ class ExtrudeDraftContractTests(unittest.TestCase): self.assertTrue(analysis.runtime_eligible) def test_draft_passes_machine_schema(self) -> None: - """文档格式契约:带 draft 的文档仍能通过 cdsl_schema.json 校验。 - - cdsl_schema.json(extrudeParams)保留 draft 字段,拒绝发生在 - 能力层(capabilities),不是 schema 层。这条测试锁死"schema 允许 - + 能力拒绝"的分层职责,防止未来把 schema 改过头(删掉字段后 - importer 未来产出 draft 会直接被 schema 打回,失去可诊断性)。 - """ + """Canonical draft input is a valid CDSL document for each extrude form.""" _validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_add_blind", with_draft=True)) _validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_add_two_sided", with_draft=True)) _validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_cut_blind", with_draft=True)) - def test_draft_blocks_every_extrude_atomic(self) -> None: - """覆盖:三种 extrude 原子都报同一个 unsupported_draft blocker。 + def test_two_sided_draft_is_explicitly_blocked(self) -> None: + analysis = analyze_cdsl(_extrude_cdsl(atomic_id="extrude_add_two_sided", with_draft=True)) - draft 检查挂在 _SKETCH_ATOM_PREFIXES(extrude_/revolve_)公共入口, - 必须对 extrude_add_blind / extrude_add_two_sided / extrude_cut_blind - 同时生效,而不是只修了某一个。 - """ - for atomic_id in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"): - with self.subTest(atomic_id=atomic_id): - analysis = analyze_cdsl(_extrude_cdsl(atomic_id=atomic_id, with_draft=True)) - result = next(item for item in analysis.feature_results if item.feature_id == "base_add") - self.assertIn("unsupported_draft", [blocker.code for blocker in result.blockers]) + self.assertFalse(analysis.runtime_eligible) + result = next(item for item in analysis.feature_results if item.feature_id == "base_add") + self.assertIn("unsupported_draft_extent", [blocker.code for blocker in result.blockers]) - def test_draft_rebuild_fails_loudly_not_silently(self) -> None: - """端到端:draft 文档的重建必须大声失败,而不是静默产出直壁实体。 + def test_blind_draft_rebuilds_and_batch_reports_an_artifact(self) -> None: + """The supported branch builds a STEP artifact through both entry points.""" + with tempfile.TemporaryDirectory() as directory: + out_step = Path(directory) / "part.step" + rebuild_cdsl(_extrude_cdsl(with_draft=True), out_step) + self.assertTrue(out_step.exists()) - 修复前:rebuild_cdsl(strict)对 draft 视而不见 → 正常返回实体, - volume > 0,但几何是**没有拔模角的直壁**——静默错误。 - 修复后:rebuild_cdsl(strict)因 runtime_eligible=False 抛 - ValueError(feature is not runtime eligible: unsupported_draft); - analyze_document 报告 runtime_eligible=False 且不 built—— - 批量重建不会被静默污染。 - """ - # 1) strict 重建直接抛错(大声失败)。 - with self.assertRaisesRegex(ValueError, "unsupported_draft"): - with tempfile.TemporaryDirectory() as directory: - rebuild_cdsl(_extrude_cdsl(with_draft=True), Path(directory) / "part.step") - - # 2) 批量层 analyze_document 报告不可执行且不产出 STEP。 cdsl = _extrude_cdsl(with_draft=True) with tempfile.TemporaryDirectory() as directory: out_step = Path(directory) / "part.step" report = _analyze_inline(cdsl, out_step) - self.assertFalse(report["runtime_eligible"]) - self.assertFalse(report.get("built", False)) + self.assertTrue(out_step.exists()) + self.assertTrue(report["runtime_eligible"]) + self.assertTrue(report["built"]) + + def test_noncanonical_draft_is_rejected_by_machine_schema(self) -> None: + cdsl = _extrude_cdsl(with_draft=True) + cdsl["features"][0]["params"]["draft"] = {"angle_deg": 5.0, "direction": "toward_sketch"} + with self.assertRaises(jsonschema.ValidationError): + _validate_against_cdsl_schema(cdsl) def _analyze_inline(cdsl: dict, out_step: Path) -> dict: diff --git a/backend/tests/test_profile_schema.py b/backend/tests/test_profile_schema.py index df41b7c4..dd180c04 100644 --- a/backend/tests/test_profile_schema.py +++ b/backend/tests/test_profile_schema.py @@ -60,6 +60,28 @@ class ProfileSchemaTests(unittest.TestCase): ) self.assertEqual(set(self.schema["profiles"]), set(self.schema["runtime_supported_profiles"])) + def test_runtime_capability_flags_are_complete_and_structurally_sound(self) -> None: + contracts = self.schema["operation_contracts"] + flags = ("body_mutating", "requires_active_body", "replayable", "requires_selector", "open_profile_ok") + for atomic_id, contract in contracts.items(): + capability = contract.get("runtime_capability") + self.assertIsInstance(capability, dict, atomic_id) + for flag in flags: + self.assertIsInstance(capability.get(flag), bool, f"{atomic_id}.{flag}") + for atomic_id, contract in contracts.items(): + capability = contract["runtime_capability"] + if capability["replayable"]: + # Only body-mutating features (or pattern replay itself) can be + # the source of another pattern replay. + self.assertTrue( + capability["body_mutating"] or atomic_id.startswith("pattern_"), + atomic_id, + ) + if capability["requires_active_body"]: + self.assertTrue(capability["body_mutating"], atomic_id) + if capability["open_profile_ok"]: + self.assertTrue(atomic_id.startswith("extrude_cut"), atomic_id) + def test_rejects_unsupported_atomic_operation_before_rebuild(self) -> None: cdsl = { "schema": "cad.cdsl.llm.v1",