feat(cadfs): expand reconstruction capability coverage
This commit is contained in:
@@ -5,25 +5,31 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import Any, Iterable
|
||||
|
||||
from build123d import Axis, Compound, Edge, Face, Location, Plane, ShapeList, Shell, Solid, Vector, Wire, export_step
|
||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
|
||||
from build123d import AngularDirection, Axis, Compound, Edge, Face, Location, Plane, ShapeList, Shell, Solid, Vector, Wire, export_step
|
||||
from OCP.BOPAlgo import BOPAlgo_Splitter
|
||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
|
||||
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
|
||||
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
|
||||
from OCP.BRepOffset import BRepOffset_Skin
|
||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
|
||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol
|
||||
from OCP.Geom import Geom_SurfaceOfRevolution
|
||||
from OCP.GeomAbs import GeomAbs_Arc
|
||||
from OCP.LocOpe import LocOpe_DPrism
|
||||
from OCP.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle
|
||||
from OCP.TopAbs import TopAbs_SHELL
|
||||
from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopTools import TopTools_ListOfShape
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Vec
|
||||
from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
||||
|
||||
from .parametric_bend import build_bend_solid
|
||||
from .parametric_thread import build_thread_solid
|
||||
from .runtime_types import AxisSpec, BendSpec, HoleSpec, PlaneSpec, ThreadSpec, TopologyRecord, Vector3, canonical_plane_signature
|
||||
from .runtime_types import (
|
||||
AxisSpec, BendSpec, HoleSpec, PlaneSpec, ThreadSpec, TopologyDelta,
|
||||
TopologyDeltaRelation, TopologyRecord, Vector3, canonical_plane_signature,
|
||||
)
|
||||
|
||||
|
||||
def _vector(value: list[float] | tuple[float, float, float]) -> Vector:
|
||||
@@ -68,6 +74,9 @@ def _arc_midpoint(edge: dict[str, Any], start: Vector, end: Vector, center: Vect
|
||||
class Build123dGeometryAdapter:
|
||||
"""All B-rep construction and mutation lives in this adapter."""
|
||||
|
||||
CONTACT_FUSE_TOLERANCE_MM = 1e-7
|
||||
COINCIDENT_FUSE_TOLERANCE_MM = 1e-3
|
||||
|
||||
@staticmethod
|
||||
def plane(spec: PlaneSpec) -> Plane:
|
||||
# 将运行时平面定义 PlaneSpec 转换为 build123d 的 Plane。
|
||||
@@ -83,20 +92,48 @@ class Build123dGeometryAdapter:
|
||||
# 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。
|
||||
built: list[Edge] = []
|
||||
for edge in edges:
|
||||
if edge.get("type") == "circle":
|
||||
center = _vector(edge["center_mm"])
|
||||
x_dir = _vector(edge.get("x_dir_mm") or [1, 0, 0])
|
||||
normal = _vector(edge.get("normal") or [0, 0, 1])
|
||||
radius = float(edge.get("radius_mm") or 0.0)
|
||||
if radius <= 0.0 or x_dir.length <= 1e-9 or normal.length <= 1e-9:
|
||||
raise ValueError("circle contour edge has a degenerate frame")
|
||||
direction = AngularDirection.CLOCKWISE if bool(edge.get("clockwise")) else AngularDirection.COUNTER_CLOCKWISE
|
||||
built.append(Edge.make_circle(radius, Plane(origin=center, x_dir=x_dir, z_dir=normal), angular_direction=direction))
|
||||
continue
|
||||
if edge.get("type") == "bspline":
|
||||
points = [_vector(point) for point in edge.get("points_mm") or []]
|
||||
if len(points) < 3:
|
||||
raise ValueError("bspline contour edge needs at least 3 points")
|
||||
parameters = edge.get("parameters")
|
||||
start_tangent = edge.get("start_tangent_mm")
|
||||
end_tangent = edge.get("end_tangent_mm")
|
||||
if len(points) < 2:
|
||||
raise ValueError("bspline contour edge needs at least 2 points")
|
||||
if (start_tangent is None) != (end_tangent is None):
|
||||
raise ValueError("bspline contour edge requires both endpoint tangents")
|
||||
if len(points) == 2:
|
||||
if math.dist(
|
||||
(points[0].X, points[0].Y, points[0].Z),
|
||||
(points[1].X, points[1].Y, points[1].Z),
|
||||
) <= 1e-5:
|
||||
raise ValueError("two-point bspline contour edge endpoints must be distinct")
|
||||
if bool(edge.get("periodic")) or start_tangent is None or end_tangent is None:
|
||||
raise ValueError("two-point bspline contour edge requires non-periodic endpoint tangents")
|
||||
if not isinstance(parameters, list) or len(parameters) != 2:
|
||||
raise ValueError("two-point bspline contour edge requires explicit parameters")
|
||||
try:
|
||||
parameter_values = [float(value) for value in parameters]
|
||||
except (OverflowError, TypeError, ValueError) as exc:
|
||||
raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing") from exc
|
||||
if not all(math.isfinite(value) for value in parameter_values) or parameter_values[1] - parameter_values[0] <= 1e-5:
|
||||
raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing")
|
||||
else:
|
||||
parameter_values = [float(value) for value in parameters] if parameters is not None else None
|
||||
built.append(Edge.make_spline(
|
||||
points,
|
||||
tangents=[_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None,
|
||||
periodic=bool(edge.get("periodic")),
|
||||
parameters=[float(value) for value in parameters] if parameters is not None else None,
|
||||
parameters=parameter_values,
|
||||
scale=False,
|
||||
))
|
||||
continue
|
||||
@@ -166,6 +203,222 @@ class Build123dGeometryAdapter:
|
||||
faces.append(face.make_holes(holes) if holes else face)
|
||||
return faces
|
||||
|
||||
@staticmethod
|
||||
def _split_images(splitter: BOPAlgo_Splitter, edge: Edge) -> list[Edge]:
|
||||
"""Return OCC split history, keeping an unchanged input as one image."""
|
||||
images = [Edge.cast(shape) for shape in splitter.Modified(edge.wrapped)]
|
||||
return images or [edge]
|
||||
|
||||
@staticmethod
|
||||
def _imprint_support_face(edges: list[Edge], plane_spec: PlaneSpec) -> Face:
|
||||
"""Build a finite support face around all source curves.
|
||||
|
||||
A fixed world-aligned box would silently make the result depend on a
|
||||
sketch's orientation. Projecting every edge bounding-box corner into
|
||||
the explicit workplane creates a deterministic support boundary for
|
||||
the OCC splitter. Any selected face that touches that boundary is
|
||||
rejected later as an unbounded IMPRINT region.
|
||||
"""
|
||||
if not edges:
|
||||
raise ValueError("planar_imprint has no source edges")
|
||||
origin = _vector(plane_spec.origin_mm)
|
||||
x_dir = _vector(plane_spec.x_dir)
|
||||
y_dir = _vector(plane_spec.y_dir)
|
||||
coordinates: list[tuple[float, float]] = []
|
||||
for edge in edges:
|
||||
box = edge.bounding_box()
|
||||
for x in (box.min.X, box.max.X):
|
||||
for y in (box.min.Y, box.max.Y):
|
||||
for z in (box.min.Z, box.max.Z):
|
||||
offset = Vector(x, y, z) - origin
|
||||
coordinates.append((offset.dot(x_dir), offset.dot(y_dir)))
|
||||
if not coordinates:
|
||||
raise ValueError("planar_imprint cannot bound source geometry")
|
||||
u_values, v_values = zip(*coordinates)
|
||||
u_min, u_max = min(u_values), max(u_values)
|
||||
v_min, v_max = min(v_values), max(v_values)
|
||||
span = max(u_max - u_min, v_max - v_min, 1.0)
|
||||
margin = max(span * 0.1, 1.0)
|
||||
corners = [
|
||||
origin + x_dir * (u_min - margin) + y_dir * (v_min - margin),
|
||||
origin + x_dir * (u_max + margin) + y_dir * (v_min - margin),
|
||||
origin + x_dir * (u_max + margin) + y_dir * (v_max + margin),
|
||||
origin + x_dir * (u_min - margin) + y_dir * (v_max + margin),
|
||||
]
|
||||
return Face(Wire([
|
||||
Edge.make_line(corners[index], corners[(index + 1) % len(corners)])
|
||||
for index in range(len(corners))
|
||||
]))
|
||||
|
||||
@staticmethod
|
||||
def _intersection_parameters(source: Edge, anchors: list[Edge]) -> list[float]:
|
||||
"""Return unique exact intersection points ordered by source direction."""
|
||||
parameters: list[float] = []
|
||||
for anchor in anchors:
|
||||
distance = BRepExtrema_DistShapeShape(source.wrapped, anchor.wrapped)
|
||||
distance.Perform()
|
||||
if not distance.IsDone() or distance.Value() > 1e-6:
|
||||
continue
|
||||
for index in range(1, distance.NbSolution() + 1):
|
||||
point = Vector(distance.PointOnShape1(index))
|
||||
parameter = float(source.param_at_point(point))
|
||||
if not math.isfinite(parameter):
|
||||
continue
|
||||
if not any(abs(parameter - existing) <= 1e-7 for existing in parameters):
|
||||
parameters.append(parameter)
|
||||
return sorted(parameters)
|
||||
|
||||
@staticmethod
|
||||
def _selected_imprint_edges(
|
||||
splitter: BOPAlgo_Splitter,
|
||||
source: Edge,
|
||||
anchors: list[Edge],
|
||||
fragment: dict[str, Any] | None,
|
||||
) -> list[Edge]:
|
||||
"""Resolve a logical source or one ordered split fragment exactly."""
|
||||
images = Build123dGeometryAdapter._split_images(splitter, source)
|
||||
if not fragment:
|
||||
# A bare IMPRINT source query denotes every builder image of the
|
||||
# same logical FeatureScript edge. The caller proves that every
|
||||
# requested-side candidate is bounded; it must not pick an
|
||||
# arbitrary image just because OCC introduced vertices at contact
|
||||
# points.
|
||||
return images
|
||||
intersections = Build123dGeometryAdapter._intersection_parameters(source, anchors)
|
||||
if not intersections:
|
||||
raise ValueError("planar_imprint fragment source and anchor do not intersect")
|
||||
order = fragment.get("intersection_index")
|
||||
if order is None:
|
||||
if len(intersections) != 1:
|
||||
raise ValueError("planar_imprint fragment intersection is not unique")
|
||||
anchor_parameter = intersections[0]
|
||||
elif isinstance(order, int) and 0 <= order < len(intersections):
|
||||
anchor_parameter = intersections[order]
|
||||
else:
|
||||
raise ValueError("planar_imprint fragment intersection index is invalid")
|
||||
# FeatureScript's topology disambiguation uses -1 for the directed
|
||||
# successor of a vertex and +1 for its predecessor. Comparing raw
|
||||
# parameters would break at a periodic curve's 0/1 seam, so resolve
|
||||
# the image by the exact split endpoint and its source-aligned tangent.
|
||||
forward = float(fragment.get("side")) < 0.0
|
||||
anchor_point = source.position_at(anchor_parameter)
|
||||
source_tangent = source.tangent_at(anchor_parameter)
|
||||
if source_tangent.length <= 1e-9:
|
||||
raise ValueError("planar_imprint source edge has no directed tangent")
|
||||
if len(images) == 1 and bool(source.wrapped.Closed()):
|
||||
# One exact vertex does not divide a periodic OCC edge. Both
|
||||
# directed choices therefore refer to its sole logical fragment.
|
||||
return images
|
||||
candidates: list[Edge] = []
|
||||
for edge in images:
|
||||
endpoint = edge.position_at(0 if forward else 1)
|
||||
tangent = edge.tangent_at(0 if forward else 1)
|
||||
if (endpoint - anchor_point).length > 1e-6:
|
||||
continue
|
||||
if tangent.length <= 1e-9 or source_tangent.normalized().dot(tangent.normalized()) < 1.0 - 1e-7:
|
||||
continue
|
||||
candidates.append(edge)
|
||||
if len(candidates) != 1:
|
||||
raise ValueError("planar_imprint fragment side does not resolve one split edge")
|
||||
return candidates
|
||||
|
||||
@staticmethod
|
||||
def _face_uses_boundary(face: Face, boundary_edges: list[Edge]) -> bool:
|
||||
return any(
|
||||
edge.wrapped.IsSame(boundary.wrapped)
|
||||
for edge in face.edges()
|
||||
for boundary in boundary_edges
|
||||
)
|
||||
|
||||
def _faces_from_planar_imprint(self, sketch: dict[str, Any]) -> list[Face]:
|
||||
"""Materialize exact bounded IMPRINT regions with OCC planar splitting."""
|
||||
source_entries = sketch.get("imprint_entities_mm") or []
|
||||
selections = sketch.get("imprint_selections") or []
|
||||
if not source_entries or not selections:
|
||||
raise ValueError("planar_imprint is missing resolved source entities or selections")
|
||||
source_edges: dict[str, list[Edge]] = {}
|
||||
all_edges: list[Edge] = []
|
||||
for entry in source_entries:
|
||||
source_id = str(entry.get("id") or "")
|
||||
raw_edges = entry.get("edges") or []
|
||||
if not source_id or not raw_edges or source_id in source_edges:
|
||||
raise ValueError("planar_imprint source entities are invalid")
|
||||
edges = list(self._wire(raw_edges).edges())
|
||||
if not edges:
|
||||
raise ValueError(f"planar_imprint source entity {source_id!r} has no OCC edge")
|
||||
source_edges[source_id] = edges
|
||||
all_edges.extend(edges)
|
||||
plane_spec = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||||
support = self._imprint_support_face(all_edges, plane_spec)
|
||||
splitter = BOPAlgo_Splitter()
|
||||
splitter.AddArgument(support.wrapped)
|
||||
for edge in all_edges:
|
||||
splitter.AddTool(edge.wrapped)
|
||||
splitter.Perform()
|
||||
if splitter.HasErrors():
|
||||
raise ValueError("planar_imprint OCC splitter failed")
|
||||
explorer = TopExp_Explorer(splitter.Shape(), TopAbs_FACE)
|
||||
regions: list[Face] = []
|
||||
while explorer.More():
|
||||
face = Face.cast(explorer.Current())
|
||||
if face.area > 1e-10:
|
||||
regions.append(face)
|
||||
explorer.Next()
|
||||
if not regions:
|
||||
raise ValueError("planar_imprint OCC splitter produced no regions")
|
||||
boundary_edges = [
|
||||
image
|
||||
for edge in support.edges()
|
||||
for image in self._split_images(splitter, edge)
|
||||
]
|
||||
normal = _vector(plane_spec.normal)
|
||||
selected: list[Face] = []
|
||||
for selection in selections:
|
||||
source_id = str(selection.get("source_entity_id") or "")
|
||||
source = source_edges.get(source_id) or []
|
||||
if len(source) != 1:
|
||||
raise ValueError("planar_imprint selection source must resolve to one analytic edge")
|
||||
fragment = selection.get("fragment")
|
||||
anchor_id = str((fragment or {}).get("anchor_entity_id") or "")
|
||||
anchors = source_edges.get(anchor_id) or [] if fragment else []
|
||||
if fragment and not anchors:
|
||||
raise ValueError("planar_imprint fragment anchor is unavailable")
|
||||
edges = self._selected_imprint_edges(splitter, source[0], anchors, fragment)
|
||||
face_side = float(selection.get("face_side") or 0.0)
|
||||
if face_side not in {-1.0, 1.0}:
|
||||
raise ValueError("planar_imprint face side is invalid")
|
||||
candidate_faces: list[Face] = []
|
||||
for edge in edges:
|
||||
tangent = edge.tangent_at(0.5)
|
||||
lateral = normal.cross(tangent)
|
||||
if lateral.length <= 1e-9:
|
||||
raise ValueError("planar_imprint selected edge has no in-plane side")
|
||||
extent = max(edge.length, 1e-4)
|
||||
probe_distance = max(1e-5, min(extent / 1000.0, 0.01))
|
||||
probe = edge.position_at(0.5) + lateral.normalized() * (probe_distance * face_side)
|
||||
candidates = [face for face in regions if face.is_inside(probe, probe_distance / 10.0)]
|
||||
if len(candidates) != 1:
|
||||
raise ValueError("planar_imprint face side does not resolve one region")
|
||||
face = candidates[0]
|
||||
if not any(face.wrapped.IsSame(existing.wrapped) for existing in candidate_faces):
|
||||
candidate_faces.append(face)
|
||||
unbounded = [
|
||||
face for face in candidate_faces
|
||||
if self._face_uses_boundary(face, boundary_edges)
|
||||
]
|
||||
if unbounded:
|
||||
if len(candidate_faces) == 1 and self._face_uses_boundary(candidate_faces[0], boundary_edges):
|
||||
raise ValueError("planar_imprint selected region is unbounded")
|
||||
raise ValueError("planar_imprint source side includes an unbounded region")
|
||||
# A bare source query can legitimately select every bounded face
|
||||
# adjacent to its OCC split descendants. This is a set-valued
|
||||
# FeatureScript IMPRINT result, not an invitation to choose one
|
||||
# segment by length, order, or proximity.
|
||||
for face in candidate_faces:
|
||||
if not any(face.wrapped.IsSame(existing.wrapped) for existing in selected):
|
||||
selected.append(face)
|
||||
return selected
|
||||
|
||||
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Face]:
|
||||
# 从草图数据解析出可拉伸/旋转的轮廓面,按三种数据来源依次回退。
|
||||
# 1. 单圆 contour 不应先被 sketch_solver 展开成四段圆弧。圆弧分段会
|
||||
@@ -174,6 +427,8 @@ class Build123dGeometryAdapter:
|
||||
# 只含闭合整圆的轮廓,保留每个圆一条原生 circle edge;同心圆仍由
|
||||
# _faces_from_circles 的包含关系生成带孔面。
|
||||
profile = sketch.get("profile") or {}
|
||||
if profile.get("type") == "planar_imprint":
|
||||
return self._faces_from_planar_imprint(sketch)
|
||||
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
|
||||
if isinstance(contours, list) and contours and all(
|
||||
isinstance(contour, dict)
|
||||
@@ -227,8 +482,8 @@ class Build123dGeometryAdapter:
|
||||
raise ValueError("profile hole feature requires at least one cap face")
|
||||
return Face(outer.outer_wire()).make_holes(wires)
|
||||
|
||||
def loft(self, sketches: list[dict[str, Any]]) -> Solid:
|
||||
"""由多条简单闭合草图轮廓生成实体放样。"""
|
||||
def _loft_wires(self, sketches: list[dict[str, Any]]) -> list[Wire]:
|
||||
"""Resolve the bounded closed-wire CDSL loft contract once."""
|
||||
wires: list[Wire] = []
|
||||
for index, sketch in enumerate(sketches):
|
||||
# Solid.make_loft 接收 Wire;复用 faces_for_sketch 保持放样、
|
||||
@@ -242,7 +497,35 @@ class Build123dGeometryAdapter:
|
||||
wires.append(faces[0].outer_wire())
|
||||
if len(wires) < 2:
|
||||
raise ValueError("loft requires at least two profile sketches")
|
||||
return Solid.make_loft(wires)
|
||||
return wires
|
||||
|
||||
def loft(self, sketches: list[dict[str, Any]]) -> Solid:
|
||||
result, _delta = self.loft_with_topology_delta(sketches)
|
||||
return result
|
||||
|
||||
def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Solid, TopologyDelta]:
|
||||
"""Build a simple closed-wire solid loft through one OCC builder."""
|
||||
wires = self._loft_wires(sketches)
|
||||
builder = BRepOffsetAPI_ThruSections(True, False)
|
||||
builder.CheckCompatibility(True)
|
||||
for wire in wires:
|
||||
builder.AddWire(wire.wrapped)
|
||||
builder.Build()
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCC loft operation did not complete")
|
||||
result = Solid(builder.Shape())
|
||||
if not result.is_valid or not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9:
|
||||
raise ValueError("OCC loft operation did not produce a valid solid")
|
||||
relations: list[TopologyDeltaRelation] = []
|
||||
for wire, output, role in (
|
||||
(wires[0], builder.FirstShape(), "loft.start"),
|
||||
(wires[-1], builder.LastShape(), "loft.end"),
|
||||
):
|
||||
if not output.IsNull() and output.ShapeType() == TopAbs_FACE:
|
||||
relations.append(TopologyDeltaRelation(
|
||||
"generated", "face", wire.wrapped, (output,), output_role=role,
|
||||
))
|
||||
return result, TopologyDelta(operation="loft", relations=tuple(relations))
|
||||
|
||||
def loft_with_cap_face(self, cap_face: Face, sketches: list[dict[str, Any]]) -> Solid:
|
||||
"""Loft from one cap face's outer wire to one closed sketch profile."""
|
||||
@@ -285,13 +568,62 @@ class Build123dGeometryAdapter:
|
||||
return Solid.extrude(face, _vector(direction))
|
||||
|
||||
@staticmethod
|
||||
def extrude_taper(face: Face, direction: Vector3, taper_deg: float) -> Solid:
|
||||
# 沿给定方向以锥角拉伸一个面。build123d 正角收缩外轮廓,负角扩张;
|
||||
# CADFS 的 draftPullDirection 已由 lowering 映射到该符号。
|
||||
# build123d 对负锥角回退为 offset wire loft;椭圆等解析曲线在该
|
||||
# 路径会产生仅能留在内存、STEP round-trip 后退化为 Shell 的 B-rep。
|
||||
# LocOpe_DPrism 同时支持正负拔模角,且保留一张解析侧面,因此在
|
||||
# 无内环、拉伸方向与 face normal 同向时始终优先使用它。
|
||||
def face_normal(face: Face) -> Vector3:
|
||||
normal = face.normal_at()
|
||||
return (float(normal.X), float(normal.Y), float(normal.Z))
|
||||
|
||||
@staticmethod
|
||||
def extrude_with_topology_delta(face: Face, direction: Vector3) -> tuple[Solid, TopologyDelta]:
|
||||
"""Extrude one B-rep face and retain its two builder-proven cap faces."""
|
||||
vector = _vector(direction)
|
||||
if vector.length <= 1e-9:
|
||||
raise ValueError("extrude direction must be non-zero")
|
||||
builder = BRepPrimAPI_MakePrism(
|
||||
face.wrapped, gp_Vec(vector.X, vector.Y, vector.Z), True, True,
|
||||
)
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCC extrude operation did not complete")
|
||||
result = Solid(builder.Shape())
|
||||
if not result.is_valid or not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9:
|
||||
raise ValueError("OCC extrude operation did not produce a valid solid")
|
||||
relations: list[TopologyDeltaRelation] = []
|
||||
for output, role in ((builder.FirstShape(), "extrude.start"), (builder.LastShape(), "extrude.end")):
|
||||
if not output.IsNull() and output.ShapeType() == TopAbs_FACE:
|
||||
relations.append(TopologyDeltaRelation(
|
||||
"generated", "face", face.wrapped, (output,), output_role=role,
|
||||
))
|
||||
return result, TopologyDelta(operation="extrude", relations=tuple(relations))
|
||||
|
||||
@staticmethod
|
||||
def _single_face_from_shape(shape: Any) -> Face | None:
|
||||
"""Return one face only when an OCC builder output contains exactly one.
|
||||
|
||||
``LocOpe_DPrism.FirstShape`` and ``LastShape`` are shells in the OCP
|
||||
binding, even for the single cap faces they represent. Requiring one
|
||||
contained face keeps those roles tied to the builder output rather
|
||||
than guessing a cap from a coincident planar result face.
|
||||
"""
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
faces: list[Face] = []
|
||||
while explorer.More():
|
||||
faces.append(Face.cast(explorer.Current()))
|
||||
explorer.Next()
|
||||
return faces[0] if len(faces) == 1 else None
|
||||
|
||||
@staticmethod
|
||||
def extrude_taper_with_topology_delta(
|
||||
face: Face,
|
||||
direction: Vector3,
|
||||
taper_deg: float,
|
||||
) -> tuple[Solid, TopologyDelta | None]:
|
||||
"""Extrude a drafted face and retain exact caps from ``LocOpe_DPrism``.
|
||||
|
||||
The native tapered-extrude fallback has no history interface. Only
|
||||
the narrow ``LocOpe_DPrism`` path can expose a cap role, and only when
|
||||
each of its ``FirstShape``/``LastShape`` outputs contains exactly one
|
||||
face. All other valid draft results deliberately retain no topology
|
||||
delta instead of inferring one from geometry.
|
||||
"""
|
||||
vector = _vector(direction)
|
||||
normal = face.normal_at()
|
||||
if (
|
||||
@@ -309,8 +641,31 @@ class Build123dGeometryAdapter:
|
||||
if prism.IsDone():
|
||||
result = Solid(TopoDS.Solid_s(prism.Shape()))
|
||||
if result.is_valid:
|
||||
return result
|
||||
return Solid.extrude_taper(face, _vector(direction), taper_deg)
|
||||
relations: list[TopologyDeltaRelation] = []
|
||||
for cap_shape, role in (
|
||||
(prism.FirstShape(), "extrude.start"),
|
||||
(prism.LastShape(), "extrude.end"),
|
||||
):
|
||||
cap = Build123dGeometryAdapter._single_face_from_shape(cap_shape)
|
||||
if cap is not None:
|
||||
relations.append(TopologyDeltaRelation(
|
||||
"generated", "face", face.wrapped, (cap.wrapped,), output_role=role,
|
||||
))
|
||||
return result, TopologyDelta(operation="extrude_taper", relations=tuple(relations))
|
||||
return Solid.extrude_taper(face, vector, taper_deg), None
|
||||
|
||||
@staticmethod
|
||||
def extrude_taper(face: Face, direction: Vector3, taper_deg: float) -> Solid:
|
||||
# 沿给定方向以锥角拉伸一个面。build123d 正角收缩外轮廓,负角扩张;
|
||||
# CADFS 的 draftPullDirection 已由 lowering 映射到该符号。
|
||||
# build123d 对负锥角回退为 offset wire loft;椭圆等解析曲线在该
|
||||
# 路径会产生仅能留在内存、STEP round-trip 后退化为 Shell 的 B-rep。
|
||||
# LocOpe_DPrism 同时支持正负拔模角,且保留一张解析侧面,因此在
|
||||
# 无内环、拉伸方向与 face normal 同向时始终优先使用它。
|
||||
result, _topology_delta = Build123dGeometryAdapter.extrude_taper_with_topology_delta(
|
||||
face, direction, taper_deg,
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def extrude_trimmed(face: Face, target: Any, direction: Vector3) -> Any:
|
||||
@@ -587,10 +942,38 @@ class Build123dGeometryAdapter:
|
||||
|
||||
@staticmethod
|
||||
def fuse(body: Any | None, solid: Any) -> Any:
|
||||
"""Fuse bodies without retaining a builder history."""
|
||||
result, _delta = Build123dGeometryAdapter.fuse_with_topology_delta(
|
||||
body, solid, record_history=False,
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def fuse_with_topology_delta(
|
||||
body: Any | None, solid: Any, *, record_history: bool = True,
|
||||
) -> tuple[Any, TopologyDelta | None]:
|
||||
"""Fuse one explicit body pair and retain history when it stays exact.
|
||||
|
||||
The established fallback sequence changes the kernel result: a normal
|
||||
build123d fuse or fuzzy OCC fuse has a different history object. Such
|
||||
results remain executable, but must not inherit relationships from the
|
||||
discarded first builder. Multi-member inputs are likewise outside the
|
||||
one-builder proof boundary.
|
||||
"""
|
||||
# 布尔并:没有既有主体时,直接以该实体作为新主体。
|
||||
# 实参类型放宽为 Any:build123d 的布尔结果可能是 Solid 或 Compound。
|
||||
if body is None:
|
||||
return solid
|
||||
return solid, None
|
||||
if (
|
||||
not record_history
|
||||
or len(Build123dGeometryAdapter.body_solids(body)) != 1
|
||||
or len(Build123dGeometryAdapter.body_solids(solid)) != 1
|
||||
):
|
||||
return Build123dGeometryAdapter._fuse_without_history(body, solid), None
|
||||
return Build123dGeometryAdapter._fuse_with_history(body, solid)
|
||||
|
||||
@staticmethod
|
||||
def _fuse_without_history(body: Any, solid: Any) -> Any:
|
||||
# build123d.Shape.fuse 未启用 OBB 加速器;镜像后的重叠实体在该路径
|
||||
# 会偶发返回反向、无效的 B-rep。直接采用 OCC 的稳定布尔配置,保留
|
||||
# 一般 add/replay 的同一 union 语义。
|
||||
@@ -603,6 +986,30 @@ class Build123dGeometryAdapter:
|
||||
raise ValueError("OCC union operation did not complete")
|
||||
result = Solid(operation.Shape())
|
||||
if result.is_valid:
|
||||
# OBB 对多个相交的曲面 sweep 偶尔会把交叠区单独保留为一个
|
||||
# Solid。普通 fuse 若能以更少的有效实体表示相同并集,应优先
|
||||
# 使用它;不相交结果仍保留 OBB 的多实体 body 语义。
|
||||
if len(Build123dGeometryAdapter.body_solids(result)) > 1:
|
||||
fallback = Build123dGeometryAdapter._coerce_single_or_compound(body.fuse(solid))
|
||||
if fallback is not None and fallback.is_valid and (
|
||||
len(Build123dGeometryAdapter.body_solids(fallback))
|
||||
< len(Build123dGeometryAdapter.body_solids(result))
|
||||
):
|
||||
return fallback
|
||||
# 两个输入在数学上已经接触时,曲面 sweep 的近似交界可能只因
|
||||
# 内核容差留下重叠成员。仅在这种零距离情形重试 fuzzy boolean;
|
||||
# 有实际间隙的独立 body 不参与该修复,不能被错误地桥接合并。
|
||||
if body.distance_to(solid) <= Build123dGeometryAdapter.CONTACT_FUSE_TOLERANCE_MM:
|
||||
operation = BRepAlgoAPI_Fuse()
|
||||
operation.SetRunParallel(True); operation.SetUseOBB(True)
|
||||
operation.SetFuzzyValue(Build123dGeometryAdapter.COINCIDENT_FUSE_TOLERANCE_MM)
|
||||
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
|
||||
fuzzy = Solid(operation.Shape()) if operation.IsDone() else None
|
||||
if fuzzy is not None and fuzzy.is_valid and (
|
||||
len(Build123dGeometryAdapter.body_solids(fuzzy))
|
||||
< len(Build123dGeometryAdapter.body_solids(result))
|
||||
) and fuzzy.volume + 1e-6 >= max(float(body.volume), float(solid.volume)):
|
||||
return fuzzy
|
||||
return result
|
||||
# 保留 build123d 的既有调用作为内核版本差异下的兼容回退;无效结果
|
||||
# 不能悄然进入后续 feature history。
|
||||
@@ -611,6 +1018,39 @@ class Build123dGeometryAdapter:
|
||||
return fallback
|
||||
raise ValueError("OCC union operation produced an invalid shape")
|
||||
|
||||
@staticmethod
|
||||
def _fuse_with_history(body: Any, solid: Any) -> tuple[Any, TopologyDelta | None]:
|
||||
"""Run the primary fuse algorithm with its own exact history object."""
|
||||
arguments = TopTools_ListOfShape(); arguments.Append(body.wrapped)
|
||||
tools = TopTools_ListOfShape(); tools.Append(solid.wrapped)
|
||||
operation = BRepAlgoAPI_Fuse()
|
||||
operation.SetRunParallel(True); operation.SetUseOBB(True); operation.SetToFillHistory(True)
|
||||
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
|
||||
if not operation.IsDone():
|
||||
raise ValueError("OCC union operation did not complete")
|
||||
result = Solid(operation.Shape())
|
||||
if not result.is_valid:
|
||||
return Build123dGeometryAdapter._fuse_without_history(body, solid), None
|
||||
if len(Build123dGeometryAdapter.body_solids(result)) > 1:
|
||||
fallback = Build123dGeometryAdapter._coerce_single_or_compound(body.fuse(solid))
|
||||
if fallback is not None and fallback.is_valid and (
|
||||
len(Build123dGeometryAdapter.body_solids(fallback))
|
||||
< len(Build123dGeometryAdapter.body_solids(result))
|
||||
):
|
||||
return fallback, None
|
||||
if body.distance_to(solid) <= Build123dGeometryAdapter.CONTACT_FUSE_TOLERANCE_MM:
|
||||
fuzzy = BRepAlgoAPI_Fuse()
|
||||
fuzzy.SetRunParallel(True); fuzzy.SetUseOBB(True)
|
||||
fuzzy.SetFuzzyValue(Build123dGeometryAdapter.COINCIDENT_FUSE_TOLERANCE_MM)
|
||||
fuzzy.SetArguments(arguments); fuzzy.SetTools(tools); fuzzy.Build()
|
||||
fuzzy_result = Solid(fuzzy.Shape()) if fuzzy.IsDone() else None
|
||||
if fuzzy_result is not None and fuzzy_result.is_valid and (
|
||||
len(Build123dGeometryAdapter.body_solids(fuzzy_result))
|
||||
< len(Build123dGeometryAdapter.body_solids(result))
|
||||
) and fuzzy_result.volume + 1e-6 >= max(float(body.volume), float(solid.volume)):
|
||||
return fuzzy_result, None
|
||||
return result, Build123dGeometryAdapter._builder_topology_delta(operation, (body, solid), "union")
|
||||
|
||||
@staticmethod
|
||||
def combine(body: Any | None, solid: Any) -> Any:
|
||||
# 保留独立 result body:不得调用 fuse,否则相交实体会被内核合并。
|
||||
@@ -621,10 +1061,50 @@ class Build123dGeometryAdapter:
|
||||
@staticmethod
|
||||
def cut(body: Any, tool: Any) -> Any:
|
||||
# 从主体上减去工具实体。
|
||||
# Compound 内的独立实体分别切除再组合,与整体差集的集合语义一致。
|
||||
# 对包含抽壳薄壁的多个成员,直接对整个 Compound 做 OCC boolean 会在
|
||||
# 内核中长时间求解,且不会改善任何成员间不存在的拓扑关系。
|
||||
members = Build123dGeometryAdapter.body_solids(body)
|
||||
if len(members) > 1:
|
||||
result = None
|
||||
for member in members:
|
||||
cut_member = Build123dGeometryAdapter._coerce_single_or_compound(member.cut(tool))
|
||||
# 多实体差集允许 cutter 完全移除其中一个成员;其他成员仍是
|
||||
# 当前 feature 的有效结果。只有所有成员均被移除才是空切除。
|
||||
if cut_member is not None:
|
||||
result = Build123dGeometryAdapter.combine(result, cut_member)
|
||||
if result is None:
|
||||
raise ValueError("OCC cut operation produced no shape")
|
||||
return result
|
||||
return Build123dGeometryAdapter._coerce_single_or_compound(
|
||||
body.cut(tool), empty_error="OCC cut operation produced no shape",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def cut_with_topology_delta(body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]:
|
||||
"""Subtract single explicit bodies while preserving exact OCC history.
|
||||
|
||||
The established multi-member path cuts each member independently to
|
||||
bound OCC work. It has no single builder history for the aggregate,
|
||||
so it deliberately keeps the executable result but returns no delta
|
||||
rather than composing an unproven history graph.
|
||||
"""
|
||||
if len(Build123dGeometryAdapter.body_solids(body)) != 1 or len(Build123dGeometryAdapter.body_solids(tool)) != 1:
|
||||
return Build123dGeometryAdapter.cut(body, tool), None
|
||||
arguments = TopTools_ListOfShape(); arguments.Append(body.wrapped)
|
||||
tools = TopTools_ListOfShape(); tools.Append(tool.wrapped)
|
||||
operation = BRepAlgoAPI_Cut()
|
||||
operation.SetRunParallel(True); operation.SetUseOBB(True); operation.SetToFillHistory(True)
|
||||
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
|
||||
if not operation.IsDone():
|
||||
raise ValueError("OCC cut operation did not complete")
|
||||
result = Build123dGeometryAdapter._coerce_single_or_compound(
|
||||
Solid(operation.Shape()), empty_error="OCC cut operation produced no shape",
|
||||
)
|
||||
return result, Build123dGeometryAdapter._builder_topology_delta(
|
||||
operation, (body, tool), "subtract",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def sphere(radius_mm: float, center_mm: Vector3) -> Solid:
|
||||
# 以给定球心与半径生成球体实体。
|
||||
@@ -656,6 +1136,147 @@ class Build123dGeometryAdapter:
|
||||
left.intersect(right), empty_error="boolean intersection produced no solid",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def intersect_with_topology_delta(left: Any, right: Any) -> tuple[Any, TopologyDelta | None]:
|
||||
"""Intersect single bodies through one OCC builder and retain history."""
|
||||
if len(Build123dGeometryAdapter.body_solids(left)) != 1 or len(Build123dGeometryAdapter.body_solids(right)) != 1:
|
||||
return Build123dGeometryAdapter.intersect(left, right), None
|
||||
arguments = TopTools_ListOfShape(); arguments.Append(left.wrapped)
|
||||
tools = TopTools_ListOfShape(); tools.Append(right.wrapped)
|
||||
operation = BRepAlgoAPI_Common()
|
||||
operation.SetToFillHistory(True)
|
||||
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
|
||||
if not operation.IsDone():
|
||||
raise ValueError("OCC intersection operation did not complete")
|
||||
result = Build123dGeometryAdapter._coerce_single_or_compound(
|
||||
Solid(operation.Shape()), empty_error="boolean intersection produced no solid",
|
||||
)
|
||||
return result, Build123dGeometryAdapter._builder_topology_delta(
|
||||
operation, (left, right), "intersect",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def transform(body: Any, transform: dict[str, Any]) -> Any:
|
||||
"""Apply one explicit body transform without exposing kernel history."""
|
||||
result, _delta = Build123dGeometryAdapter.transform_with_topology_delta(body, transform)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def transform_with_topology_delta(body: Any, transform: dict[str, Any]) -> tuple[Any, TopologyDelta]:
|
||||
"""Apply one body transform and retain exact OCC subshape history."""
|
||||
kind = str(transform.get("type") or "")
|
||||
conversion = gp_Trsf()
|
||||
if kind == "translation":
|
||||
offset = transform.get("translation_mm")
|
||||
if not isinstance(offset, list) or len(offset) != 3:
|
||||
raise ValueError("translation transform requires translation_mm")
|
||||
conversion.SetTranslation(gp_Vec(*(float(value) for value in offset)))
|
||||
elif kind == "rotation":
|
||||
axis = AxisSpec.from_mapping(transform.get("axis") or {})
|
||||
angle_deg = transform.get("angle_deg")
|
||||
if not isinstance(angle_deg, (int, float)):
|
||||
raise ValueError("rotation transform requires angle_deg")
|
||||
conversion.SetRotation(
|
||||
gp_Ax1(gp_Pnt(*axis.origin_mm), gp_Dir(*axis.direction)),
|
||||
math.radians(float(angle_deg)),
|
||||
)
|
||||
elif kind == "uniform_scale":
|
||||
center = transform.get("center_mm")
|
||||
scale_factor = transform.get("scale_factor")
|
||||
if not isinstance(center, list) or len(center) != 3:
|
||||
raise ValueError("uniform_scale transform requires center_mm")
|
||||
if not isinstance(scale_factor, (int, float)) or not math.isfinite(float(scale_factor)) or float(scale_factor) <= 0:
|
||||
raise ValueError("uniform_scale transform requires a finite positive scale_factor")
|
||||
conversion.SetScale(gp_Pnt(*(float(value) for value in center)), float(scale_factor))
|
||||
else:
|
||||
raise ValueError(f"unsupported body transform type {kind!r}")
|
||||
operation = BRepBuilderAPI_Transform(body.wrapped, conversion, True)
|
||||
operation.Build()
|
||||
if not operation.IsDone():
|
||||
raise ValueError("OCC body transform did not complete")
|
||||
result = Solid(operation.Shape())
|
||||
if not result.is_valid:
|
||||
raise ValueError("OCC body transform produced an invalid shape")
|
||||
return result, Build123dGeometryAdapter._builder_topology_delta(operation, (body,), kind)
|
||||
|
||||
@staticmethod
|
||||
def _builder_topology_delta(operation: Any, sources: Iterable[Any], operation_name: str) -> TopologyDelta:
|
||||
"""Translate OCC builder history into adapter-neutral opaque relations."""
|
||||
relations: list[TopologyDeltaRelation] = []
|
||||
for source in sources:
|
||||
for kind, shapes in (
|
||||
("face", list(source.faces())),
|
||||
("edge", list(source.edges())),
|
||||
("vertex", list(source.vertices())),
|
||||
):
|
||||
for shape in shapes:
|
||||
source_value = shape.wrapped
|
||||
is_deleted = bool(
|
||||
operation.IsDeleted(source_value)
|
||||
if hasattr(operation, "IsDeleted") else operation.IsRemoved(source_value)
|
||||
)
|
||||
if is_deleted:
|
||||
relations.append(TopologyDeltaRelation("deleted", kind, source_value))
|
||||
modified = tuple(operation.Modified(source_value))
|
||||
generated = tuple(operation.Generated(source_value))
|
||||
same_modified = (
|
||||
len(modified) == 1 and bool(modified[0].IsSame(source_value))
|
||||
)
|
||||
if modified:
|
||||
relations.append(TopologyDeltaRelation(
|
||||
"preserved" if same_modified and not generated else "modified",
|
||||
kind, source_value, modified,
|
||||
))
|
||||
elif not generated and not is_deleted:
|
||||
# A no-op transform can retain the original OCC object.
|
||||
# The registry still requires it to appear in the result
|
||||
# snapshot before treating this as a continuation.
|
||||
relations.append(TopologyDeltaRelation("preserved", kind, source_value, (source_value,)))
|
||||
if generated:
|
||||
relations.append(TopologyDeltaRelation("generated", kind, source_value, generated))
|
||||
return TopologyDelta(operation=operation_name, relations=tuple(relations))
|
||||
|
||||
@staticmethod
|
||||
def _shell_topology_delta(
|
||||
operation: Any, source: Solid, closing_faces: Iterable[Face],
|
||||
) -> TopologyDelta:
|
||||
"""Annotate exact shell history with only builder-proven output roles."""
|
||||
base_delta = Build123dGeometryAdapter._builder_topology_delta(operation, (source,), "shell")
|
||||
closing_values = tuple(face.wrapped for face in closing_faces)
|
||||
closing_edge_values = tuple(
|
||||
edge.wrapped for face in closing_faces for edge in face.edges()
|
||||
)
|
||||
|
||||
def is_member(value: Any, candidates: tuple[Any, ...]) -> bool:
|
||||
return any(bool(value.IsSame(candidate)) for candidate in candidates)
|
||||
|
||||
def output_role(relation: TopologyDeltaRelation) -> str | None:
|
||||
if relation.kind == "face":
|
||||
is_closing = is_member(relation.source_value, closing_values)
|
||||
if is_closing and relation.event in {"preserved", "modified", "generated"}:
|
||||
return "shell.closing_descendant"
|
||||
if not is_closing and relation.event == "generated":
|
||||
return "shell.offset_face"
|
||||
if not is_closing and relation.event in {"preserved", "modified"}:
|
||||
return "shell.body_face"
|
||||
if (
|
||||
relation.kind == "edge" and relation.event == "generated"
|
||||
and is_member(relation.source_value, closing_edge_values)
|
||||
):
|
||||
return "shell.wall"
|
||||
return None
|
||||
|
||||
return TopologyDelta(
|
||||
operation=base_delta.operation,
|
||||
relations=tuple(
|
||||
TopologyDeltaRelation(
|
||||
relation.event, relation.kind, relation.source_value, relation.result_values,
|
||||
output_role=output_role(relation),
|
||||
)
|
||||
for relation in base_delta.relations
|
||||
),
|
||||
)
|
||||
|
||||
def hole_tool(self, spec: HoleSpec, starts: Iterable[Vector3], inward: Vector3, through_depth_mm: float) -> Solid:
|
||||
"""Build a neutral ``HoleSpec`` into one OCC cutting tool."""
|
||||
# 将孔规格 HoleSpec 转成一个可直接切除的 OCC 工具体。
|
||||
@@ -712,6 +1333,18 @@ class Build123dGeometryAdapter:
|
||||
sheet thickness on ``spec.frame.normal`` and the width axis on
|
||||
``spec.frame.x_dir``.
|
||||
"""
|
||||
# ``bend_add`` 生成器是可选的几何实现。不能因该模块未随部署产物
|
||||
# 提交而让所有非钣金 CDSL 在 adapter import 阶段失效;真正执行
|
||||
# 折弯时仍须报出精确的缺失依赖,不能退化为虚构实体。
|
||||
try:
|
||||
from .parametric_bend import build_bend_solid
|
||||
except ModuleNotFoundError as error:
|
||||
if error.name != f"{__package__}.parametric_bend":
|
||||
raise
|
||||
raise RuntimeError(
|
||||
"bend_add requires cdsl_engine.parametric_bend.build_bend_solid, "
|
||||
"but the generator module is not present in this checkout"
|
||||
) from error
|
||||
solid = build_bend_solid(spec)
|
||||
frame = spec.frame
|
||||
plane = Plane(
|
||||
@@ -726,6 +1359,26 @@ class Build123dGeometryAdapter:
|
||||
# 对指定边以给定半径做圆角。
|
||||
return body.fillet(radius_mm, list(edges))
|
||||
|
||||
@staticmethod
|
||||
def fillet_with_topology_delta(
|
||||
body: Any, radius_mm: float, edges: Iterable[Edge],
|
||||
) -> tuple[Any, TopologyDelta | None]:
|
||||
"""Fillet a single body and retain its direct OCC builder history."""
|
||||
selected = list(edges)
|
||||
if len(Build123dGeometryAdapter.body_solids(body)) != 1:
|
||||
return Build123dGeometryAdapter.fillet(body, radius_mm, selected), None
|
||||
builder = BRepFilletAPI_MakeFillet(body.wrapped)
|
||||
for edge in selected:
|
||||
builder.Add(radius_mm, edge.wrapped)
|
||||
builder.Build()
|
||||
if builder.IsDone():
|
||||
result = Solid(builder.Shape())
|
||||
if result.is_valid:
|
||||
return result, Build123dGeometryAdapter._builder_topology_delta(builder, (body,), "fillet")
|
||||
# Preserve build123d's existing fallback/error semantics when OCC's
|
||||
# direct builder cannot construct this dress-up.
|
||||
return Build123dGeometryAdapter.fillet(body, radius_mm, selected), None
|
||||
|
||||
@staticmethod
|
||||
def tangent_edges(body: Any, seeds: Iterable[Edge], *, angular_tolerance: float = 1e-6) -> list[Edge]:
|
||||
"""Expand selected edges through actual tangent, vertex-adjacent chains.
|
||||
@@ -796,6 +1449,25 @@ class Build123dGeometryAdapter:
|
||||
return result
|
||||
return body.chamfer(distance_mm, distance_2_mm, selected, face=face)
|
||||
|
||||
@staticmethod
|
||||
def chamfer_with_topology_delta(
|
||||
body: Any, distance_mm: float, distance_2_mm: float | None,
|
||||
edges: Iterable[Edge], face: Face | None = None,
|
||||
) -> tuple[Any, TopologyDelta | None]:
|
||||
"""Retain history for the equal-distance single-body chamfer subset."""
|
||||
selected = list(edges)
|
||||
if distance_2_mm is not None or face is not None or len(Build123dGeometryAdapter.body_solids(body)) != 1:
|
||||
return Build123dGeometryAdapter.chamfer(body, distance_mm, distance_2_mm, selected, face=face), None
|
||||
builder = BRepFilletAPI_MakeChamfer(body.wrapped)
|
||||
for edge in selected:
|
||||
builder.Add(distance_mm, edge.wrapped)
|
||||
builder.Build()
|
||||
if builder.IsDone():
|
||||
result = Solid(builder.Shape())
|
||||
if result.is_valid:
|
||||
return result, Build123dGeometryAdapter._builder_topology_delta(builder, (body,), "chamfer")
|
||||
return Build123dGeometryAdapter.chamfer(body, distance_mm, distance_2_mm, selected, face=face), None
|
||||
|
||||
@staticmethod
|
||||
def _surface_limited_chamfer_tool(body: Any, edge: Edge, distance_mm: float, surfaces: Iterable[Any]) -> Solid:
|
||||
"""Build the removable material for one surface-supported circular chamfer.
|
||||
@@ -920,6 +1592,15 @@ class Build123dGeometryAdapter:
|
||||
|
||||
@staticmethod
|
||||
def shell(body: Any, faces: Iterable[Face], thickness_mm: float, *, inward: bool = True) -> Any:
|
||||
result, _delta = Build123dGeometryAdapter.shell_with_topology_delta(
|
||||
body, faces, thickness_mm, inward=inward,
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def shell_with_topology_delta(
|
||||
body: Any, faces: Iterable[Face], thickness_mm: float, *, inward: bool = True,
|
||||
) -> tuple[Any, TopologyDelta]:
|
||||
# 对单个实体移除指定面并偏置其余面,生成薄壁实体。多 body 的目标
|
||||
# 选择与结果合并由 runtime 处理;OCC 的 MakeThickSolidByJoin 只接受
|
||||
# 一个 Solid,不能把 Compound 直接交给内核并猜测其 body 生命周期。
|
||||
@@ -953,7 +1634,7 @@ class Build123dGeometryAdapter:
|
||||
result = Solid(builder.Shape())
|
||||
if not result.is_valid:
|
||||
raise ValueError("OCC shell operation produced an invalid shape")
|
||||
return result
|
||||
return result, Build123dGeometryAdapter._shell_topology_delta(builder, solids[0], selected)
|
||||
|
||||
@staticmethod
|
||||
def sweep(
|
||||
@@ -964,6 +1645,22 @@ class Build123dGeometryAdapter:
|
||||
make_solid: bool = True,
|
||||
is_frenet: bool = False,
|
||||
transition: Any = None,
|
||||
) -> Solid:
|
||||
result, _delta = Build123dGeometryAdapter.sweep_with_topology_delta(
|
||||
section, spine, inner_wires=inner_wires, make_solid=make_solid,
|
||||
is_frenet=is_frenet, transition=transition,
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _sweep_without_topology_delta(
|
||||
section: Face | Wire,
|
||||
spine: Edge | Wire,
|
||||
*,
|
||||
inner_wires: list[Wire] | None = None,
|
||||
make_solid: bool = True,
|
||||
is_frenet: bool = False,
|
||||
transition: Any = None,
|
||||
) -> Solid:
|
||||
# 沿路径线扫掠截面生成实体(build123d 原生扫掠,路径可为直线/曲线/螺旋边)。
|
||||
# 默认值对齐 build123d Solid.sweep:make_solid=True 封盖成体;is_frenet=True
|
||||
@@ -983,6 +1680,54 @@ class Build123dGeometryAdapter:
|
||||
raise ValueError("OCC sweep operation did not produce a solid")
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def sweep_with_topology_delta(
|
||||
section: Face | Wire,
|
||||
spine: Edge | Wire,
|
||||
*,
|
||||
inner_wires: list[Wire] | None = None,
|
||||
make_solid: bool = True,
|
||||
is_frenet: bool = False,
|
||||
transition: Any = None,
|
||||
) -> tuple[Solid, TopologyDelta | None]:
|
||||
"""Sweep one simple profile through its direct pipe-shell builder.
|
||||
|
||||
The existing native path remains authoritative for hollow profiles,
|
||||
transition variants and non-solid output. Those cases can still
|
||||
execute, but their final builder provenance is not available through
|
||||
this bounded contract.
|
||||
"""
|
||||
if (
|
||||
not isinstance(section, Face)
|
||||
or section.inner_wires()
|
||||
or inner_wires
|
||||
or not make_solid
|
||||
or transition is not None
|
||||
):
|
||||
return Build123dGeometryAdapter._sweep_without_topology_delta(
|
||||
section, spine, inner_wires=inner_wires, make_solid=make_solid,
|
||||
is_frenet=is_frenet, transition=transition,
|
||||
), None
|
||||
path = spine if isinstance(spine, Wire) else Wire.combine([spine])[0]
|
||||
builder = BRepOffsetAPI_MakePipeShell(path.wrapped)
|
||||
builder.SetMode(bool(is_frenet))
|
||||
builder.Add(section.outer_wire().wrapped, False, False)
|
||||
builder.Build()
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCC sweep operation did not complete")
|
||||
if not builder.MakeSolid():
|
||||
raise ValueError("OCC sweep operation did not produce a solid")
|
||||
result = Solid(builder.Shape())
|
||||
if not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9 or not result.is_valid:
|
||||
raise ValueError("OCC sweep operation did not produce a valid solid")
|
||||
relations: list[TopologyDeltaRelation] = []
|
||||
for output, role in ((builder.FirstShape(), "sweep.start"), (builder.LastShape(), "sweep.end")):
|
||||
if not output.IsNull() and output.ShapeType() == TopAbs_FACE:
|
||||
relations.append(TopologyDeltaRelation(
|
||||
"generated", "face", section.wrapped, (output,), output_role=role,
|
||||
))
|
||||
return result, TopologyDelta(operation="sweep", relations=tuple(relations))
|
||||
|
||||
@staticmethod
|
||||
def sweep_path(
|
||||
points: Iterable[Vector3],
|
||||
@@ -1005,6 +1750,21 @@ class Build123dGeometryAdapter:
|
||||
tangents = [_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None
|
||||
if parameters is not None and len(parameters) != len(vertices):
|
||||
raise ValueError("sweep B-spline path parameters must match point count")
|
||||
direction = vertices[-1] - vertices[0]
|
||||
tolerance = 1e-9 * max(1.0, direction.length)
|
||||
collinear_points = direction.length > tolerance and all(
|
||||
(point - vertices[0]).cross(direction).length <= tolerance
|
||||
for point in vertices[1:-1]
|
||||
)
|
||||
collinear_tangents = tangents is None or all(
|
||||
tangent.cross(direction).length <= tolerance and tangent.dot(direction) > tolerance
|
||||
for tangent in tangents
|
||||
)
|
||||
if collinear_points and collinear_tangents:
|
||||
# OCC 对完全共线的插值 B-spline 做实体 sweep 时可能无限求解。
|
||||
# 此处的点列和端切线没有曲率信息,几何上严格等价于一条直线;
|
||||
# 仅在同向条件成立时退化,反向切线仍保留 B-spline 语义。
|
||||
return Edge.make_line(vertices[0], vertices[-1])
|
||||
return Edge.make_spline(vertices, tangents=tangents, parameters=parameters, scale=False)
|
||||
|
||||
@staticmethod
|
||||
@@ -1384,6 +2144,20 @@ class Build123dGeometryAdapter:
|
||||
"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])
|
||||
|
||||
@@ -12,17 +12,20 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .runtime_types import CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeDiagnostic
|
||||
from .runtime_types import (
|
||||
CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeDiagnostic,
|
||||
pattern_instance_member_id, transform_copy_member_id,
|
||||
)
|
||||
from .operation_contracts import materialized_feature_contracts
|
||||
|
||||
|
||||
_SELECTOR_REQUIRED = frozenset({"extrude_add_blind_with_hole", "loft_add_with_cap_face", "fillet", "chamfer", "shell"})
|
||||
_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_surface",
|
||||
"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",
|
||||
@@ -36,11 +39,18 @@ _ACTIVE_BODY_REQUIRED = frozenset({
|
||||
"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_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", *_HOLE_ATOMICS, "fillet", "chamfer", "shell", "boolean_bodies",
|
||||
})
|
||||
# ``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
|
||||
# contracts that preserve a previous member. Circular patterns over those
|
||||
# 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 and bend_add are excluded: pattern translation does not yet move
|
||||
@@ -61,6 +71,167 @@ _EXTENT_TARGET_KINDS = {
|
||||
}
|
||||
|
||||
|
||||
def _mappings(value: Any):
|
||||
"""Yield nested feature mappings for capability-only contract checks."""
|
||||
if isinstance(value, dict):
|
||||
yield value
|
||||
for child in value.values():
|
||||
yield from _mappings(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
yield from _mappings(child)
|
||||
|
||||
|
||||
def _transform_member_sources(params: dict[str, Any]) -> set[str]:
|
||||
"""Return internal body-member keys named by a transform contract."""
|
||||
source_ids = {str(value) for value in params.get("source_feature_ids") or ()}
|
||||
source_ids.update(
|
||||
pattern_instance_member_id(
|
||||
str(reference.get("pattern_feature_id") or ""),
|
||||
str(reference.get("source_feature_id") or ""),
|
||||
int(reference.get("instance_index") or 0),
|
||||
)
|
||||
for reference in params.get("pattern_instance_refs") or ()
|
||||
if isinstance(reference, dict)
|
||||
)
|
||||
source_ids.update(
|
||||
transform_copy_member_id(
|
||||
str(reference.get("transform_feature_id") or ""),
|
||||
str(reference.get("source_feature_id") or ""),
|
||||
)
|
||||
for reference in params.get("transform_copy_refs") or ()
|
||||
if isinstance(reference, dict)
|
||||
)
|
||||
return source_ids
|
||||
|
||||
|
||||
def _pattern_instance_member_sources(params: dict[str, Any], parameter: str) -> set[str]:
|
||||
"""Project structured pattern refs to internal body-member keys."""
|
||||
return {
|
||||
pattern_instance_member_id(
|
||||
str(reference.get("pattern_feature_id") or ""),
|
||||
str(reference.get("source_feature_id") or ""),
|
||||
int(reference.get("instance_index") or 0),
|
||||
)
|
||||
for reference in params.get(parameter) or ()
|
||||
if isinstance(reference, dict)
|
||||
}
|
||||
|
||||
|
||||
def _next_body_graph(
|
||||
node: FeaturePlanNode,
|
||||
members: set[str],
|
||||
has_active_body: bool,
|
||||
nodes_by_id: dict[str, FeaturePlanNode],
|
||||
) -> tuple[set[str], bool]:
|
||||
"""Project the explicit runtime body-member lifecycle without geometry.
|
||||
|
||||
The capability phase cannot know whether two B-reps intersect, but it can
|
||||
mirror the ownership contract used by ``ExecutionSession``. This keeps a
|
||||
historical feature's successful execution separate from its continued
|
||||
availability as an independently selectable body. In particular, this
|
||||
must never turn an absorbed feature or pattern replay into ``session.body``.
|
||||
"""
|
||||
atomic_id = node.atomic_id
|
||||
feature_id = node.feature_id
|
||||
|
||||
if atomic_id == "boolean_bodies":
|
||||
target_ids = {str(value) for value in node.params.get("target_feature_ids") or ()}
|
||||
target_ids.update(_pattern_instance_member_sources(node.params, "target_pattern_instance_refs"))
|
||||
tool_ids = {str(value) for value in node.params.get("tool_feature_ids") or ()}
|
||||
tool_ids.update(_pattern_instance_member_sources(node.params, "tool_pattern_instance_refs"))
|
||||
next_members = members - target_ids - tool_ids
|
||||
next_members.add(feature_id)
|
||||
if bool(node.params.get("keep_tools")):
|
||||
next_members.update(tool_ids)
|
||||
return next_members, True
|
||||
|
||||
if atomic_id == "transform_bodies":
|
||||
source_ids = _transform_member_sources(node.params)
|
||||
next_members = set(members)
|
||||
if not bool(node.params.get("make_copy")):
|
||||
next_members.difference_update(source_ids)
|
||||
next_members.add(feature_id)
|
||||
elif len(node.params.get("source_feature_ids") or ()) > 1:
|
||||
# Multi-source COPY outputs have no aggregate body-member owner.
|
||||
# Keep each transformed source addressable by its exact origin.
|
||||
next_members.update(
|
||||
transform_copy_member_id(feature_id, source_id)
|
||||
for source_id in source_ids
|
||||
)
|
||||
else:
|
||||
next_members.add(feature_id)
|
||||
return next_members, True
|
||||
|
||||
if atomic_id == "delete_bodies":
|
||||
source_ids = {str(value) for value in node.params.get("target_feature_ids") or ()}
|
||||
next_members = members - source_ids
|
||||
return next_members, bool(next_members)
|
||||
|
||||
if atomic_id in _PATTERN_ATOMICS:
|
||||
source_ids = {str(value) for value in node.params.get("source_feature_ids") or ()}
|
||||
if (
|
||||
atomic_id == "pattern_mirror"
|
||||
and source_ids
|
||||
and source_ids <= members
|
||||
and all(
|
||||
(source := nodes_by_id.get(source_id)) is not None
|
||||
and source.params.get("result_mode") == "new_body"
|
||||
for source_id in source_ids
|
||||
)
|
||||
):
|
||||
# A mirror produces an independently addressable COPY only when
|
||||
# every source is an explicit NEW body. A hole, dress-up, or
|
||||
# ordinary additive feature may be the current aggregate's
|
||||
# successor, not a standalone body: its mirror must remain a
|
||||
# feature replay and cannot be exposed as a body member.
|
||||
next_members = set(members)
|
||||
next_members.update(
|
||||
pattern_instance_member_id(node.feature_id, source_id, 1)
|
||||
for source_id in source_ids
|
||||
)
|
||||
return next_members, True
|
||||
if (
|
||||
atomic_id == "pattern_circular"
|
||||
and str(node.params.get("operation_mode") or "add") == "add"
|
||||
and source_ids
|
||||
and source_ids <= members
|
||||
):
|
||||
count = int(node.params.get("pattern_count") or 0)
|
||||
excluded = {int(value) for value in node.params.get("excluded_instance_indices") or ()}
|
||||
next_members = set(members)
|
||||
for instance in range(1, count):
|
||||
if instance in excluded:
|
||||
continue
|
||||
next_members.update(
|
||||
pattern_instance_member_id(node.feature_id, source_id, instance)
|
||||
for source_id in source_ids
|
||||
)
|
||||
return next_members, True
|
||||
# Replay/fused pattern output has no member-level contract. It creates
|
||||
# active geometry, but cannot prove which replay instance a later body
|
||||
# query names.
|
||||
return set(), has_active_body
|
||||
|
||||
if atomic_id not in _BODY_MUTATING_ATOMICS:
|
||||
return members, has_active_body
|
||||
|
||||
if atomic_id in {"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "revolve_cut"} or (
|
||||
atomic_id == "extrude_from_face" and node.params.get("operation") == "cut"
|
||||
):
|
||||
# Primary cuts execute per explicit member in the runtime so a later
|
||||
# body query still addresses the same CADFS NEW/COPY lifecycle node.
|
||||
return set(members), True
|
||||
|
||||
if atomic_id in _PRIMARY_ATOMICS and "cut" not in atomic_id and node.params.get("operation") != "cut" and node.params.get("result_mode") == "new_body":
|
||||
return members | {feature_id}, True
|
||||
|
||||
# All remaining body-mutating executors register their output as the sole
|
||||
# explicit member. This includes fused additive features, cuts and
|
||||
# dress-ups, whose source-member topology no longer has an identity.
|
||||
return {feature_id}, True
|
||||
|
||||
|
||||
def _has_explicit_axis(axis: Any) -> bool:
|
||||
return isinstance(axis, dict) and axis.get("origin_mm") is not None and axis.get("direction") is not None
|
||||
|
||||
@@ -171,6 +342,13 @@ def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]:
|
||||
|
||||
def _has_closed_region(sketch: dict[str, Any]) -> bool:
|
||||
"""Mirror the adapter's input contract without importing the geometry kernel."""
|
||||
profile = sketch.get("profile") or {}
|
||||
if profile.get("type") == "planar_imprint":
|
||||
# The exact bounded-region proof happens in the OCC splitter. At this
|
||||
# stage the typed contract proves only that region selection work is
|
||||
# possible; an unbounded or ambiguous runtime result remains a stable
|
||||
# feature execution diagnostic rather than a guessed sketch contour.
|
||||
return bool(sketch.get("imprint_entities_mm") and sketch.get("imprint_selections"))
|
||||
regions = sketch.get("contour_regions_mm") or []
|
||||
if any(len(region.get("outer") or []) >= 1 for region in regions if isinstance(region, dict)):
|
||||
return True
|
||||
@@ -254,6 +432,7 @@ class CapabilityAnalyzer:
|
||||
results: list[CapabilityResult] = []
|
||||
completed: set[str] = set()
|
||||
body_available = False
|
||||
body_members: set[str] = set()
|
||||
for node in plan:
|
||||
blockers: list[RuntimeDiagnostic] = []
|
||||
contract = self.contracts.get(node.atomic_id)
|
||||
@@ -267,7 +446,9 @@ class CapabilityAnalyzer:
|
||||
for dependency in node.depends_on:
|
||||
if dependency not in completed:
|
||||
blockers.append(self._blocker(node.feature_id, "dependency_unavailable", "Feature dependency did not become executable", dependency=dependency))
|
||||
if node.atomic_id in _ACTIVE_BODY_REQUIRED:
|
||||
if node.atomic_id in _ACTIVE_BODY_REQUIRED or (
|
||||
node.atomic_id == "extrude_from_face" and node.params.get("operation") == "cut"
|
||||
):
|
||||
required.append("active_body")
|
||||
if not body_available:
|
||||
blockers.append(self._blocker(
|
||||
@@ -362,6 +543,74 @@ class CapabilityAnalyzer:
|
||||
"Loft currently requires exactly one outer profile without holes",
|
||||
sketch_id=sketch_id,
|
||||
))
|
||||
for selector in _mappings(params):
|
||||
if selector.get("output_role") is not None:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_output_role_selector_context",
|
||||
"Feature output role selectors are only supported in feature.selectors",
|
||||
))
|
||||
for selector_index, selector in enumerate(node.selectors):
|
||||
if selector.get("output_role") is None:
|
||||
continue
|
||||
required.append("selector:feature_output_role")
|
||||
if contract is None or contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face":
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_output_role_selector",
|
||||
"This feature contract cannot consume a feature output role selector",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if selector.get("kind") != "face" or not isinstance(selector.get("owner_feature_id"), str):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"invalid_output_role_selector",
|
||||
"A feature output role selector requires kind face and owner_feature_id",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if selector.get("source") != "runtime_snapshot":
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"invalid_output_role_selector",
|
||||
"A feature output role selector requires runtime_snapshot evidence",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"invalid_output_role_selector",
|
||||
"A feature output role selector cannot mix stable or geometry evidence",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
role_source = selector.get("output_role_source")
|
||||
if role_source is not None:
|
||||
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
|
||||
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
|
||||
source = nodes_by_id.get(str(source_owner or ""))
|
||||
source_params = (source.params if source is not None else {}) or {}
|
||||
if not isinstance(source_owner, str) or not isinstance(source_role, str):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_output_role_source",
|
||||
"An output role source requires owner_feature_id and output_role",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
elif selector.get("output_role") != "shell.offset_face" or node.atomic_id != "shell":
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "unsupported_output_role_source",
|
||||
"Output role sources are currently supported only for shell.offset_face",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
elif source_role not in {"extrude.start", "extrude.end"} or (
|
||||
source is None
|
||||
or source.atomic_id != "extrude_add_blind"
|
||||
or source_params.get("result_mode") != "new_body"
|
||||
or (source_params.get("end_condition") or {}).get("type") != "blind"
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "unsupported_output_role_source",
|
||||
"shell.offset_face requires a direct new_body blind extrusion cap source",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if node.atomic_id == "sweep_add":
|
||||
path = params.get("path")
|
||||
segment = path.get("segment") if isinstance(path, dict) else None
|
||||
@@ -389,18 +638,29 @@ class CapabilityAnalyzer:
|
||||
))
|
||||
if node.atomic_id == "boolean_bodies":
|
||||
target_ids = params.get("target_feature_ids")
|
||||
target_instance_refs = params.get("target_pattern_instance_refs")
|
||||
tool_ids = params.get("tool_feature_ids")
|
||||
tool_instance_refs = params.get("tool_pattern_instance_refs")
|
||||
operation = params.get("operation")
|
||||
if operation not in {"union", "subtract", "intersect"}:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "unsupported_boolean_operation",
|
||||
"booleanBodies requires union, subtract or intersect",
|
||||
))
|
||||
for parameter, feature_ids in (("target_feature_ids", target_ids), ("tool_feature_ids", tool_ids)):
|
||||
if not isinstance(feature_ids, list) or not feature_ids:
|
||||
target_members: set[str] = set()
|
||||
tool_members: set[str] = set()
|
||||
for parameter, instance_parameter, feature_ids, instance_refs, selected_members in (
|
||||
("target_feature_ids", "target_pattern_instance_refs", target_ids, target_instance_refs, target_members),
|
||||
("tool_feature_ids", "tool_pattern_instance_refs", tool_ids, tool_instance_refs, tool_members),
|
||||
):
|
||||
if feature_ids is None:
|
||||
feature_ids = []
|
||||
if instance_refs is None:
|
||||
instance_refs = []
|
||||
if not isinstance(feature_ids, list) or not isinstance(instance_refs, list) or not (feature_ids or instance_refs):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "missing_boolean_bodies",
|
||||
"booleanBodies requires explicit target and tool feature ids", parameter=parameter,
|
||||
"booleanBodies requires explicit target and tool body references", parameter=parameter,
|
||||
))
|
||||
continue
|
||||
for feature_id in feature_ids:
|
||||
@@ -408,18 +668,222 @@ class CapabilityAnalyzer:
|
||||
if source is None:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "boolean_body_unavailable",
|
||||
"booleanBodies source feature does not exist", feature_id=feature_id,
|
||||
"booleanBodies source feature does not exist", source_feature_id=feature_id,
|
||||
))
|
||||
elif source.feature_id not in completed:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "boolean_body_unavailable",
|
||||
"booleanBodies source feature did not become executable", feature_id=feature_id,
|
||||
"booleanBodies source feature did not become executable", source_feature_id=feature_id,
|
||||
))
|
||||
if isinstance(target_ids, list) and isinstance(tool_ids, list) and set(target_ids) & set(tool_ids):
|
||||
elif source.feature_id not in body_members:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "boolean_body_unavailable",
|
||||
"Selected source no longer has an independently selectable body output",
|
||||
source_feature_id=feature_id,
|
||||
))
|
||||
else:
|
||||
selected_members.add(str(feature_id))
|
||||
for index, reference in enumerate(instance_refs):
|
||||
if not isinstance(reference, dict):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_pattern_instance_ref",
|
||||
"Pattern instance body reference must be an object", parameter=instance_parameter, index=index,
|
||||
))
|
||||
continue
|
||||
instance = reference.get("instance_index")
|
||||
if not isinstance(instance, int):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_pattern_instance_ref",
|
||||
"Pattern instance body reference requires an integer instance_index",
|
||||
parameter=instance_parameter, index=index,
|
||||
))
|
||||
continue
|
||||
member_id = pattern_instance_member_id(
|
||||
str(reference.get("pattern_feature_id") or ""),
|
||||
str(reference.get("source_feature_id") or ""),
|
||||
instance,
|
||||
)
|
||||
if member_id not in body_members:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "pattern_instance_unavailable",
|
||||
"Pattern instance has no independently selectable body output",
|
||||
pattern_feature_id=reference.get("pattern_feature_id"),
|
||||
source_feature_id=reference.get("source_feature_id"),
|
||||
instance_index=reference.get("instance_index"),
|
||||
))
|
||||
continue
|
||||
selected_members.add(member_id)
|
||||
if target_members & tool_members:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "boolean_body_overlap",
|
||||
"booleanBodies targets and tools must be disjoint",
|
||||
))
|
||||
if node.atomic_id == "shell" and params.get("target_feature_id") is not None:
|
||||
target_feature_id = params.get("target_feature_id")
|
||||
required.append("shell:explicit_target_body")
|
||||
target = nodes_by_id.get(str(target_feature_id or ""))
|
||||
if not isinstance(target_feature_id, str) or not target_feature_id:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_shell_target_body",
|
||||
"shell target_feature_id must name one preceding body member",
|
||||
))
|
||||
elif target is None:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "shell_target_body_unavailable",
|
||||
"shell target body feature does not exist",
|
||||
target_feature_id=target_feature_id,
|
||||
))
|
||||
elif target.feature_id not in completed:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "shell_target_body_unavailable",
|
||||
"shell target body feature did not become executable",
|
||||
target_feature_id=target_feature_id,
|
||||
))
|
||||
elif target.feature_id not in body_members:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "shell_target_body_unavailable",
|
||||
"shell target no longer has an independently selectable body output",
|
||||
target_feature_id=target_feature_id,
|
||||
))
|
||||
if node.atomic_id in {"transform_bodies", "delete_bodies"}:
|
||||
parameter = "source_feature_ids" if node.atomic_id == "transform_bodies" else "target_feature_ids"
|
||||
source_ids = params.get(parameter)
|
||||
pattern_instances = params.get("pattern_instance_refs") if node.atomic_id == "transform_bodies" else []
|
||||
transform_copies = params.get("transform_copy_refs") if node.atomic_id == "transform_bodies" else []
|
||||
if source_ids is None:
|
||||
source_ids = []
|
||||
if pattern_instances is None:
|
||||
pattern_instances = []
|
||||
if transform_copies is None:
|
||||
transform_copies = []
|
||||
if (
|
||||
not isinstance(source_ids, list)
|
||||
or not isinstance(pattern_instances, list)
|
||||
or not isinstance(transform_copies, list)
|
||||
or not (source_ids or pattern_instances or transform_copies)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "missing_body_sources",
|
||||
f"{node.atomic_id} requires explicit source body feature ids", parameter=parameter,
|
||||
))
|
||||
else:
|
||||
for source_id in source_ids:
|
||||
source = nodes_by_id.get(str(source_id))
|
||||
if source is None:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "body_source_unavailable",
|
||||
"Selected body source feature does not exist", source_feature_id=source_id,
|
||||
))
|
||||
elif source.feature_id not in completed:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "body_source_unavailable",
|
||||
"Selected body source did not become executable", source_feature_id=source_id,
|
||||
))
|
||||
elif source.feature_id not in body_members:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "body_source_unavailable",
|
||||
"Selected source no longer has an independently selectable body output",
|
||||
source_feature_id=source_id,
|
||||
))
|
||||
for reference in pattern_instances:
|
||||
if not isinstance(reference, dict):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_pattern_instance_ref",
|
||||
"Pattern instance body reference must be an object",
|
||||
))
|
||||
continue
|
||||
pattern_id = str(reference.get("pattern_feature_id") or "")
|
||||
source_id = str(reference.get("source_feature_id") or "")
|
||||
instance = reference.get("instance_index")
|
||||
pattern = nodes_by_id.get(pattern_id)
|
||||
if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "pattern_instance_unavailable",
|
||||
"Pattern instance owner is not a preceding circular or mirror pattern",
|
||||
pattern_feature_id=pattern_id,
|
||||
))
|
||||
continue
|
||||
if pattern.feature_id not in completed:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "pattern_instance_unavailable",
|
||||
"Pattern instance owner did not become executable",
|
||||
pattern_feature_id=pattern_id,
|
||||
))
|
||||
continue
|
||||
count = int(pattern.params.get("pattern_count") or 0)
|
||||
excluded = {int(value) for value in pattern.params.get("excluded_instance_indices") or ()}
|
||||
surviving_instance = (
|
||||
isinstance(instance, int)
|
||||
and (
|
||||
(pattern.atomic_id == "pattern_mirror" and instance == 1)
|
||||
or (
|
||||
pattern.atomic_id == "pattern_circular"
|
||||
and 1 <= instance < count
|
||||
and instance not in excluded
|
||||
)
|
||||
)
|
||||
)
|
||||
if (
|
||||
not surviving_instance
|
||||
or source_id not in {str(value) for value in pattern.params.get("source_feature_ids") or ()}
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "pattern_instance_unavailable",
|
||||
"Pattern instance reference is not a surviving source copy",
|
||||
pattern_feature_id=pattern_id, source_feature_id=source_id, instance_index=instance,
|
||||
))
|
||||
continue
|
||||
member_id = pattern_instance_member_id(pattern_id, source_id, instance)
|
||||
if member_id not in body_members:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "pattern_instance_unavailable",
|
||||
"Pattern instance has no independently selectable body output",
|
||||
pattern_feature_id=pattern_id, source_feature_id=source_id, instance_index=instance,
|
||||
))
|
||||
for reference in transform_copies:
|
||||
if not isinstance(reference, dict):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_transform_copy_ref",
|
||||
"Transform COPY body reference must be an object",
|
||||
))
|
||||
continue
|
||||
transform_id = str(reference.get("transform_feature_id") or "")
|
||||
source_id = str(reference.get("source_feature_id") or "")
|
||||
transform = nodes_by_id.get(transform_id)
|
||||
if transform is None or transform.atomic_id != "transform_bodies":
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "transform_copy_unavailable",
|
||||
"Transform COPY owner is not a preceding body transform",
|
||||
transform_feature_id=transform_id,
|
||||
))
|
||||
continue
|
||||
if transform.feature_id not in completed:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "transform_copy_unavailable",
|
||||
"Transform COPY owner did not become executable",
|
||||
transform_feature_id=transform_id,
|
||||
))
|
||||
continue
|
||||
transform_sources = transform.params.get("source_feature_ids") or []
|
||||
if (
|
||||
not bool(transform.params.get("make_copy"))
|
||||
or not isinstance(transform_sources, list)
|
||||
or len(transform_sources) < 2
|
||||
or source_id not in {str(value) for value in transform_sources}
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "transform_copy_unavailable",
|
||||
"Transform COPY reference is not a source-qualified multi-body copy",
|
||||
transform_feature_id=transform_id, source_feature_id=source_id,
|
||||
))
|
||||
continue
|
||||
member_id = transform_copy_member_id(transform_id, source_id)
|
||||
if member_id not in body_members:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "transform_copy_unavailable",
|
||||
"Transform COPY source has no independently selectable body output",
|
||||
transform_feature_id=transform_id, source_feature_id=source_id,
|
||||
))
|
||||
if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES):
|
||||
end_condition = params.get("end_condition") or {"type": "blind"}
|
||||
end_type = end_condition.get("type")
|
||||
@@ -429,7 +893,7 @@ class CapabilityAnalyzer:
|
||||
# Solid.extrude_taper。双向、到面和非实体 profile 的中性面
|
||||
# 语义尚无 CDSL 表达,必须保留为明确能力缺口。
|
||||
if (
|
||||
node.atomic_id not in {"extrude_add_blind", "extrude_cut_blind"}
|
||||
node.atomic_id not in {"extrude_add_blind", "extrude_cut_blind", "extrude_from_face"}
|
||||
or end_type != "blind"
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
@@ -469,7 +933,7 @@ class CapabilityAnalyzer:
|
||||
node.feature_id, "missing_offset_distance",
|
||||
"Offset-from-surface requires a non-zero captured offset distance",
|
||||
))
|
||||
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}:
|
||||
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_type = reverse_condition.get("type")
|
||||
required.append(f"extent:reverse:{reverse_type}")
|
||||
@@ -501,7 +965,7 @@ class CapabilityAnalyzer:
|
||||
))
|
||||
if node.atomic_id in _SELECTOR_REQUIRED and not node.selectors:
|
||||
blockers.append(self._blocker(node.feature_id, "missing_selector", "Dress-up features require an explicit selector"))
|
||||
if node.atomic_id in {"extrude_add_blind_with_hole", "loft_add_with_cap_face"}:
|
||||
if node.atomic_id in {"extrude_add_blind_with_hole", "extrude_from_face", "loft_add_with_cap_face"}:
|
||||
face_selectors = [selector for selector in node.selectors if selector.get("kind") == "face"]
|
||||
if len(face_selectors) != 1 or len(node.selectors) != 1:
|
||||
blockers.append(self._blocker(
|
||||
@@ -604,10 +1068,9 @@ class CapabilityAnalyzer:
|
||||
results.append(CapabilityResult(node.feature_id, node.atomic_id, status, tuple(required), tuple(blockers)))
|
||||
if status == "executable":
|
||||
completed.add(node.feature_id)
|
||||
if node.atomic_id in _BODY_MUTATING_ATOMICS:
|
||||
body_available = True
|
||||
body_members, body_available = _next_body_graph(node, body_members, body_available, nodes_by_id)
|
||||
body_producers = {
|
||||
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
|
||||
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face",
|
||||
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "boolean_bodies",
|
||||
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
|
||||
"thread_add", "bend_add",
|
||||
|
||||
@@ -103,6 +103,12 @@
|
||||
"required": ["distance_mm"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"extrudeFromFaceParams": {
|
||||
"type": "object",
|
||||
"properties": {"distance_mm": {"$ref": "#/$defs/number"}, "operation": {"enum": ["add", "cut"]}, "reverse": {"type": "boolean"}, "reverse_distance_mm": {"$ref": "#/$defs/number"}, "two_sided": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}, "reverse_end_condition": {"$ref": "#/$defs/endCondition"}, "draft": {"$ref": "#/$defs/extrudeDraft"}, "result_mode": {"enum": ["fuse", "new_body"]}},
|
||||
"required": ["distance_mm", "operation"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"extrudeDraft": {
|
||||
"type": "object",
|
||||
"properties": {"angle_deg": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 90}, "pull_direction": {"type": "boolean"}},
|
||||
@@ -346,7 +352,7 @@
|
||||
},
|
||||
"shellParams": {
|
||||
"type": "object",
|
||||
"properties": {"thickness_mm": {"type": "number", "exclusiveMinimum": 0}, "inward": {"type": "boolean"}},
|
||||
"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
|
||||
},
|
||||
@@ -355,10 +361,74 @@
|
||||
"properties": {
|
||||
"operation": {"enum": ["union", "subtract", "intersect"]},
|
||||
"target_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
|
||||
"target_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
|
||||
"tool_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
|
||||
"tool_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
|
||||
"keep_tools": {"type": "boolean"}
|
||||
},
|
||||
"required": ["operation", "target_feature_ids", "tool_feature_ids"],
|
||||
"required": ["operation"],
|
||||
"allOf": [
|
||||
{"anyOf": [{"required": ["target_feature_ids"]}, {"required": ["target_pattern_instance_refs"]}]},
|
||||
{"anyOf": [{"required": ["tool_feature_ids"]}, {"required": ["tool_pattern_instance_refs"]}]}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"bodyTransform": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"enum": ["translation", "rotation", "uniform_scale"]},
|
||||
"translation_mm": {"$ref": "#/$defs/point3"},
|
||||
"axis": {"$ref": "#/$defs/axis"},
|
||||
"angle_deg": {"type": "number"},
|
||||
"center_mm": {"$ref": "#/$defs/point3"},
|
||||
"scale_factor": {"type": "number", "exclusiveMinimum": 0}
|
||||
},
|
||||
"required": ["type"],
|
||||
"allOf": [
|
||||
{"if": {"properties": {"type": {"const": "translation"}}, "required": ["type"]}, "then": {"required": ["translation_mm"]}},
|
||||
{"if": {"properties": {"type": {"const": "rotation"}}, "required": ["type"]}, "then": {"required": ["axis", "angle_deg"]}},
|
||||
{"if": {"properties": {"type": {"const": "uniform_scale"}}, "required": ["type"]}, "then": {"required": ["center_mm", "scale_factor"]}}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"patternInstanceBodyRef": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"source_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"instance_index": {"type": "integer", "minimum": 1}
|
||||
},
|
||||
"required": ["pattern_feature_id", "source_feature_id", "instance_index"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"transformCopyBodyRef": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"transform_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"source_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
|
||||
},
|
||||
"required": ["transform_feature_id", "source_feature_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"transformBodiesParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
|
||||
"pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
|
||||
"transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}},
|
||||
"transform": {"$ref": "#/$defs/bodyTransform"},
|
||||
"make_copy": {"type": "boolean"}
|
||||
},
|
||||
"required": ["transform", "make_copy"],
|
||||
"anyOf": [{"required": ["source_feature_ids"]}, {"required": ["pattern_instance_refs"]}, {"required": ["transform_copy_refs"]}],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"deleteBodiesParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}
|
||||
},
|
||||
"required": ["target_feature_ids"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"linearPatternParams": {
|
||||
@@ -436,12 +506,30 @@
|
||||
"required": ["hole_type", "diameter_mm", "depth_mm", "end_condition"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"featureOutputRole": {
|
||||
"enum": [
|
||||
"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end",
|
||||
"shell.offset_face", "shell.closing_descendant", "shell.body_face"
|
||||
]
|
||||
},
|
||||
"outputRoleSource": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_.:-]{1,160}$"},
|
||||
"output_role": {"$ref": "#/$defs/featureOutputRole"}
|
||||
},
|
||||
"required": ["owner_feature_id", "output_role"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selectorRef": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]},
|
||||
"stable_id": {"type": "string", "minLength": 1},
|
||||
"output_role": {"$ref": "#/$defs/featureOutputRole"},
|
||||
"output_role_source": {"$ref": "#/$defs/outputRoleSource"},
|
||||
"owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_.:-]{1,160}$"},
|
||||
"owner_match_required": {"type": "boolean"},
|
||||
"geometry": {"type": "object"},
|
||||
"source": {"enum": ["solidworks", "inferred_from_step", "runtime_snapshot", "viewer_selection"]},
|
||||
"snapshot_id": {"type": "string", "minLength": 1},
|
||||
@@ -451,7 +539,8 @@
|
||||
"intersection_of": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
|
||||
},
|
||||
"required": ["kind", "stable_id", "source", "confidence"],
|
||||
"required": ["kind", "source", "confidence"],
|
||||
"anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"analyticSegment": {
|
||||
@@ -465,8 +554,8 @@
|
||||
"major_radius_mm": {"$ref": "#/$defs/positive"},
|
||||
"minor_radius_mm": {"$ref": "#/$defs/positive"},
|
||||
"major_axis": {"$ref": "#/$defs/point2"},
|
||||
"points": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}},
|
||||
"parameters": {"type": "array", "minItems": 3, "items": {"type": "number"}},
|
||||
"points": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/point2"}},
|
||||
"parameters": {"type": "array", "minItems": 2, "items": {"type": "number"}},
|
||||
"periodic": {"type": "boolean"},
|
||||
"parameterization": {"enum": ["chord", "centripetal"]},
|
||||
"clockwise": {"type": "boolean"},
|
||||
@@ -479,7 +568,8 @@
|
||||
{"if": {"properties": {"type": {"const": "arc"}}}, "then": {"required": ["start", "end", "center", "radius_mm"]}},
|
||||
{"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}},
|
||||
{"if": {"properties": {"type": {"const": "ellipse"}}}, "then": {"required": ["center", "major_radius_mm", "minor_radius_mm", "major_axis"]}},
|
||||
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["start", "end", "points"]}}
|
||||
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["start", "end", "points"]}},
|
||||
{"if": {"properties": {"type": {"const": "bspline"}, "points": {"maxItems": 2}}}, "then": {"required": ["start_tangent", "end_tangent", "parameters"], "properties": {"periodic": {"const": false}}}}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -522,7 +612,46 @@
|
||||
"required": ["type", "contours"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_surface", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "fillet", "chamfer", "shell", "boolean_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
|
||||
"imprintSourceEntity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"curve": {"$ref": "#/$defs/analyticSegment"}
|
||||
},
|
||||
"required": ["id", "curve"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"imprintFragment": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"anchor_entity_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"side": {"enum": [-1, 1]},
|
||||
"intersection_index": {"type": "integer", "minimum": 0}
|
||||
},
|
||||
"required": ["anchor_entity_id", "side"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"imprintSelection": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"face_side": {"enum": [-1, 1]},
|
||||
"fragment": {"$ref": "#/$defs/imprintFragment"}
|
||||
},
|
||||
"required": ["source_entity_id", "face_side"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"planarImprintProfile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"const": "planar_imprint"},
|
||||
"source_entities": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/imprintSourceEntity"}},
|
||||
"selections": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSelection"}}
|
||||
},
|
||||
"required": ["type", "source_entities", "selections"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_from_face", "extrude_surface", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "fillet", "chamfer", "shell", "boolean_bodies", "transform_bodies", "delete_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
|
||||
"feature": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -545,6 +674,7 @@
|
||||
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_through"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeCutThroughParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_two_sided"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "extrude_from_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeFromFaceParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "loft_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "loft_add_with_cap_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftCapFaceParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "sweep_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sweepParams"}}}},
|
||||
@@ -565,6 +695,8 @@
|
||||
{"if": {"properties": {"atomic_id": {"const": "chamfer"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/chamferParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "shell"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/shellParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "boolean_bodies"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/booleanBodiesParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "transform_bodies"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/transformBodiesParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "delete_bodies"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/deleteBodiesParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "pattern_linear"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/linearPatternParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "pattern_mirror"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/mirrorPatternParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "pattern_circular"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/circularPatternParams"}}}},
|
||||
@@ -573,12 +705,13 @@
|
||||
{"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}}
|
||||
]
|
||||
},
|
||||
"profile_type": {"enum": ["circle", "polygon", "analytic_contours"]},
|
||||
"profile_type": {"enum": ["circle", "polygon", "analytic_contours", "planar_imprint"]},
|
||||
"profile": {
|
||||
"oneOf": [
|
||||
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false},
|
||||
{"$ref": "#/$defs/analyticProfile"}
|
||||
{"$ref": "#/$defs/analyticProfile"},
|
||||
{"$ref": "#/$defs/planarImprintProfile"}
|
||||
]
|
||||
},
|
||||
"sketch": {
|
||||
|
||||
@@ -30,7 +30,13 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
|
||||
shape = raw.get("fragment_shape")
|
||||
params_schema = raw.get("author_params_schema")
|
||||
injected_paths = raw.get("server_injected_paths")
|
||||
if not isinstance(shape, dict) or not isinstance(params_schema, dict) or not isinstance(injected_paths, list):
|
||||
selector_policy = raw.get("selector_policy")
|
||||
if (
|
||||
not isinstance(shape, dict)
|
||||
or not isinstance(params_schema, dict)
|
||||
or not isinstance(injected_paths, list)
|
||||
or not isinstance(selector_policy, dict)
|
||||
):
|
||||
raise ValueError(f"operation contract is incomplete for {atomic_id}")
|
||||
properties = params_schema.get("properties")
|
||||
required = params_schema.get("required")
|
||||
@@ -51,5 +57,7 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
|
||||
"required_params": materialized_required,
|
||||
"optional_params": [name for name in properties if name not in materialized_required],
|
||||
"requires_sketch": shape.get("sketch") == "required",
|
||||
"selector_slot": selector_policy.get("slot"),
|
||||
"selector_token_kind": selector_policy.get("token_kind"),
|
||||
}
|
||||
return derived
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"schema": "cdsl.engine.schema.v1",
|
||||
"schema_version": "1.3.2",
|
||||
"schema_version": "1.3.3",
|
||||
"cdsl_json_schema_file": "cdsl_schema.json",
|
||||
"maintenance_rule": "The CDSL-only runtime contract is limited to direct generic profiles and runtime.py EXECUTORS. Legacy macro profiles are importer compatibility syntax and must be lowered by cdsl_importer.legacy_profile_adapter before generic runtime validation.",
|
||||
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
|
||||
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours"],
|
||||
"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"]},
|
||||
@@ -62,8 +63,49 @@
|
||||
},
|
||||
"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.0","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"}},"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":"none"},"semantic_preflight":["requires_active_solid","selected_faces_exist"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
|
||||
"boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.0","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},"tool_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"}},"required":["operation","target_feature_ids","tool_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":[]},
|
||||
"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":[]},
|
||||
"transform_bodies": {
|
||||
"atomic_id": "transform_bodies",
|
||||
"contract_version": "3.3",
|
||||
"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},
|
||||
"pattern_instance_refs": {"type": "array", "items": {"type": "object", "properties": {"pattern_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "instance_index": {"type": "integer", "minimum": 1}}, "required": ["pattern_feature_id", "source_feature_id", "instance_index"], "additionalProperties": false}, "minItems": 1, "maxItems": 64, "uniqueItems": true},
|
||||
"transform_copy_refs": {"type": "array", "items": {"type": "object", "properties": {"transform_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}}, "required": ["transform_feature_id", "source_feature_id"], "additionalProperties": false}, "minItems": 1, "maxItems": 64, "uniqueItems": true},
|
||||
"transform": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"enum": ["translation", "rotation", "uniform_scale"]},
|
||||
"translation_mm": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3},
|
||||
"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"},
|
||||
"center_mm": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3},
|
||||
"scale_factor": {"type": "number", "exclusiveMinimum": 0}
|
||||
},
|
||||
"required": ["type"],
|
||||
"allOf": [
|
||||
{"if": {"properties": {"type": {"const": "translation"}}, "required": ["type"]}, "then": {"required": ["translation_mm"]}},
|
||||
{"if": {"properties": {"type": {"const": "rotation"}}, "required": ["type"]}, "then": {"required": ["axis", "angle_deg"]}},
|
||||
{"if": {"properties": {"type": {"const": "uniform_scale"}}, "required": ["type"]}, "then": {"required": ["center_mm", "scale_factor"]}}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"make_copy": {"type": "boolean"}
|
||||
},
|
||||
"required": ["transform", "make_copy"],
|
||||
"anyOf": [{"required": ["source_feature_ids"]}, {"required": ["pattern_instance_refs"]}, {"required": ["transform_copy_refs"]}],
|
||||
"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_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":[]}
|
||||
@@ -81,7 +123,10 @@
|
||||
"constraints": ["vertices contains at least three [u, v] points"]
|
||||
},
|
||||
"analytic_contours": {
|
||||
"summary": "Closed executable line, arc, circle, ellipse and interpolation B-spline contours. An ellipse retains its local center, radii and major-axis direction. Executable B-splines preserve ordered interpolation points; periodic contours repeat their first point only to state closure and are interpolated from unique points. B-splines may explicitly use chord or centripetal parameterization; CADFS closed skFitSpline uses centripetal. Imported construction B-splines remain non-executable audit geometry."
|
||||
"summary": "Closed executable line, arc, circle, ellipse and interpolation B-spline contours. An ellipse retains its local center, radii and major-axis direction. Executable B-splines preserve ordered interpolation points; periodic contours repeat their first point only to state closure and are interpolated from unique points. A non-periodic two-point B-spline is permitted only with explicit, strictly increasing parameters and both endpoint tangents, giving the OCP interpolation contract sufficient curvature information; a two-point B-spline without those fields is invalid rather than a line fallback. B-splines may explicitly use chord or centripetal parameterization; CADFS closed skFitSpline uses centripetal. Imported construction B-splines remain non-executable audit geometry."
|
||||
},
|
||||
"planar_imprint": {
|
||||
"summary": "An exact planar arrangement derived from one sketch's original analytic entities. Each selected region retains an IMPRINT source edge, its face side, and optional INTERSECT vertex order and fragment side. The adapter splits a bounded support face with OCC and rejects non-unique or unbounded selections; it never samples curves into a polygon or substitutes an unrelated sketch contour."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ from .build123d_adapter import Build123dGeometryAdapter
|
||||
from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract
|
||||
from .runtime_types import (
|
||||
AxisSpec, BendSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec,
|
||||
ThreadSpec, Vector3,
|
||||
ThreadSpec, TopologyDelta, TopologyDeltaRelation, Vector3,
|
||||
RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry,
|
||||
pattern_instance_member_id, transform_copy_member_id,
|
||||
vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit,
|
||||
)
|
||||
from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
|
||||
@@ -21,12 +22,12 @@ from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
|
||||
|
||||
ALL_ATOMIC_IDS = frozenset({
|
||||
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface",
|
||||
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add",
|
||||
"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",
|
||||
"pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies",
|
||||
"thread_add", "thread_cut",
|
||||
"bend_add",
|
||||
})
|
||||
@@ -86,10 +87,15 @@ class GeometryAdapter(Protocol):
|
||||
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]: ...
|
||||
@@ -98,9 +104,14 @@ class GeometryAdapter(Protocol):
|
||||
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 thread_solid(self, spec: ThreadSpec) -> Any: ...
|
||||
def bend_solid(self, spec: BendSpec) -> Any: ...
|
||||
@@ -114,10 +125,13 @@ class GeometryAdapter(Protocol):
|
||||
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: ...
|
||||
|
||||
|
||||
@@ -143,6 +157,8 @@ class ExecutionSession:
|
||||
*,
|
||||
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":
|
||||
@@ -153,7 +169,11 @@ class ExecutionSession:
|
||||
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))
|
||||
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
|
||||
@@ -163,7 +183,10 @@ class ExecutionSession:
|
||||
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)
|
||||
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,),
|
||||
@@ -184,6 +207,12 @@ class ExecutionSession:
|
||||
))
|
||||
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
|
||||
@@ -441,14 +470,31 @@ def _extent_vectors(
|
||||
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(_normal_from_sketch(sketch), field_name="sketch normal")
|
||||
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"}:
|
||||
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:
|
||||
@@ -525,6 +571,97 @@ def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any
|
||||
)
|
||||
|
||||
|
||||
def _cut_explicit_body_members(session: ExecutionSession, tool: Any) -> dict[str, Any]:
|
||||
"""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:
|
||||
# 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。
|
||||
|
||||
@@ -545,28 +682,16 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
|
||||
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)。
|
||||
extents = _extent_vectors(node, faces, selected_sketch, 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
|
||||
solids: list[Any] = []
|
||||
for face in faces:
|
||||
for extent in extents:
|
||||
if draft is not None:
|
||||
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
|
||||
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, topology_delta = _extruded_tool(
|
||||
node, faces, _normal_from_sketch(selected_sketch), session,
|
||||
)
|
||||
else:
|
||||
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
|
||||
axis = _revolve_axis(node, session)
|
||||
@@ -581,32 +706,26 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
|
||||
# 侧实现,使三方合同一致。
|
||||
if bool(node.params.get("reverse")):
|
||||
angle = -angle
|
||||
solids = [session.adapter.revolve(face, angle, axis) for face in faces]
|
||||
# 4. 将所有子实体做布尔并(fuse)合并为一个工具体(tool)。
|
||||
tool = None
|
||||
for solid in solids:
|
||||
tool = session.adapter.fuse(tool, solid)
|
||||
if tool is None:
|
||||
raise ValueError("primary feature produced no solid")
|
||||
# 5. 与当前主体做布尔操作:
|
||||
if "cut" in node.atomic_id:
|
||||
# 切除类特征:要求已有主体,从主体上减去工具体(cut)。
|
||||
if session.body is None:
|
||||
raise ValueError("cut feature has no body")
|
||||
body = session.adapter.cut(session.body, tool)
|
||||
members = {node.feature_id: body}
|
||||
elif node.params.get("result_mode") == "new_body":
|
||||
# FeatureScript NEW creates an independent result body even when it
|
||||
# intersects a prior body. Keep both shapes in the exported compound.
|
||||
body = session.adapter.combine(session.body, tool)
|
||||
members = {**session.body_members, node.feature_id: tool}
|
||||
else:
|
||||
# 添加类特征:将工具体并到当前主体上(fuse),首个特征时 body 为 None 也能直接成立。
|
||||
body = session.adapter.fuse(session.body, tool)
|
||||
members = {node.feature_id: body}
|
||||
# 6. 登记新主体(更新拓扑、记录重放定义),并返回该特征的结果对象。
|
||||
session.register_body(node.feature_id, body, replay_node=node, body_members=members)
|
||||
return session.result(node)
|
||||
tool = None
|
||||
for solid in (session.adapter.revolve(face, angle, axis) for face in faces):
|
||||
tool = session.adapter.fuse(tool, solid)
|
||||
if tool is None:
|
||||
raise ValueError("revolve produced no solid")
|
||||
return _apply_primary_tool(
|
||||
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
|
||||
)
|
||||
|
||||
|
||||
def _execute_extrude_from_face(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
|
||||
resolved = [session.resolve(selector) for selector in node.selectors]
|
||||
failed = next((item for item in resolved if item.status != "resolved"), None)
|
||||
if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face":
|
||||
raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "derived profile face is unresolved")
|
||||
face = resolved[0].record.value
|
||||
tool, topology_delta = _extruded_tool(node, [face], session.adapter.face_normal(face), session)
|
||||
return _apply_primary_tool(
|
||||
node, session, tool, cutting=node.params.get("operation") == "cut", topology_delta=topology_delta,
|
||||
)
|
||||
|
||||
|
||||
def _execute_revolve_surface(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
|
||||
@@ -667,22 +786,24 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: ExecutionSession) ->
|
||||
# booleanBodies 总是作用于 source feature 的明确 body 输出,不能回退为
|
||||
# 当前聚合 body。这样相邻独立实体不会意外成为工具或目标。
|
||||
params = node.params
|
||||
target_ids = [str(value) for value in params.get("target_feature_ids") or []]
|
||||
tool_ids = [str(value) for value in params.get("tool_feature_ids") or []]
|
||||
missing = [feature_id for feature_id in target_ids + tool_ids if feature_id not in session.body_members]
|
||||
if missing:
|
||||
raise ValueError("booleanBodies source bodies are unavailable: " + ", ".join(missing))
|
||||
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 = session.adapter.fuse(target, tool)
|
||||
result, topology_delta = session.adapter.fuse_with_topology_delta(target, tool)
|
||||
elif operation == "subtract":
|
||||
result = session.adapter.cut(target, tool)
|
||||
result, topology_delta = session.adapter.cut_with_topology_delta(target, tool)
|
||||
elif operation == "intersect":
|
||||
result = session.adapter.intersect(target, tool)
|
||||
result, topology_delta = session.adapter.intersect_with_topology_delta(target, tool)
|
||||
else:
|
||||
raise ValueError(f"unsupported booleanBodies operation {operation!r}")
|
||||
members = {
|
||||
@@ -693,7 +814,147 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: ExecutionSession) ->
|
||||
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)
|
||||
session.register_body(
|
||||
node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta,
|
||||
)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
def _pattern_instance_sources(
|
||||
node: FeaturePlanNode,
|
||||
session: ExecutionSession,
|
||||
parameter: str = "pattern_instance_refs",
|
||||
) -> list[str]:
|
||||
"""Resolve CDSL pattern-instance refs to their internal body-member keys."""
|
||||
resolved: list[str] = []
|
||||
for reference in node.params.get(parameter) or ():
|
||||
if not isinstance(reference, dict):
|
||||
raise ValueError("pattern instance reference must be an object")
|
||||
pattern_id = str(reference.get("pattern_feature_id") or "")
|
||||
source_id = str(reference.get("source_feature_id") or "")
|
||||
instance = reference.get("instance_index")
|
||||
if not pattern_id or not source_id or not isinstance(instance, int):
|
||||
raise ValueError("pattern instance reference is incomplete")
|
||||
pattern = session.nodes.get(pattern_id)
|
||||
if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}:
|
||||
raise ValueError(f"pattern instance owner is unavailable: {pattern_id}")
|
||||
params = pattern.params
|
||||
if source_id not in {str(value) for value in params.get("source_feature_ids") or ()}:
|
||||
raise ValueError("pattern instance source is not selected by its pattern")
|
||||
count = int(params.get("pattern_count") or 0)
|
||||
excluded = {int(value) for value in params.get("excluded_instance_indices") or ()}
|
||||
if (
|
||||
pattern.atomic_id == "pattern_mirror" and instance != 1
|
||||
) or (
|
||||
pattern.atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded)
|
||||
):
|
||||
raise ValueError("pattern instance is outside the pattern's surviving instances")
|
||||
member_id = pattern_instance_member_id(pattern_id, source_id, instance)
|
||||
if member_id not in session.body_members:
|
||||
raise ValueError(f"pattern instance body is unavailable: {pattern_id}/{source_id}/{instance}")
|
||||
if member_id not in resolved:
|
||||
resolved.append(member_id)
|
||||
return resolved
|
||||
|
||||
|
||||
def _transform_copy_sources(node: FeaturePlanNode, session: ExecutionSession) -> list[str]:
|
||||
"""Resolve source-qualified outputs of preceding multi-body COPY transforms."""
|
||||
resolved: list[str] = []
|
||||
for reference in node.params.get("transform_copy_refs") or ():
|
||||
if not isinstance(reference, dict):
|
||||
raise ValueError("transform COPY reference must be an object")
|
||||
transform_id = str(reference.get("transform_feature_id") or "")
|
||||
source_id = str(reference.get("source_feature_id") or "")
|
||||
if not transform_id or not source_id:
|
||||
raise ValueError("transform COPY reference is incomplete")
|
||||
transform = session.nodes.get(transform_id)
|
||||
params = transform.params if transform is not None else {}
|
||||
sources = params.get("source_feature_ids") or []
|
||||
if (
|
||||
transform is None
|
||||
or transform.atomic_id != "transform_bodies"
|
||||
or not bool(params.get("make_copy"))
|
||||
or not isinstance(sources, list)
|
||||
or len(sources) < 2
|
||||
or source_id not in {str(value) for value in sources}
|
||||
):
|
||||
raise ValueError(f"transform COPY owner/source is unavailable: {transform_id}/{source_id}")
|
||||
member_id = transform_copy_member_id(transform_id, source_id)
|
||||
if member_id not in session.body_members:
|
||||
raise ValueError(f"transform COPY body is unavailable: {transform_id}/{source_id}")
|
||||
if member_id not in resolved:
|
||||
resolved.append(member_id)
|
||||
return resolved
|
||||
|
||||
|
||||
def _member_sources(
|
||||
node: FeaturePlanNode,
|
||||
session: ExecutionSession,
|
||||
parameter: str,
|
||||
*,
|
||||
pattern_instance_parameter: str | None = None,
|
||||
allow_transform_copies: bool = False,
|
||||
) -> list[str]:
|
||||
source_ids = [str(value) for value in node.params.get(parameter) or []]
|
||||
if pattern_instance_parameter is not None:
|
||||
source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter))
|
||||
if allow_transform_copies:
|
||||
source_ids.extend(_transform_copy_sources(node, session))
|
||||
if not source_ids:
|
||||
raise ValueError(f"{node.atomic_id} requires explicit {parameter}")
|
||||
missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members]
|
||||
if missing:
|
||||
raise ValueError(f"{node.atomic_id} source bodies are unavailable: " + ", ".join(missing))
|
||||
return source_ids
|
||||
|
||||
|
||||
def _execute_transform_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
|
||||
# FeatureScript transform targets explicit bodies. Do not move the
|
||||
# aggregate session body, because it may include unrelated members.
|
||||
source_ids = _member_sources(
|
||||
node, session, "source_feature_ids", pattern_instance_parameter="pattern_instance_refs", allow_transform_copies=True,
|
||||
)
|
||||
make_copy = bool(node.params.get("make_copy"))
|
||||
direct_sources = node.params.get("source_feature_ids") or []
|
||||
if make_copy and isinstance(direct_sources, list) and len(direct_sources) > 1:
|
||||
# The aggregate is only an export compound. Each source transform has
|
||||
# its own B-rep builder and is the only output a later COPY query may
|
||||
# select. Do not attach an aggregate topology delta to source members.
|
||||
members = dict(session.body_members)
|
||||
members.update({
|
||||
transform_copy_member_id(node.feature_id, source_id): session.adapter.transform(
|
||||
session.body_members[source_id], dict(node.params.get("transform") or {}),
|
||||
)
|
||||
for source_id in source_ids
|
||||
})
|
||||
session.register_body(
|
||||
node.feature_id, _combine_members(session, members), body_members=members,
|
||||
)
|
||||
return session.result(node)
|
||||
source = _combine_members(session, {feature_id: session.body_members[feature_id] for feature_id in source_ids})
|
||||
transformed, topology_delta = session.adapter.transform_with_topology_delta(
|
||||
source, dict(node.params.get("transform") or {}),
|
||||
)
|
||||
members = dict(session.body_members)
|
||||
if not make_copy:
|
||||
for feature_id in source_ids:
|
||||
members.pop(feature_id)
|
||||
members[node.feature_id] = transformed
|
||||
session.register_body(
|
||||
node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta,
|
||||
)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
def _execute_delete_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
|
||||
# Deletion is a body-graph operation, never a Boolean subtraction. A
|
||||
# selected member can be disjoint or overlap another independent body.
|
||||
source_ids = _member_sources(node, session, "target_feature_ids")
|
||||
members = {feature_id: body for feature_id, body in session.body_members.items() if feature_id not in set(source_ids)}
|
||||
if members:
|
||||
session.register_body(node.feature_id, _combine_members(session, members), body_members=members)
|
||||
else:
|
||||
session.clear_body()
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@@ -707,8 +968,14 @@ def _execute_loft_add(node: FeaturePlanNode, session: ExecutionSession) -> Featu
|
||||
if sketch is None:
|
||||
raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved")
|
||||
profiles.append(sketch)
|
||||
solid = session.adapter.loft(profiles)
|
||||
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
|
||||
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)
|
||||
|
||||
|
||||
@@ -776,12 +1043,18 @@ def _execute_sweep_add(node: FeaturePlanNode, session: ExecutionSession, sketch:
|
||||
faces = session.adapter.faces_for_sketch(profile)
|
||||
if len(faces) != 1:
|
||||
raise ValueError("sweep requires exactly one closed profile region")
|
||||
solid = session.adapter.sweep(
|
||||
solid, topology_delta = session.adapter.sweep_with_topology_delta(
|
||||
faces[0], _sweep_path(node, session),
|
||||
is_frenet=bool(node.params.get("is_frenet", False)),
|
||||
)
|
||||
body = session.adapter.combine(session.body, solid) if node.params.get("result_mode") == "new_body" else session.adapter.fuse(session.body, solid)
|
||||
session.register_body(node.feature_id, body, replay_node=node)
|
||||
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)
|
||||
|
||||
|
||||
@@ -1077,19 +1350,33 @@ def _shell_target(node: FeaturePlanNode, session: ExecutionSession) -> tuple[Any
|
||||
target_id = next(iter(target_ids))
|
||||
members = session.adapter.body_solids(session.body)
|
||||
if len(members) == 1:
|
||||
return members[0], [record.value for record in records]
|
||||
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")
|
||||
return members[member_index], [record.value for record in records]
|
||||
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:
|
||||
@@ -1118,8 +1405,13 @@ def _execute_shell(node: FeaturePlanNode, session: ExecutionSession) -> FeatureR
|
||||
if thickness <= 0:
|
||||
raise ValueError("shell thickness_mm must be > 0")
|
||||
target, faces = _shell_target(node, session)
|
||||
result = session.adapter.shell(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)
|
||||
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)
|
||||
|
||||
|
||||
@@ -1134,11 +1426,11 @@ def _execute_fillet(node: FeaturePlanNode, session: ExecutionSession) -> Feature
|
||||
if radius <= 0:
|
||||
raise ValueError("fillet radius_mm must be > 0")
|
||||
# 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。
|
||||
body = session.adapter.fillet(
|
||||
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)
|
||||
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@@ -1164,8 +1456,9 @@ def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> Featur
|
||||
# 4. 解析目标边(支持相切传播),执行倒角。
|
||||
edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation")))
|
||||
diagnostics: list[RuntimeDiagnostic] = []
|
||||
topology_delta: TopologyDelta | None = None
|
||||
try:
|
||||
body = session.adapter.chamfer(session.body, distance, distance_2, edges)
|
||||
body, topology_delta = session.adapter.chamfer_with_topology_delta(session.body, distance, distance_2, edges)
|
||||
except ValueError as error:
|
||||
# 显式 surfaceEntities 可以在后续实体上留下曲面分区边界。若标准
|
||||
# OCC 倒角因环域宽度不足而拒绝,只允许在该 shell 给出同轴边界证据
|
||||
@@ -1185,7 +1478,7 @@ def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> Featur
|
||||
detail={"distance_mm": distance, "surface_count": len(session.surface_members)},
|
||||
))
|
||||
# 5. 登记新主体并返回结果。
|
||||
session.register_body(node.feature_id, body, replay_node=node)
|
||||
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
|
||||
return session.result(node, diagnostics=diagnostics)
|
||||
|
||||
|
||||
@@ -1533,6 +1826,31 @@ def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) ->
|
||||
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,否则会
|
||||
@@ -1738,6 +2056,108 @@ def _pattern_operation_node(node: FeaturePlanNode, operation_mode: str) -> Featu
|
||||
)
|
||||
|
||||
|
||||
def _circular_source_is_axisymmetric(node: FeaturePlanNode, session: ExecutionSession, axis: AxisSpec) -> bool:
|
||||
"""Whether rotating a direct circular extrusion creates no new geometry."""
|
||||
if node.atomic_id not in {"extrude_add_blind", "extrude_add_two_sided"}:
|
||||
return False
|
||||
sketch = session.sketches.get(str(node.sketch_id))
|
||||
if sketch is None:
|
||||
return False
|
||||
profile = sketch.get("profile") or {}
|
||||
circle = profile if profile.get("type") == "circle" else None
|
||||
if circle is None:
|
||||
contours = profile.get("contours") or []
|
||||
segments = (contours[0] or {}).get("segments") if len(contours) == 1 else []
|
||||
circle = segments[0] if isinstance(segments, list) and len(segments) == 1 and segments[0].get("type") == "circle" else None
|
||||
center = (circle or {}).get("center")
|
||||
if not isinstance(center, list) or len(center) != 2:
|
||||
return False
|
||||
try:
|
||||
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if abs(vector_dot(plane.normal, axis.direction)) < 1 - 1e-7:
|
||||
return False
|
||||
world_center = vector_add(
|
||||
plane.origin_mm,
|
||||
vector_add(vector_scale(plane.x_dir, float(center[0])), vector_scale(plane.y_dir, float(center[1]))),
|
||||
)
|
||||
offset = vector_subtract(world_center, axis.origin_mm)
|
||||
radial = vector_subtract(offset, vector_scale(axis.direction, vector_dot(offset, axis.direction)))
|
||||
return math.sqrt(vector_dot(radial, radial)) <= 1e-6
|
||||
|
||||
|
||||
def _advance_copy_topology_records(
|
||||
records: list[TopologyRecord], topology_delta: TopologyDelta | None,
|
||||
) -> list[TopologyRecord]:
|
||||
"""Carry COPY provenance through one exact adapter-history operation.
|
||||
|
||||
Pattern copies are separate CDSL results even when their solids fuse into
|
||||
a single final body. The temporary records here are never selector
|
||||
candidates themselves. They only retain instance ownership while opaque
|
||||
OCC history proves a unique subshape continuation to the final snapshot.
|
||||
"""
|
||||
if topology_delta is None:
|
||||
return []
|
||||
advanced: list[TopologyRecord] = []
|
||||
for record in records:
|
||||
values: list[Any] = []
|
||||
for relation in topology_delta.relations:
|
||||
if (
|
||||
relation.kind != record.kind
|
||||
or relation.event not in {"preserved", "modified"}
|
||||
or not TopologyRegistry._same_topology_value(record.value, relation.source_value)
|
||||
):
|
||||
continue
|
||||
for value in relation.result_values:
|
||||
if not any(TopologyRegistry._same_topology_value(value, known) for known in values):
|
||||
values.append(value)
|
||||
# A split/merge has no unique COPY owner in the present selector
|
||||
# contract. Keep the executable model, but do not make a claim that a
|
||||
# later COPY selector can bind one arbitrary descendant.
|
||||
if len(values) != 1:
|
||||
continue
|
||||
advanced.append(TopologyRecord(
|
||||
record_id=record.record_id,
|
||||
kind=record.kind,
|
||||
feature_id=record.feature_id,
|
||||
body_id=record.body_id,
|
||||
geometry=dict(record.geometry),
|
||||
value=values[0],
|
||||
owner_feature_ids=record.owners,
|
||||
output_roles=record.output_roles,
|
||||
output_role_sources=record.output_role_sources,
|
||||
))
|
||||
return advanced
|
||||
|
||||
|
||||
def _copy_snapshot_topology_delta(records: list[TopologyRecord]) -> TopologyDelta | None:
|
||||
"""Bridge traced final COPY handles into the one registered body snapshot."""
|
||||
if not records:
|
||||
return None
|
||||
return TopologyDelta(
|
||||
operation="pattern_circular_copy_snapshot",
|
||||
relations=tuple(
|
||||
# ``record.value`` has already passed through every transform/fuse
|
||||
# builder in this pattern and is an actual final-B-rep handle. The
|
||||
# identity relation merely connects that evidence to the fresh
|
||||
# adapter snapshot; it is not a geometric rebinding shortcut.
|
||||
TopologyDeltaRelation("preserved", record.kind, record.value, (record.value,))
|
||||
for record in records
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _has_usable_pattern_body(session: ExecutionSession, body: Any | None) -> bool:
|
||||
"""Reject a formally valid but empty OCC boolean result before publishing it."""
|
||||
if body is None or not session.adapter.body_solids(body):
|
||||
return False
|
||||
try:
|
||||
return abs(float(body.volume)) > 1e-12
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult:
|
||||
# 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征
|
||||
# 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。
|
||||
@@ -1759,6 +2179,51 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession,
|
||||
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
|
||||
@@ -1766,6 +2231,11 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession,
|
||||
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}")
|
||||
@@ -1793,7 +2263,23 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession,
|
||||
# selector binding 会只保留最后一个实例的 body id,漏掉其它 COPY 实例。
|
||||
if session.body is None:
|
||||
raise ValueError("circular pattern produced no body")
|
||||
session.register_body(node.feature_id, session.body, replay_node=node)
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -1931,6 +2417,7 @@ EXECUTORS: dict[str, ExecutorFunction] = {
|
||||
"extrude_cut_blind": _primary_executor,
|
||||
"extrude_cut_two_sided": _primary_executor,
|
||||
"extrude_cut_through": _primary_executor,
|
||||
"extrude_from_face": lambda node, session, _sketch: _execute_extrude_from_face(node, session),
|
||||
"loft_add": _loft_executor,
|
||||
"loft_add_with_cap_face": _loft_cap_face_executor,
|
||||
"sweep_add": _sweep_executor,
|
||||
@@ -1946,6 +2433,8 @@ EXECUTORS: dict[str, ExecutorFunction] = {
|
||||
"chamfer": _chamfer_executor,
|
||||
"shell": _shell_executor,
|
||||
"boolean_bodies": lambda node, session, sketch: _execute_boolean_bodies(node, session),
|
||||
"transform_bodies": lambda node, session, sketch: _execute_transform_bodies(node, session),
|
||||
"delete_bodies": lambda node, session, sketch: _execute_delete_bodies(node, session),
|
||||
"pattern_linear": _linear_pattern_executor,
|
||||
"pattern_mirror": _mirror_pattern_executor,
|
||||
"pattern_circular": _circular_pattern_executor,
|
||||
@@ -2040,5 +2529,6 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
|
||||
"feature_results": [result.as_dict() for result in session.results.values()],
|
||||
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics],
|
||||
"topology_records": [record.public_dict() for record in session.topology.records()],
|
||||
"topology_deltas": list(session.topology.topology_deltas()),
|
||||
"selector_resolution": session.selector_resolutions,
|
||||
}
|
||||
|
||||
@@ -15,6 +15,26 @@ from typing import Any, Iterable
|
||||
|
||||
Vector3 = tuple[float, float, float]
|
||||
|
||||
|
||||
def pattern_instance_member_id(pattern_feature_id: str, source_feature_id: str, instance_index: int) -> str:
|
||||
"""Return the runtime-only body-member key for one proven pattern copy.
|
||||
|
||||
CDSL keeps the three source fields separately, so callers never need to
|
||||
manufacture this internal key. The body graph uses the same derivation in
|
||||
runtime and capability preflight.
|
||||
"""
|
||||
return f"pattern:{pattern_feature_id}:{source_feature_id}:copy:{instance_index}"
|
||||
|
||||
|
||||
def transform_copy_member_id(transform_feature_id: str, source_member_id: str) -> str:
|
||||
"""Return the runtime-only key for one source of a multi-body COPY.
|
||||
|
||||
A multi-source ``transform_bodies`` COPY has several independently
|
||||
addressable outputs. CDSL records the transform and its selected source as
|
||||
separate fields; the opaque key stays internal to the body graph.
|
||||
"""
|
||||
return f"transform:{transform_feature_id}:{source_member_id}:copy"
|
||||
|
||||
# y_dir 与 x_dir / normal 点积的绝对值不超过该值时,认为 y_dir 是正交的,
|
||||
# 予以保留;否则视为偏斜数据,正交化并显式警告。
|
||||
_Y_DIR_ORTHOGONALITY_TOL = 1e-6
|
||||
@@ -558,6 +578,14 @@ class TopologyRecord:
|
||||
geometry: dict[str, Any] = field(default_factory=dict)
|
||||
value: Any = None
|
||||
owner_feature_ids: tuple[str, ...] = ()
|
||||
# Builder-produced roles describe a particular result subshape. They are
|
||||
# deliberately separate from the geometric signature: equal geometry does
|
||||
# not prove that two faces have the same feature-output meaning.
|
||||
output_roles: tuple[str, ...] = ()
|
||||
# Generated roles may carry the direct feature-output role that the kernel
|
||||
# operation transformed. This is semantic provenance, not a stable-id
|
||||
# shortcut: the resolver still requires the exact active result snapshot.
|
||||
output_role_sources: tuple[tuple[str, str, str], ...] = ()
|
||||
|
||||
@property
|
||||
def owners(self) -> tuple[str, ...]:
|
||||
@@ -575,9 +603,57 @@ class TopologyRecord:
|
||||
output["body_id"] = self.body_id
|
||||
if self.owner_feature_ids:
|
||||
output["owner_feature_ids"] = list(self.owner_feature_ids)
|
||||
if self.output_roles:
|
||||
output["output_roles"] = list(self.output_roles)
|
||||
if self.output_role_sources:
|
||||
output["output_role_sources"] = [
|
||||
{"output_role": role, "owner_feature_id": owner, "source_output_role": source_role}
|
||||
for role, owner, source_role in self.output_role_sources
|
||||
]
|
||||
return output
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TopologyDeltaRelation:
|
||||
"""One opaque kernel-history relationship for a topology subshape.
|
||||
|
||||
Geometry adapters retain ownership of the values in this structure. They
|
||||
are intentionally opaque to the runtime: a build123d/OCC adapter may use
|
||||
``TopoDS_Shape`` values while another adapter can use its native handles.
|
||||
The registry only asks whether a handle is exactly the same topology item;
|
||||
it never uses this contract to score nearby geometry.
|
||||
"""
|
||||
|
||||
event: str
|
||||
kind: str
|
||||
source_value: Any
|
||||
result_values: tuple[Any, ...] = ()
|
||||
output_role: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.event not in {"preserved", "modified", "generated", "deleted"}:
|
||||
raise ValueError(f"unsupported topology delta event {self.event!r}")
|
||||
if self.kind not in {"face", "edge", "vertex"}:
|
||||
raise ValueError(f"unsupported topology delta kind {self.kind!r}")
|
||||
if self.event == "deleted" and (self.result_values or self.output_role is not None):
|
||||
raise ValueError("deleted topology delta relations cannot have result values or an output role")
|
||||
if self.output_role is not None and (not isinstance(self.output_role, str) or not self.output_role):
|
||||
raise ValueError("topology delta output_role must be a non-empty string when provided")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TopologyDelta:
|
||||
"""Kernel-backed topology history for one adapter operation.
|
||||
|
||||
``operation`` is evidence only. The runtime transfers durable provenance
|
||||
solely from a unique exact relationship, never from an operation name or a
|
||||
geometric resemblance.
|
||||
"""
|
||||
|
||||
operation: str
|
||||
relations: tuple[TopologyDeltaRelation, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SelectorResolution:
|
||||
selector: dict[str, Any]
|
||||
@@ -613,6 +689,7 @@ class TopologyRegistry:
|
||||
self._records: list[TopologyRecord] = []
|
||||
self._by_feature: dict[str, list[TopologyRecord]] = {}
|
||||
self._active_body_id: str | None = None
|
||||
self._topology_deltas: list[dict[str, Any]] = []
|
||||
# #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer
|
||||
# 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何
|
||||
# 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的
|
||||
@@ -629,6 +706,10 @@ class TopologyRegistry:
|
||||
def records(self) -> tuple[TopologyRecord, ...]:
|
||||
return tuple(self._records)
|
||||
|
||||
def topology_deltas(self) -> tuple[dict[str, Any], ...]:
|
||||
"""Return serializable evidence derived from exact adapter history."""
|
||||
return tuple(self._topology_deltas)
|
||||
|
||||
def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord:
|
||||
kind = "plane" if isinstance(context, PlaneSpec) else "axis"
|
||||
record = TopologyRecord(
|
||||
@@ -643,13 +724,18 @@ class TopologyRegistry:
|
||||
|
||||
def replace_body_topology(
|
||||
self, feature_id: str, body_id: str, records: Iterable[TopologyRecord],
|
||||
*, active_body_id: str | None = None,
|
||||
*, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None,
|
||||
additional_predecessors: Iterable[TopologyRecord] = (),
|
||||
) -> None:
|
||||
self.replace_body_topologies(feature_id, [(body_id, records)], active_body_id=active_body_id)
|
||||
self.replace_body_topologies(
|
||||
feature_id, [(body_id, records)], active_body_id=active_body_id, topology_delta=topology_delta,
|
||||
additional_predecessors=additional_predecessors,
|
||||
)
|
||||
|
||||
def replace_body_topologies(
|
||||
self, feature_id: str, bodies: Iterable[tuple[str, Iterable[TopologyRecord]]],
|
||||
*, active_body_id: str | None = None,
|
||||
*, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None,
|
||||
additional_predecessors: Iterable[TopologyRecord] = (),
|
||||
) -> None:
|
||||
"""Record a fresh B-rep snapshot after a feature mutates the body.
|
||||
|
||||
@@ -665,7 +751,7 @@ class TopologyRegistry:
|
||||
mutation's predecessor lookup scoped to every solid of the previous
|
||||
body, while each member keeps its own ``body:{feature}:{index}`` id.
|
||||
"""
|
||||
previous = [
|
||||
active_previous = [
|
||||
record for record in self._records
|
||||
if self._active_body_id is not None and record.body_id is not None
|
||||
and (
|
||||
@@ -673,13 +759,32 @@ class TopologyRegistry:
|
||||
or record.body_id.startswith(f"{self._active_body_id}:")
|
||||
)
|
||||
]
|
||||
# A pattern COPY can carry a chain of exact transform/boolean builder
|
||||
# histories before its final aggregate snapshot is registered. These
|
||||
# temporary records are valid predecessors only for that documented
|
||||
# kernel-history bridge. They are deliberately excluded from geometric
|
||||
# fallback matching: an equal-looking final face never proves that it
|
||||
# belongs to one particular copy instance.
|
||||
transient_previous = list(additional_predecessors)
|
||||
previous = [*active_previous, *transient_previous]
|
||||
# 同一 source feature 的 pattern copy 可以产生完全相同的几何面。它们
|
||||
# 必须保留为多个实例,不能在跨 body 的全局 predecessor 匹配中互相消费。
|
||||
# pattern 的 Compound 成员顺序是稳定的:已有实例以同一 member index
|
||||
# 延续,新增实例只会出现在末尾。按该 index 限定后继匹配。
|
||||
current = [(body_id, list(records)) for body_id, records in bodies]
|
||||
current_records = [record for _body_id, records in current for record in records]
|
||||
(
|
||||
exact_predecessors,
|
||||
exact_successors,
|
||||
kernel_covered_predecessors,
|
||||
exact_output_roles,
|
||||
exact_output_role_sources,
|
||||
delta_evidence,
|
||||
) = self._exact_delta_links(
|
||||
topology_delta, previous, current_records,
|
||||
)
|
||||
previous_member_ids = {
|
||||
suffix for record in previous
|
||||
suffix for record in active_previous
|
||||
for suffix in [str(record.body_id).rsplit(":", 1)[-1]]
|
||||
if suffix.isdigit()
|
||||
}
|
||||
@@ -689,13 +794,36 @@ class TopologyRegistry:
|
||||
for body_id, records in current:
|
||||
member_id = str(body_id).rsplit(":", 1)[-1]
|
||||
local_predecessors = [
|
||||
record for record in previous
|
||||
record for record in active_previous
|
||||
if not use_member_indexes or str(record.body_id).rsplit(":", 1)[-1] == member_id
|
||||
]
|
||||
for record in records:
|
||||
predecessor = self._unique_equivalent_predecessor(record, local_predecessors, consumed_predecessors)
|
||||
exact_predecessor_id = exact_predecessors.get(record.record_id)
|
||||
# Member order is a useful isolation boundary for geometric
|
||||
# fallback matching, especially for coincident pattern
|
||||
# copies. It is not a provenance boundary when a kernel
|
||||
# builder explicitly relates one source subshape to one
|
||||
# result subshape: boolean/delete lifecycle can remove an
|
||||
# earlier member and shift a surviving source to another
|
||||
# member index. A unique OCC continuation remains exact
|
||||
# evidence across that index change.
|
||||
predecessor = next(
|
||||
(prior for prior in previous if prior.record_id == exact_predecessor_id),
|
||||
None,
|
||||
)
|
||||
if predecessor is None and record.record_id not in exact_predecessors:
|
||||
predecessor = self._unique_equivalent_predecessor(record, local_predecessors, consumed_predecessors)
|
||||
owners = predecessor.owners if predecessor is not None else (feature_id,)
|
||||
if predecessor is not None:
|
||||
output_roles = set(record.output_roles)
|
||||
output_roles.update(exact_output_roles.get(record.record_id, ()))
|
||||
output_role_sources = set(record.output_role_sources)
|
||||
output_role_sources.update(exact_output_role_sources.get(record.record_id, ()))
|
||||
# A feature-output role can survive a later operation only
|
||||
# through the same unique kernel continuation used for owner
|
||||
# provenance. Geometry equivalence alone never carries it.
|
||||
if predecessor is not None and record.record_id in exact_predecessors:
|
||||
output_roles.update(predecessor.output_roles)
|
||||
if predecessor is not None and record.record_id not in exact_predecessors:
|
||||
consumed_predecessors.add(predecessor.record_id)
|
||||
registered.append(TopologyRecord(
|
||||
record_id=record.record_id,
|
||||
@@ -705,16 +833,33 @@ class TopologyRegistry:
|
||||
geometry=dict(record.geometry),
|
||||
value=record.value,
|
||||
owner_feature_ids=owners,
|
||||
output_roles=tuple(sorted(output_roles)),
|
||||
output_role_sources=tuple(sorted(output_role_sources)),
|
||||
))
|
||||
for record in registered:
|
||||
self.register(record)
|
||||
for predecessor_id, successor_ids in exact_successors.items():
|
||||
known = self._successors.setdefault(predecessor_id, [])
|
||||
for successor_id in successor_ids:
|
||||
if successor_id not in known:
|
||||
known.append(successor_id)
|
||||
if delta_evidence is not None:
|
||||
self._topology_deltas.append({
|
||||
"feature_id": feature_id,
|
||||
"operation": topology_delta.operation,
|
||||
"relations": delta_evidence,
|
||||
})
|
||||
# #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续
|
||||
# selector 的 stable_id 引用解析到 active body 内的新形态。多条演化
|
||||
# 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段
|
||||
# 也有圆角过渡带的新边,前者的端点与原边重合、漂移更小);漂移并列
|
||||
# (如竖直边被完整消费成两条等距直段)属于本质歧义,保守不登记。
|
||||
for prior in previous:
|
||||
if prior.record_id in consumed_predecessors:
|
||||
if (
|
||||
prior.record_id in consumed_predecessors
|
||||
or prior.record_id in exact_successors
|
||||
or prior.record_id in kernel_covered_predecessors
|
||||
):
|
||||
continue
|
||||
candidates = sorted(
|
||||
(
|
||||
@@ -730,6 +875,126 @@ class TopologyRegistry:
|
||||
self._successors[prior.record_id] = [best[1]]
|
||||
self._active_body_id = active_body_id or body_id
|
||||
|
||||
@staticmethod
|
||||
def _same_topology_value(left: Any, right: Any) -> bool:
|
||||
"""Compare adapter handles only through their exact topology identity."""
|
||||
left_value = getattr(left, "wrapped", left)
|
||||
right_value = getattr(right, "wrapped", right)
|
||||
if left_value is right_value:
|
||||
return True
|
||||
for candidate, other in ((left_value, right_value), (right_value, left_value)):
|
||||
for method_name in ("IsSame", "is_same"):
|
||||
method = getattr(candidate, method_name, None)
|
||||
if callable(method):
|
||||
try:
|
||||
return bool(method(other))
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
continue
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _exact_delta_links(
|
||||
cls,
|
||||
topology_delta: TopologyDelta | None,
|
||||
previous: list[TopologyRecord],
|
||||
current: list[TopologyRecord],
|
||||
) -> tuple[
|
||||
dict[str, str],
|
||||
dict[str, list[str]],
|
||||
set[str],
|
||||
dict[str, tuple[str, ...]],
|
||||
dict[str, tuple[tuple[str, str, str], ...]],
|
||||
list[dict[str, Any]] | None,
|
||||
]:
|
||||
"""Bind opaque kernel history to snapshots without geometric guessing.
|
||||
|
||||
Ownership transfer is intentionally limited to a single source item and
|
||||
a single output item. Split/merge history remains useful evidence, but
|
||||
has no unique owner continuation until a later operation-specific
|
||||
contract can express it.
|
||||
"""
|
||||
if topology_delta is None:
|
||||
return {}, {}, set(), {}, {}, None
|
||||
candidate_sources: dict[str, set[str]] = {}
|
||||
kernel_covered_predecessors: set[str] = set()
|
||||
output_roles: dict[str, set[str]] = {}
|
||||
output_role_sources: dict[str, set[tuple[str, str, str]]] = {}
|
||||
relation_links: list[tuple[str, str] | None] = []
|
||||
evidence: list[dict[str, Any]] = []
|
||||
for relation in topology_delta.relations:
|
||||
sources = [
|
||||
record for record in previous
|
||||
if record.kind == relation.kind and cls._same_topology_value(record.value, relation.source_value)
|
||||
]
|
||||
outputs = [
|
||||
record for record in current
|
||||
if record.kind == relation.kind
|
||||
and any(cls._same_topology_value(record.value, value) for value in relation.result_values)
|
||||
]
|
||||
item = {
|
||||
"event": relation.event,
|
||||
"kind": relation.kind,
|
||||
"source_record_ids": [record.record_id for record in sources],
|
||||
"result_record_ids": [record.record_id for record in outputs],
|
||||
"proof": "kernel_history",
|
||||
}
|
||||
if relation.output_role is not None:
|
||||
item["output_role"] = relation.output_role
|
||||
role_is_unique = len(relation.result_values) == 1 and len(outputs) == 1
|
||||
item["output_role_status"] = (
|
||||
"unique_result_snapshot" if role_is_unique else "non_unique_or_missing_result_snapshot"
|
||||
)
|
||||
if role_is_unique:
|
||||
output_roles.setdefault(outputs[0].record_id, set()).add(relation.output_role)
|
||||
for source in sources:
|
||||
for source_role in source.output_roles:
|
||||
for source_owner in source.owners:
|
||||
output_role_sources.setdefault(outputs[0].record_id, set()).add(
|
||||
(relation.output_role, source_owner, source_role)
|
||||
)
|
||||
if len(sources) == 1:
|
||||
kernel_covered_predecessors.add(sources[0].record_id)
|
||||
can_transfer = (
|
||||
relation.event in {"preserved", "modified"}
|
||||
and len(relation.result_values) == 1
|
||||
and len(sources) == 1
|
||||
and len(outputs) == 1
|
||||
)
|
||||
if can_transfer:
|
||||
source_id, result_id = sources[0].record_id, outputs[0].record_id
|
||||
candidate_sources.setdefault(result_id, set()).add(source_id)
|
||||
relation_links.append((source_id, result_id))
|
||||
else:
|
||||
relation_links.append(None)
|
||||
evidence.append(item)
|
||||
predecessors = {
|
||||
result_id: next(iter(source_ids))
|
||||
for result_id, source_ids in candidate_sources.items()
|
||||
if len(source_ids) == 1
|
||||
}
|
||||
successors: dict[str, list[str]] = {}
|
||||
for result_id, source_id in predecessors.items():
|
||||
successors.setdefault(source_id, []).append(result_id)
|
||||
for item, relation, link in zip(evidence, topology_delta.relations, relation_links):
|
||||
if link is not None:
|
||||
_source_id, result_id = link
|
||||
item["status"] = (
|
||||
"unique_exact_continuation"
|
||||
if len(candidate_sources[result_id]) == 1 else "ambiguous_exact_continuation"
|
||||
)
|
||||
elif relation.event in {"preserved", "modified"}:
|
||||
item["status"] = "non_unique_or_incomplete"
|
||||
else:
|
||||
item["status"] = "recorded_without_owner_transfer"
|
||||
return (
|
||||
predecessors,
|
||||
successors,
|
||||
kernel_covered_predecessors,
|
||||
{record_id: tuple(sorted(roles)) for record_id, roles in output_roles.items()},
|
||||
{record_id: tuple(sorted(sources)) for record_id, sources in output_role_sources.items()},
|
||||
evidence,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool:
|
||||
try:
|
||||
@@ -871,7 +1136,7 @@ class TopologyRegistry:
|
||||
if not selector_geometry:
|
||||
return 0.0
|
||||
scores: list[float] = []
|
||||
for key in ("center_mm", "normal", "origin_mm", "direction", "plane_normal", "start_mm", "end_mm"):
|
||||
for key in ("center_mm", "circle_center_mm", "normal", "origin_mm", "direction", "plane_normal", "start_mm", "end_mm"):
|
||||
if key in selector_geometry:
|
||||
score = cls._vector_score(selector_geometry[key], record_geometry.get(key))
|
||||
if score is None:
|
||||
@@ -901,6 +1166,12 @@ class TopologyRegistry:
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
scores.append(max(0.0, 1.0 - delta / 1e-4))
|
||||
if "radius_mm" in selector_geometry:
|
||||
try:
|
||||
delta = abs(float(selector_geometry["radius_mm"]) - float(record_geometry.get("radius_mm")))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
scores.append(max(0.0, 1.0 - delta / 1e-4))
|
||||
if "area_mm2" in selector_geometry:
|
||||
try:
|
||||
expected_area = float(selector_geometry["area_mm2"])
|
||||
@@ -977,6 +1248,101 @@ class TopologyRegistry:
|
||||
detail={"minimum_score": minimum_score},
|
||||
),
|
||||
)
|
||||
output_role = str(selector.get("output_role") or "").strip()
|
||||
if output_role:
|
||||
if not owner:
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="not_found",
|
||||
candidates=(),
|
||||
diagnostic=RuntimeDiagnostic(
|
||||
code="selector_output_role_owner_required",
|
||||
message="A feature output role selector requires owner_feature_id",
|
||||
detail={"output_role": output_role},
|
||||
),
|
||||
)
|
||||
if active_body_id is None:
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="not_found",
|
||||
candidates=(),
|
||||
diagnostic=RuntimeDiagnostic(
|
||||
code="selector_output_role_active_body_required",
|
||||
message="A feature output role selector requires an active body snapshot",
|
||||
detail={"output_role": output_role},
|
||||
),
|
||||
)
|
||||
if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")):
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="not_found",
|
||||
candidates=(),
|
||||
diagnostic=RuntimeDiagnostic(
|
||||
code="selector_output_role_mixed_evidence",
|
||||
message="A feature output role selector cannot mix stable or geometry evidence",
|
||||
detail={"output_role": output_role},
|
||||
),
|
||||
)
|
||||
role_candidates = [record for record in candidates if output_role in record.output_roles]
|
||||
role_source = selector.get("output_role_source")
|
||||
if role_source is not None:
|
||||
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
|
||||
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
|
||||
if not isinstance(source_owner, str) or not isinstance(source_role, str):
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="not_found",
|
||||
candidates=(),
|
||||
diagnostic=RuntimeDiagnostic(
|
||||
code="selector_output_role_source_invalid",
|
||||
message="An output role selector source requires owner_feature_id and output_role",
|
||||
),
|
||||
)
|
||||
if output_role != "shell.offset_face" or source_role not in {"extrude.start", "extrude.end"}:
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="not_found",
|
||||
candidates=(),
|
||||
diagnostic=RuntimeDiagnostic(
|
||||
code="selector_output_role_source_unsupported",
|
||||
message="Output role sources are currently supported only for shell.offset_face from an extrusion cap",
|
||||
),
|
||||
)
|
||||
role_candidates = [
|
||||
record for record in role_candidates
|
||||
if (output_role, source_owner, source_role) in record.output_role_sources
|
||||
]
|
||||
public_candidates = tuple(
|
||||
{"score": 1.0, **record.public_dict()} for record in role_candidates
|
||||
)
|
||||
if len(role_candidates) == 1:
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="resolved",
|
||||
record=role_candidates[0],
|
||||
candidates=public_candidates,
|
||||
)
|
||||
if len(role_candidates) > 1:
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="ambiguous",
|
||||
candidates=public_candidates,
|
||||
diagnostic=RuntimeDiagnostic(
|
||||
code="selector_output_role_ambiguous",
|
||||
message="More than one active topology record has the requested output role",
|
||||
detail={"output_role": output_role, "candidate_count": len(role_candidates)},
|
||||
),
|
||||
)
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="not_found",
|
||||
candidates=(),
|
||||
diagnostic=RuntimeDiagnostic(
|
||||
code="selector_output_role_not_found",
|
||||
message="No active topology record has the requested output role",
|
||||
detail={"output_role": output_role, "candidate_count": 0},
|
||||
),
|
||||
)
|
||||
stable_id = str(selector.get("stable_id") or "").strip()
|
||||
if stable_id:
|
||||
# #8 selector 持久性:stable_id 是跨 body 演化的持久标识符,精确
|
||||
@@ -1041,6 +1407,17 @@ class TopologyRegistry:
|
||||
record=record,
|
||||
candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},),
|
||||
)
|
||||
if not geometry:
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="not_found",
|
||||
candidates=(),
|
||||
diagnostic=RuntimeDiagnostic(
|
||||
code="selector_stable_id_inactive",
|
||||
message="The stable selector record is not active and has no geometry signature for rebinding",
|
||||
detail={"stable_id": stable_id},
|
||||
),
|
||||
)
|
||||
if len(exact) > 1:
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
|
||||
@@ -15,10 +15,23 @@ from typing import Any
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from .operation_contracts import materialized_feature_contracts
|
||||
|
||||
|
||||
_ID = re.compile(r"^[A-Za-z0-9_-]{1,80}$")
|
||||
|
||||
|
||||
def _mappings(value: Any):
|
||||
"""Yield nested mapping values without treating selector-like data as text."""
|
||||
if isinstance(value, dict):
|
||||
yield value
|
||||
for child in value.values():
|
||||
yield from _mappings(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
yield from _mappings(child)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _schema() -> dict[str, Any]:
|
||||
path = Path(__file__).with_name("cdsl_schema.json")
|
||||
@@ -32,6 +45,12 @@ def _validator() -> Draft202012Validator:
|
||||
return Draft202012Validator(_schema())
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _operation_contracts() -> dict[str, dict[str, Any]]:
|
||||
path = Path(__file__).with_name("profile_schema.json")
|
||||
return materialized_feature_contracts(json.loads(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def _schema_error(document: dict[str, Any]) -> str | None:
|
||||
validator = _validator()
|
||||
errors = sorted(validator.iter_errors(document), key=lambda error: (list(error.absolute_path), error.message))
|
||||
@@ -67,10 +86,29 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
|
||||
sketch_ids = {str(sketch.get("id") or "") for sketch in sketches}
|
||||
if len(sketch_ids) != len(sketches) or not all(_ID.fullmatch(item) for item in sketch_ids):
|
||||
raise ValueError("Sketch ids must be unique valid CDSL identifiers")
|
||||
for sketch in sketches:
|
||||
profile = sketch.get("profile") or {}
|
||||
if profile.get("type") != "planar_imprint":
|
||||
continue
|
||||
entities = profile.get("source_entities") or []
|
||||
entity_ids = [str(entity.get("id") or "") for entity in entities if isinstance(entity, dict)]
|
||||
if len(entity_ids) != len(entities) or len(set(entity_ids)) != len(entity_ids) or not all(entity_ids):
|
||||
raise ValueError(f"Sketch {sketch.get('id')} planar_imprint source entity ids must be unique")
|
||||
known = set(entity_ids)
|
||||
for index, selection in enumerate(profile.get("selections") or []):
|
||||
source = str(selection.get("source_entity_id") or "")
|
||||
fragment = selection.get("fragment") or {}
|
||||
anchor = str(fragment.get("anchor_entity_id") or "") if fragment else ""
|
||||
if source not in known:
|
||||
raise ValueError(f"Sketch {sketch.get('id')} planar_imprint selection {index} has an unknown source entity")
|
||||
if fragment and (anchor not in known or anchor == source):
|
||||
raise ValueError(f"Sketch {sketch.get('id')} planar_imprint selection {index} has an invalid fragment anchor")
|
||||
|
||||
feature_ids: set[str] = set()
|
||||
preceding_features: dict[str, dict[str, Any]] = {}
|
||||
deferred: list[str] = []
|
||||
unresolved: list[dict[str, Any]] = []
|
||||
contracts = _operation_contracts()
|
||||
for feature in cdsl.get("features") or []:
|
||||
fid = str(feature.get("id") or "")
|
||||
if not _ID.fullmatch(fid) or fid in feature_ids:
|
||||
@@ -85,14 +123,166 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
|
||||
raise ValueError(f"Feature {fid} must declare execution_status")
|
||||
if feature.get("execution_status") == "deferred":
|
||||
deferred.append(fid)
|
||||
for index, selector in enumerate(feature.get("selectors") or []):
|
||||
contract = contracts.get(str(feature.get("atomic_id") or "")) or {}
|
||||
feature_selectors = feature.get("selectors") or []
|
||||
output_role_selector_ids = {id(selector) for selector in feature_selectors if isinstance(selector, dict)}
|
||||
for index, selector in enumerate(feature_selectors):
|
||||
owner = selector.get("owner_feature_id")
|
||||
binding_owner = selector.get("binding_feature_id")
|
||||
if owner is not None and owner not in feature_ids and binding_owner not in feature_ids:
|
||||
raise ValueError(f"Feature {fid} selector {index} has a forward or missing owner_feature_id")
|
||||
if selector.get("output_role") is not None:
|
||||
if contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face":
|
||||
raise ValueError(f"Feature {fid} selector {index} cannot consume a feature output role")
|
||||
if selector.get("kind") != "face" or not owner or owner not in feature_ids:
|
||||
raise ValueError(f"Feature {fid} selector {index} output role requires a preceding face owner_feature_id")
|
||||
if selector.get("source") != "runtime_snapshot":
|
||||
raise ValueError(f"Feature {fid} selector {index} output role must use runtime_snapshot evidence")
|
||||
if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")):
|
||||
raise ValueError(f"Feature {fid} selector {index} output role cannot mix stable or geometry evidence")
|
||||
role_source = selector.get("output_role_source")
|
||||
if role_source is not None:
|
||||
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
|
||||
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
|
||||
source_feature = preceding_features.get(str(source_owner or ""))
|
||||
source_params = (source_feature or {}).get("params") or {}
|
||||
if not source_owner or source_owner not in feature_ids:
|
||||
raise ValueError(f"Feature {fid} selector {index} output role source requires a preceding owner_feature_id")
|
||||
if selector.get("output_role") != "shell.offset_face" or feature.get("atomic_id") != "shell":
|
||||
raise ValueError(
|
||||
f"Feature {fid} selector {index} output role source is only supported for shell.offset_face"
|
||||
)
|
||||
if source_role not in {"extrude.start", "extrude.end"}:
|
||||
raise ValueError(
|
||||
f"Feature {fid} selector {index} shell.offset_face source must be an extrude cap role"
|
||||
)
|
||||
if (
|
||||
source_feature is None
|
||||
or source_feature.get("atomic_id") != "extrude_add_blind"
|
||||
or source_params.get("result_mode") != "new_body"
|
||||
or (source_params.get("end_condition") or {}).get("type") != "blind"
|
||||
):
|
||||
raise ValueError(
|
||||
f"Feature {fid} selector {index} shell.offset_face source requires a preceding direct new_body blind extrusion"
|
||||
)
|
||||
elif selector.get("output_role_source") is not None:
|
||||
raise ValueError(f"Feature {fid} selector {index} output role source requires output_role")
|
||||
for selector in _mappings(feature):
|
||||
# ``output_role_source`` is provenance metadata nested inside a
|
||||
# feature selector, not a selector on its own.
|
||||
if (
|
||||
selector.get("output_role") is not None
|
||||
and selector.get("kind") is not None
|
||||
and id(selector) not in output_role_selector_ids
|
||||
):
|
||||
raise ValueError(f"Feature {fid} output role selectors are only supported in feature.selectors")
|
||||
if feature.get("atomic_id") == "shell":
|
||||
target_feature_id = (feature.get("params") or {}).get("target_feature_id")
|
||||
if target_feature_id is not None and target_feature_id not in feature_ids:
|
||||
raise ValueError(f"Feature {fid} shell target_feature_id requires a preceding body feature")
|
||||
if feature.get("atomic_id") == "transform_bodies":
|
||||
params = feature.get("params") or {}
|
||||
references = params.get("pattern_instance_refs") or []
|
||||
for index, reference in enumerate(references):
|
||||
if not isinstance(reference, dict):
|
||||
raise ValueError(f"Feature {fid} pattern instance reference {index} is invalid")
|
||||
pattern_id = str(reference.get("pattern_feature_id") or "")
|
||||
source_id = str(reference.get("source_feature_id") or "")
|
||||
pattern = preceding_features.get(pattern_id)
|
||||
pattern_atomic_id = pattern.get("atomic_id") if pattern is not None else None
|
||||
if pattern is None or pattern_atomic_id not in {"pattern_circular", "pattern_mirror"}:
|
||||
raise ValueError(f"Feature {fid} pattern instance reference {index} has a forward or unsupported pattern owner")
|
||||
if source_id not in {str(value) for value in (pattern.get("params") or {}).get("source_feature_ids") or ()}:
|
||||
raise ValueError(f"Feature {fid} pattern instance reference {index} names a source outside its pattern")
|
||||
source = preceding_features.get(source_id)
|
||||
if (
|
||||
pattern_atomic_id == "pattern_mirror"
|
||||
and (source is None or (source.get("params") or {}).get("result_mode") != "new_body")
|
||||
):
|
||||
raise ValueError(
|
||||
f"Feature {fid} pattern instance reference {index} requires a preceding new_body mirror source"
|
||||
)
|
||||
instance = reference.get("instance_index")
|
||||
count = int((pattern.get("params") or {}).get("pattern_count") or 0)
|
||||
excluded = {int(value) for value in (pattern.get("params") or {}).get("excluded_instance_indices") or ()}
|
||||
if not isinstance(instance, int) or (
|
||||
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(f"Feature {fid} pattern instance reference {index} is not a surviving copy")
|
||||
copy_references = params.get("transform_copy_refs") or []
|
||||
for index, reference in enumerate(copy_references):
|
||||
if not isinstance(reference, dict):
|
||||
raise ValueError(f"Feature {fid} transform COPY reference {index} is invalid")
|
||||
transform_id = str(reference.get("transform_feature_id") or "")
|
||||
source_id = str(reference.get("source_feature_id") or "")
|
||||
transform = preceding_features.get(transform_id)
|
||||
transform_params = (transform or {}).get("params") or {}
|
||||
direct_sources = transform_params.get("source_feature_ids") or []
|
||||
if (
|
||||
transform is None
|
||||
or transform.get("atomic_id") != "transform_bodies"
|
||||
or not bool(transform_params.get("make_copy"))
|
||||
or not isinstance(direct_sources, list)
|
||||
or len(direct_sources) < 2
|
||||
):
|
||||
raise ValueError(
|
||||
f"Feature {fid} transform COPY reference {index} requires a preceding multi-source make_copy transform"
|
||||
)
|
||||
if source_id not in {str(value) for value in direct_sources}:
|
||||
raise ValueError(
|
||||
f"Feature {fid} transform COPY reference {index} names a source outside its transform"
|
||||
)
|
||||
for source_id in params.get("source_feature_ids") or []:
|
||||
source = preceding_features.get(str(source_id))
|
||||
source_params = (source or {}).get("params") or {}
|
||||
source_direct_ids = source_params.get("source_feature_ids") or []
|
||||
if (
|
||||
source is not None
|
||||
and source.get("atomic_id") == "transform_bodies"
|
||||
and bool(source_params.get("make_copy"))
|
||||
and isinstance(source_direct_ids, list)
|
||||
and len(source_direct_ids) > 1
|
||||
):
|
||||
raise ValueError(
|
||||
f"Feature {fid} must use transform_copy_refs to select a source of multi-source COPY {source_id}"
|
||||
)
|
||||
if feature.get("atomic_id") == "boolean_bodies":
|
||||
params = feature.get("params") or {}
|
||||
for parameter in ("target_pattern_instance_refs", "tool_pattern_instance_refs"):
|
||||
for index, reference in enumerate(params.get(parameter) or []):
|
||||
if not isinstance(reference, dict):
|
||||
raise ValueError(f"Feature {fid} {parameter} entry {index} is invalid")
|
||||
pattern_id = str(reference.get("pattern_feature_id") or "")
|
||||
source_id = str(reference.get("source_feature_id") or "")
|
||||
pattern = preceding_features.get(pattern_id)
|
||||
pattern_atomic_id = pattern.get("atomic_id") if pattern is not None else None
|
||||
if pattern is None or pattern_atomic_id not in {"pattern_circular", "pattern_mirror"}:
|
||||
raise ValueError(f"Feature {fid} {parameter} entry {index} has a forward or unsupported pattern owner")
|
||||
if source_id not in {str(value) for value in (pattern.get("params") or {}).get("source_feature_ids") or ()}:
|
||||
raise ValueError(f"Feature {fid} {parameter} entry {index} names a source outside its pattern")
|
||||
source = preceding_features.get(source_id)
|
||||
if (
|
||||
pattern_atomic_id == "pattern_mirror"
|
||||
and (source is None or (source.get("params") or {}).get("result_mode") != "new_body")
|
||||
):
|
||||
raise ValueError(
|
||||
f"Feature {fid} {parameter} entry {index} requires a preceding new_body mirror source"
|
||||
)
|
||||
instance = reference.get("instance_index")
|
||||
count = int((pattern.get("params") or {}).get("pattern_count") or 0)
|
||||
excluded = {int(value) for value in (pattern.get("params") or {}).get("excluded_instance_indices") or []}
|
||||
if not isinstance(instance, int) or (
|
||||
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(f"Feature {fid} {parameter} entry {index} is not a surviving copy")
|
||||
if feature.get("unresolved"):
|
||||
unresolved.append({"feature_id": fid, "reasons": list(feature["unresolved"])})
|
||||
feature_ids.add(fid)
|
||||
preceding_features[fid] = feature
|
||||
|
||||
return {
|
||||
"schema_version": version,
|
||||
|
||||
@@ -82,6 +82,12 @@ def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]:
|
||||
normal = workplane.get("normal") or [0, 0, 1]
|
||||
for edge in contours:
|
||||
output = deepcopy(edge)
|
||||
if edge["type"] == "circle":
|
||||
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
|
||||
output["x_dir_mm"] = _to_3d_vector(workplane, 1.0, 0.0)
|
||||
output["normal"] = list(normal)
|
||||
transformed.append(output)
|
||||
continue
|
||||
output["start_mm"] = _to_3d(workplane, edge["start_mm"][0], edge["start_mm"][1])
|
||||
output["end_mm"] = _to_3d(workplane, edge["end_mm"][0], edge["end_mm"][1])
|
||||
if edge["type"] == "arc":
|
||||
@@ -245,10 +251,19 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
return _ellipse_edges(segment)
|
||||
if kind == "bspline":
|
||||
points = segment.get("points") or []
|
||||
if len(points) < 3:
|
||||
raise ValueError("analytic_contours: bspline needs at least 3 interpolation points")
|
||||
if len(points) < 2:
|
||||
raise ValueError("analytic_contours: bspline needs at least 2 interpolation points")
|
||||
converted = [_point(point) for point in points]
|
||||
periodic = bool(segment.get("periodic"))
|
||||
start_tangent = segment.get("start_tangent")
|
||||
end_tangent = segment.get("end_tangent")
|
||||
if len(converted) == 2:
|
||||
if periodic:
|
||||
raise ValueError("analytic_contours: two-point bspline cannot be periodic")
|
||||
if _distance(converted[0], converted[1]) <= _TOLERANCE_MM:
|
||||
raise ValueError("analytic_contours: two-point bspline endpoints must be distinct")
|
||||
if start_tangent is None or end_tangent is None:
|
||||
raise ValueError("analytic_contours: two-point bspline requires both endpoint tangents")
|
||||
if periodic:
|
||||
if _distance(converted[0], converted[-1]) > _TOLERANCE_MM:
|
||||
raise ValueError("analytic_contours: periodic bspline endpoints do not meet")
|
||||
@@ -271,6 +286,8 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
raise ValueError("analytic_contours: bspline parameters must be finite")
|
||||
if any(right - left <= _TOLERANCE_MM for left, right in zip(parameters, parameters[1:])):
|
||||
raise ValueError("analytic_contours: bspline parameters must be strictly increasing")
|
||||
if len(converted) == 2 and parameters is None:
|
||||
raise ValueError("analytic_contours: two-point bspline requires explicit parameters")
|
||||
output: _Ctx = {
|
||||
"type": "bspline",
|
||||
"start_mm": converted[0],
|
||||
@@ -280,8 +297,6 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
**({"parameters": parameters} if parameters is not None else {}),
|
||||
**({"parameters": _centripetal_parameters(interpolation_points, periodic)} if parameters is None and parameterization == "centripetal" else {}),
|
||||
}
|
||||
start_tangent = segment.get("start_tangent")
|
||||
end_tangent = segment.get("end_tangent")
|
||||
if (start_tangent is None) != (end_tangent is None):
|
||||
raise ValueError("analytic_contours: bspline requires both endpoint tangents")
|
||||
if start_tangent is not None:
|
||||
@@ -293,13 +308,68 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
|
||||
|
||||
|
||||
def _imprint_segment_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
"""Convert an IMPRINT source while retaining its FeatureScript edge identity.
|
||||
|
||||
Closed contour assembly intentionally divides circles into four arcs so
|
||||
that its loops have explicit vertices. An IMPRINT source id, however,
|
||||
denotes one logical FeatureScript edge. Splitting that circle before the
|
||||
OCC arrangement loses the one-to-one source/history mapping and makes a
|
||||
valid fragment appear to be an ambiguous multi-edge source.
|
||||
"""
|
||||
if segment.get("type") != "circle":
|
||||
return _segment_edges(segment)
|
||||
center = segment.get("center") or [0.0, 0.0]
|
||||
radius = float(segment.get("radius_mm") or 0.0)
|
||||
if radius <= 0:
|
||||
raise ValueError("planar_imprint: circle radius_mm must be > 0")
|
||||
output: _Ctx = {
|
||||
"type": "circle",
|
||||
"center_mm": [float(center[0]), float(center[1]), 0.0],
|
||||
"radius_mm": radius,
|
||||
}
|
||||
if "clockwise" in segment:
|
||||
output["clockwise"] = bool(segment["clockwise"])
|
||||
return [output]
|
||||
|
||||
|
||||
def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]:
|
||||
points: list[tuple[float, float]] = []
|
||||
for edge in edges:
|
||||
start = edge["start_mm"]
|
||||
points.append((float(start[0]), float(start[1])))
|
||||
if edge.get("type") == "bspline":
|
||||
points.extend((float(point[0]), float(point[1])) for point in edge["points_mm"][1:-1])
|
||||
spline_points = edge["points_mm"]
|
||||
if len(spline_points) == 2:
|
||||
# GeomAPI_Interpolate with exactly two endpoint derivatives is
|
||||
# the cubic Hermite curve over the explicit parameter span.
|
||||
# There are no intermediate interpolation points to sample, so
|
||||
# use its analytical points rather than incorrectly treating
|
||||
# an otherwise valid curved loop as zero-area.
|
||||
parameters = edge.get("parameters") or []
|
||||
start_tangent = edge.get("start_tangent_mm")
|
||||
end_tangent = edge.get("end_tangent_mm")
|
||||
if len(parameters) != 2 or start_tangent is None or end_tangent is None:
|
||||
raise ValueError("analytic_contours: two-point bspline sampling is unresolved")
|
||||
parameter_span = float(parameters[1]) - float(parameters[0])
|
||||
if parameter_span <= _TOLERANCE_MM:
|
||||
raise ValueError("analytic_contours: two-point bspline parameter span is degenerate")
|
||||
start_point, end_point = spline_points
|
||||
for fraction in (0.25, 0.5, 0.75):
|
||||
squared = fraction * fraction
|
||||
cubed = squared * fraction
|
||||
h00 = 2.0 * cubed - 3.0 * squared + 1.0
|
||||
h10 = cubed - 2.0 * squared + fraction
|
||||
h01 = -2.0 * cubed + 3.0 * squared
|
||||
h11 = cubed - squared
|
||||
points.append((
|
||||
h00 * float(start_point[0]) + h10 * parameter_span * float(start_tangent[0])
|
||||
+ h01 * float(end_point[0]) + h11 * parameter_span * float(end_tangent[0]),
|
||||
h00 * float(start_point[1]) + h10 * parameter_span * float(start_tangent[1])
|
||||
+ h01 * float(end_point[1]) + h11 * parameter_span * float(end_tangent[1]),
|
||||
))
|
||||
continue
|
||||
points.extend((float(point[0]), float(point[1])) for point in spline_points[1:-1])
|
||||
continue
|
||||
if edge.get("type") == "ellipse":
|
||||
center, axis = edge["center_mm"], edge["major_axis_mm"]
|
||||
@@ -439,7 +509,38 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
|
||||
return entities, []
|
||||
|
||||
|
||||
CORE_SHAPE_GENERATORS: dict[str, Any] = {"circle": _gen_circle, "polygon": _gen_polygon, "analytic_contours": _gen_analytic_contours}
|
||||
def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
|
||||
"""Prepare source curves for an exact OCC planar-arrangement split.
|
||||
|
||||
Unlike ``analytic_contours``, these curves are intentionally not joined
|
||||
into a guessed outer wire. FeatureScript's IMPRINT query identifies
|
||||
regions in the arrangement of all source curves, including open curves
|
||||
and split fragments, and the geometry adapter chooses those actual B-rep
|
||||
regions after the split.
|
||||
"""
|
||||
source_entities: list[_Ctx] = []
|
||||
for source in profile.get("source_entities") or []:
|
||||
source_id = str(source.get("id") or "")
|
||||
curve = source.get("curve") or {}
|
||||
if not source_id:
|
||||
raise ValueError("planar_imprint: source entity id is missing")
|
||||
edges = _imprint_segment_edges(curve)
|
||||
if not edges:
|
||||
raise ValueError(f"planar_imprint: source entity {source_id!r} has no curve")
|
||||
source_entities.append({"id": source_id, "edges": edges})
|
||||
if len(source_entities) < 2:
|
||||
raise ValueError("planar_imprint: at least two source entities are required")
|
||||
meta["_imprint_entities"] = source_entities
|
||||
meta["_imprint_selections"] = deepcopy(profile.get("selections") or [])
|
||||
return [], []
|
||||
|
||||
|
||||
CORE_SHAPE_GENERATORS: dict[str, Any] = {
|
||||
"circle": _gen_circle,
|
||||
"polygon": _gen_polygon,
|
||||
"analytic_contours": _gen_analytic_contours,
|
||||
"planar_imprint": _gen_planar_imprint,
|
||||
}
|
||||
SHAPE_GENERATORS = CORE_SHAPE_GENERATORS
|
||||
SHAPE_CAPABILITIES: dict[str, _Ctx] = {
|
||||
"circle": {"detectable": True, "arity": "circle", "description": "single circular contour"},
|
||||
@@ -463,7 +564,10 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
|
||||
generator = SHAPE_GENERATORS.get(profile.get("type"))
|
||||
if generator is None:
|
||||
raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {profile.get('type')!r}")
|
||||
meta: _Ctx = {"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": [], "_has_open_contour": False}
|
||||
meta: _Ctx = {
|
||||
"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": [], "_has_open_contour": False,
|
||||
"_imprint_entities": [], "_imprint_selections": [],
|
||||
}
|
||||
entities, contour = generator(profile, meta)
|
||||
output = deepcopy(sketch)
|
||||
original_circles = [entity for entity in sketch.get("entities") or [] if entity.get("type") == "circle" and not entity.get("construction")]
|
||||
@@ -480,6 +584,15 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
|
||||
]
|
||||
if meta["_has_open_contour"]:
|
||||
output["_open_contour"] = True
|
||||
if meta["_imprint_entities"]:
|
||||
output["imprint_entities_mm"] = [
|
||||
{
|
||||
"id": entity["id"],
|
||||
"edges": _transform_contours(entity["edges"], workplane) if workplane else entity["edges"],
|
||||
}
|
||||
for entity in meta["_imprint_entities"]
|
||||
]
|
||||
output["imprint_selections"] = meta["_imprint_selections"]
|
||||
return output
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user