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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
"",
|
||||
]
|
||||
Reference in New Issue
Block a user