Merge branch 'codex/integrate-ganjihong-refactor-2' into lk_dev

# Conflicts:
#	backend/app/cad_agent/domain/operation_contract.py
#	backend/engine/cdsl_engine/runtime.py
#	backend/engine/cdsl_engine/runtime_types.py
#	backend/engine/cdsl_engine/semantic_validation.py
This commit is contained in:
2026-09-09 18:50:31 +08:00
45 changed files with 12168 additions and 11395 deletions
+49 -12
View File
@@ -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/<family>.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
+11 -285
View File
@@ -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)
+33 -31
View File
@@ -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):
@@ -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}")
@@ -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)
@@ -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
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
+231
View File
@@ -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,
)
@@ -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
@@ -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
@@ -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()
@@ -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()
+8 -283
View File
@@ -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"]
+8 -551
View File
@@ -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"]
@@ -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
@@ -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
+37 -35
View File
@@ -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": {
+302 -531
View File
@@ -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()
+83
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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")
+279
View File
@@ -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
+619
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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",
]
File diff suppressed because it is too large Load Diff
@@ -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),
]
File diff suppressed because it is too large Load Diff
@@ -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",
"",
]