解决合并冲突

This commit is contained in:
2026-09-08 13:58:58 +08:00
28 changed files with 5529 additions and 336 deletions
+587 -67
View File
@@ -5,7 +5,21 @@ from __future__ import annotations
import math
from typing import Any, Iterable
from build123d import Axis, Compound, Edge, Face, Location, Plane, ShapeList, Solid, Vector, Wire, export_step
from build123d import Axis, Compound, Edge, Face, Location, Plane, ShapeList, Shell, Solid, Vector, Wire, export_step
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCP.BRep import BRep_Tool
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
from OCP.BRepOffset import BRepOffset_Skin
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
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.TopTools import TopTools_ListOfShape
from OCP.TopoDS import TopoDS
from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Vec
from .parametric_bend import build_bend_solid
from .parametric_gears import build_gear_solid, build_rack_solid
@@ -67,14 +81,34 @@ class Build123dGeometryAdapter:
@staticmethod
def _wire(edges: list[dict[str, Any]]) -> Wire:
# 将边字典列表(直线/圆弧/插值 B 样条)组装成 build123d 的 Wire 线框。
# 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。
built: list[Edge] = []
for edge in edges:
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")
built.append(Edge.make_spline(points, periodic=False))
parameters = edge.get("parameters")
start_tangent = edge.get("start_tangent_mm")
end_tangent = edge.get("end_tangent_mm")
if (start_tangent is None) != (end_tangent is None):
raise ValueError("bspline contour edge requires both endpoint tangents")
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,
scale=False,
))
continue
if edge.get("type") == "ellipse":
center = _vector(edge["center_mm"])
major_axis = _vector(edge["major_axis_mm"])
normal = _vector(edge.get("normal") or [0, 0, 1])
if major_axis.length <= 1e-9 or normal.length <= 1e-9:
raise ValueError("ellipse contour edge has a degenerate frame")
plane = Plane(origin=center, x_dir=major_axis, z_dir=normal)
built.append(Edge.make_ellipse(float(edge["major_radius_mm"]), float(edge["minor_radius_mm"]), plane=plane))
continue
start = _vector(edge["start_mm"])
end = _vector(edge["end_mm"])
@@ -135,6 +169,30 @@ class Build123dGeometryAdapter:
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Face]:
# 从草图数据解析出可拉伸/旋转的轮廓面,按三种数据来源依次回退。
# 1. 单圆 contour 不应先被 sketch_solver 展开成四段圆弧。圆弧分段会
# 改变拉伸后的圆柱面拓扑:同一圆柱侧面被拆成四块,后续来自
# FeatureScript 的 SWEPT_FACE 无法再以圆心/半径唯一定位。对于
# 只含闭合整圆的轮廓,保留每个圆一条原生 circle edge;同心圆仍由
# _faces_from_circles 的包含关系生成带孔面。
profile = sketch.get("profile") or {}
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
if isinstance(contours, list) and contours and all(
isinstance(contour, dict)
and bool(contour.get("closed"))
and len(contour.get("segments") or []) == 1
and (contour.get("segments") or [{}])[0].get("type") == "circle"
for contour in contours
):
circles = [
{
"type": "circle",
"center": segment.get("center"),
"radius_mm": segment.get("radius_mm"),
}
for contour in contours
for segment in contour.get("segments") or []
]
return self._faces_from_circles(circles, PlaneSpec.from_mapping(sketch.get("workplane") or {}))
# 1. 优先使用预计算的轮廓区域 contour_regions_mm(外轮廓 + 孔洞列表)。
regions = sketch.get("contour_regions_mm") or []
if regions:
@@ -156,6 +214,20 @@ class Build123dGeometryAdapter:
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
return self._faces_from_circles(sketch.get("entities") or [], plane)
@staticmethod
def face_with_holes(outer: Face, holes: Iterable[Face]) -> Face:
"""Build one planar profile from a sketch outer wire and cap-face holes."""
if outer.inner_wires():
raise ValueError("profile outer face must not already contain holes")
wires = []
for hole in holes:
if hole.inner_wires():
raise ValueError("profile hole face must have exactly one outer wire")
wires.append(hole.outer_wire())
if not wires:
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:
"""由多条简单闭合草图轮廓生成实体放样。"""
wires: list[Wire] = []
@@ -173,6 +245,20 @@ class Build123dGeometryAdapter:
raise ValueError("loft requires at least two profile sketches")
return Solid.make_loft(wires)
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."""
if not sketches:
raise ValueError("cap-face loft requires at least one profile sketch")
wires = [cap_face.outer_wire()]
for index, sketch in enumerate(sketches):
faces = self.faces_for_sketch(sketch)
if len(faces) != 1:
raise ValueError(f"loft profile {index} must resolve to exactly one closed region")
if faces[0].inner_wires():
raise ValueError(f"loft profile {index} must not contain inner loops")
wires.append(faces[0].outer_wire())
return Solid.make_loft(wires)
@staticmethod
def _coerce_single_or_compound(result: Any, *, empty_error: str | None = None) -> Any:
"""规整一次布尔结果:None/空视为失败(可选报错),多成员合并为 Compound。"""
@@ -189,17 +275,44 @@ class Build123dGeometryAdapter:
return None
if len(members) == 1:
return members[0]
# build123d 0.11 的有效运行时合并 API 是 Compound.make_composite(多体域
# 合并为 Compound/Part);Compound.make_compound 在类型桩与运行时均不存在,
# 此前的调用在多成员 ShapeList 下会 AttributeError。类型桩未声明
# make_composite,保留宽泛类型桩噪音抑制。
return Compound.make_composite(members)
# ``Compound`` constructor accepts an iterable on both supported
# Build123d runtimes. ``make_composite`` is not available in every
# deployed version, so using it here breaks valid multi-solid cuts.
return Compound(members)
@staticmethod
def extrude(face: Face, direction: Vector3) -> Solid:
# 沿给定方向向量拉伸一个面,生成实体。
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 同向时始终优先使用它。
vector = _vector(direction)
normal = face.normal_at()
if (
vector.length > 1e-9
and normal.length > 1e-9
and vector.normalized().dot(normal.normalized()) >= 1.0 - 1e-9
and not face.inner_wires()
):
angle_rad = math.radians(taper_deg)
prism = LocOpe_DPrism(
face.wrapped,
vector.length / math.cos(angle_rad),
angle_rad,
)
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)
@staticmethod
def extrude_trimmed(face: Face, target: Any, direction: Vector3) -> Any:
"""Extrude the profile to the target face, trimming unreached regions.
@@ -217,6 +330,7 @@ class Build123dGeometryAdapter:
# through_next 的 target 是当前主体,必须先从其面中选出实际命中的
# 下一终止面,不能把整个 body 当作待拉伸的 Face。
unit = _vector(direction).normalized()
target_body = target if not isinstance(target, Face) else None
points = Build123dGeometryAdapter.profile_sample_points(face)
targets = [target] if isinstance(target, Face) else list(target.faces())
candidates = []
@@ -231,15 +345,28 @@ class Build123dGeometryAdapter:
# 时取最近的正向交点,确保相邻面交界处的选择稳定。
_, _, target, distances = max(candidates, key=lambda item: (item[0], -item[1]))
margin = max(distances) + 2.0
# 2. 穿透拉伸 profile,同时把目标面向回推生成体层,二者求交即裁剪体。
if target_body is not None and len(distances) == len(points):
# through_next 的 target 是当前实体,不是孤立的终止面。先让工具体
# 轻微穿过首个命中面,再切掉既有实体,留下从 profile 到该面的一侧。
# 将圆柱面拉成 slab 会保留实体内部的短段,丢失外部新增材料。
pierced = Solid.extrude(face, unit * margin)
return Build123dGeometryAdapter._coerce_single_or_compound(
pierced.cut(target_body), empty_error="extent target leaves no leading material",
)
# 2. 穿透拉伸 profile,再从目标面两侧各构造一个体层。曲面 Face 的
# OCC 朝向不保证与拉伸方向一致(特别是 cut 后的内圆柱面),不能
# 固定假定 -unit 一定朝向 profile;选择与 profile 相接的交集侧。
# build123d 的布尔交方法名是 intersect(不是 OCC 的 common),
# 且多实体结果返回 ShapeList,需要规整为单个 Solid / Compound。
pierced = Solid.extrude(face, unit * margin)
slab = Solid.extrude(target, -unit * margin)
trimmed = pierced.intersect(slab)
return Build123dGeometryAdapter._coerce_single_or_compound(
trimmed, empty_error="extent target produced an empty trimmed solid",
)
candidates = []
for slab_direction in (-unit * margin, unit * margin):
trimmed = Build123dGeometryAdapter._coerce_single_or_compound(pierced.intersect(Solid.extrude(target, slab_direction)))
if trimmed is not None:
candidates.append((trimmed.distance_to(face), trimmed))
if not candidates:
raise ValueError("extent target produced an empty trimmed solid")
return min(candidates, key=lambda item: item[0])[1]
@staticmethod
def body_center(body: Any) -> Vector3:
@@ -265,6 +392,20 @@ class Build123dGeometryAdapter:
# 提取顶点的三维坐标元组。
return (float(vertex.X), float(vertex.Y), float(vertex.Z))
@staticmethod
def intersection_vertex(body: Any, face_sets: list[list[Any]]) -> Any:
"""Resolve one current-body vertex shared by every selected face set."""
if not face_sets or any(not faces for faces in face_sets):
raise ValueError("intersection selector has an empty face set")
matched = []
for vertex in body.vertices():
if all(any(face.distance_to(vertex) <= 1e-6 for face in faces) for faces in face_sets):
point = (round(float(vertex.X), 6), round(float(vertex.Y), 6), round(float(vertex.Z), 6))
if not any(point == existing[0] for existing in matched): matched.append((point, vertex))
if len(matched) != 1:
raise ValueError(f"intersection selector resolved {len(matched)} current-body vertices")
return matched[0][1]
@staticmethod
def profile_sample_points(face: Face) -> list[Vector]:
"""Sample a profile face before a selector-dependent termination.
@@ -287,6 +428,51 @@ class Build123dGeometryAdapter:
unique.append(point)
return unique
@staticmethod
def profile_touches_target(target: Any, faces: Iterable[Face]) -> bool:
"""Return whether a profile starts on the selected extent target."""
# `up_to_surface` 允许 profile 从 selected face 出发。这时 selected
# face 不是拉伸终止,而是起始边界;交给 next_body_face_after 在当前
# 主体中寻找真实的下一面。distance_to 是 OCC 的最短形体距离,适用于
# 平面、圆柱和其他可用作 selector 的 B-rep face。
for face in faces:
try:
if face.distance_to(target) <= 1e-6:
return True
except Exception as error:
raise ValueError("extent target does not support profile distance") from error
return False
@staticmethod
def next_body_face_after(body: Any, faces: Iterable[Face], direction: Vector3, *, excluded_face: Face) -> Face:
"""Find the next current-body face reached after an extent start face."""
# 对每个候选面统计其能截获的 profile 射线。完整覆盖优先,随后选取
# 最近正向交点,避免把同一外圆柱的远侧交点误当成穿过实体后的终止面。
unit = _vector(direction).normalized()
samples = [point for face in faces for point in Build123dGeometryAdapter.profile_sample_points(face)]
if not samples:
raise ValueError("extent feature has no profile samples")
candidates = []
for candidate in body.faces():
# Selector resolution can retain the preceding B-rep snapshot,
# whose wrapper is no longer `is_same` after a later boolean.
# Geometrically coincident current faces are still the selected
# start boundary and must not win by their near-zero ray hits.
if candidate.is_same(excluded_face) or candidate.distance_to(excluded_face) <= 1e-6:
continue
distances = [
value for point in samples
if (value := Build123dGeometryAdapter._forward_intersection_distance(candidate, point, unit)) is not None
]
if distances:
candidates.append((len(distances), min(distances), candidate))
if not candidates:
raise ValueError("extent target has no following body face")
coverage, _distance, target = max(candidates, key=lambda item: (item[0], -item[1]))
if coverage != len(samples):
raise ValueError("extent target does not reach every profile ray after the start face")
return target
@staticmethod
def _forward_intersection_distance(target: Any, point: Vector, direction: Vector) -> float | None:
# 从 point 沿 direction 发一条射线,求与目标的第一个正向交点距离。
@@ -325,16 +511,120 @@ class Build123dGeometryAdapter:
# 绕给定轴将面旋转指定角度,生成回转实体。
return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis))
@staticmethod
def revolve_surface(wire: Wire, angle_deg: float, axis: AxisSpec) -> Shell:
# 表面回转必须以 profile wire 而非 Face 输入。Face 回转会由 OCC 封闭为
# Solid,错误改变 CADFS NewSurfaceOperation 的实体结果和体积。
operation = BRepPrimAPI_MakeRevol(
wire.wrapped,
gp_Ax1(gp_Pnt(*axis.origin_mm), gp_Dir(*axis.direction)),
math.radians(angle_deg),
True,
)
operation.Build()
if not operation.IsDone():
raise ValueError("surface revolve did not complete")
shape = operation.Shape()
if shape.IsNull() or shape.ShapeType() != TopAbs_SHELL:
raise ValueError("surface revolve did not produce one shell")
return Shell(shape)
@staticmethod
def surface_wires_for_sketch(sketch: dict[str, Any]) -> list[Wire]:
# 曲面拉伸只消费 CADFS 显式选择的闭合曲线,不把同心圆转换成带孔 Face。
# 后者适用于实体拉伸,但会丢失每条 source edge 对应的一张独立曲面。
profile = sketch.get("profile") or {}
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
if profile.get("type") == "circle":
return [Build123dGeometryAdapter()._circle_wire(profile.get("center") or [0, 0], float(profile["radius_mm"]), plane)]
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
if not isinstance(contours, list) or not contours:
raise ValueError("surface extrude requires closed contour wires")
wires = []
for contour in contours:
segments = contour.get("segments") or []
if not contour.get("closed") or len(segments) != 1 or segments[0].get("type") != "circle":
raise ValueError("surface extrude currently supports selected circular wires only")
segment = segments[0]
wires.append(Build123dGeometryAdapter()._circle_wire(
segment.get("center") or [0, 0], float(segment["radius_mm"]), plane,
))
return wires
@staticmethod
def extrude_surface(wires: Iterable[Wire], direction: Vector3) -> Any:
# 将每条闭合 wire 沿给定方向扫成独立 shell。曲面不参与实体布尔,后续
# selector 通过 register_surface 的独立 topology snapshot 追溯其来源。
vector = _vector(direction)
if vector.length <= 1e-9:
raise ValueError("surface extrude direction must be non-zero")
surfaces = []
for wire in wires:
operation = BRepPrimAPI_MakePrism(wire.wrapped, gp_Vec(vector.X, vector.Y, vector.Z), True, True)
operation.Build()
if not operation.IsDone():
raise ValueError("surface extrude did not complete")
shape = operation.Shape()
if shape.IsNull() or shape.ShapeType() != TopAbs_SHELL:
raise ValueError("surface extrude did not produce one shell")
surfaces.append(Shell(shape))
if not surfaces:
raise ValueError("surface extrude requires at least one wire")
return surfaces[0] if len(surfaces) == 1 else Compound(surfaces)
@staticmethod
def combine_surfaces(*surfaces: Any) -> Any:
# 不能复用 combine:它按实体 body 生命周期调用 body_solids,会丢弃
# Shell。曲面组合只用于导出和拓扑登记,不执行实体 boolean。
members = []
for surface in surfaces:
if isinstance(surface, Compound):
members.extend(surface.faces())
else:
members.append(surface)
if not members:
raise ValueError("surface combination requires at least one shell")
return members[0] if len(members) == 1 else Compound(members)
@staticmethod
def fuse(body: Any | None, solid: Any) -> Any:
# 布尔并:没有既有主体时,直接以该实体作为新主体。
# 实参类型放宽为 Any:build123d 的布尔结果可能是 Solid 或 Compound。
return solid if body is None else body.fuse(solid)
if body is None:
return solid
# build123d.Shape.fuse 未启用 OBB 加速器;镜像后的重叠实体在该路径
# 会偶发返回反向、无效的 B-rep。直接采用 OCC 的稳定布尔配置,保留
# 一般 add/replay 的同一 union 语义。
arguments = TopTools_ListOfShape(); arguments.Append(body.wrapped)
tools = TopTools_ListOfShape(); tools.Append(solid.wrapped)
operation = BRepAlgoAPI_Fuse()
operation.SetRunParallel(True); operation.SetUseOBB(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 result.is_valid:
return result
# 保留 build123d 的既有调用作为内核版本差异下的兼容回退;无效结果
# 不能悄然进入后续 feature history。
fallback = Build123dGeometryAdapter._coerce_single_or_compound(body.fuse(solid))
if fallback is not None and fallback.is_valid:
return fallback
raise ValueError("OCC union operation produced an invalid shape")
@staticmethod
def combine(body: Any | None, solid: Any) -> Any:
# 保留独立 result body:不得调用 fuse,否则相交实体会被内核合并。
members = ([] if body is None else Build123dGeometryAdapter.body_solids(body))
members.extend(Build123dGeometryAdapter.body_solids(solid))
return members[0] if len(members) == 1 else Compound(members)
@staticmethod
def cut(body: Any, tool: Any) -> Any:
# 从主体上减去工具实体。
return body.cut(tool)
return Build123dGeometryAdapter._coerce_single_or_compound(
body.cut(tool), empty_error="OCC cut operation produced no shape",
)
@staticmethod
def sphere(radius_mm: float, center_mm: Vector3) -> Solid:
@@ -533,7 +823,179 @@ class Build123dGeometryAdapter:
@staticmethod
def chamfer(body: Any, distance_mm: float, distance_2_mm: float | None, edges: Iterable[Edge], face: Face | None = None) -> Any:
# 对指定边做倒角;distance_2_mm 提供时形成非对称倒角。
return body.chamfer(distance_mm, distance_2_mm, list(edges), face=face)
# OCC 的单距离 Add 重载会按内核的等距倒角语义处理两侧相邻面。build123d
# 的通用实现会先任选一张邻接面再调用双距离重载,复杂实体上该选择会改变
# 倒角结果,因此仅等距倒角优先走原生重载。
selected = list(edges)
if distance_2_mm is None and face is None and len(Build123dGeometryAdapter.body_solids(body)) == 1:
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
return body.chamfer(distance_mm, distance_2_mm, selected, face=face)
@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.
A regular equal-offset chamfer is first attempted by ``chamfer``. This
helper only handles the narrow CADFS case where that operation reaches
a concentric surface split before the requested second offset. The
explicit shell must contain the limiting circle at the selected plane;
without that evidence this method deliberately rejects the fallback.
"""
if str(edge.geom_type).split(".")[-1].lower() != "circle":
raise ValueError("surface-limited chamfer requires circular edges")
try:
radius = float(edge.radius)
center = edge.arc_center
except (TypeError, ValueError) as error:
raise ValueError("surface-limited chamfer edge has no circle radius") from error
if radius <= 0:
raise ValueError("surface-limited chamfer edge radius must be positive")
adjacent = [
face for face in body.faces()
if any(face_edge.is_same(edge) for face_edge in face.edges())
]
planes = [face for face in adjacent if str(face.geom_type).split(".")[-1].lower() == "plane"]
cylinders = [face for face in adjacent if str(face.geom_type).split(".")[-1].lower() == "cylinder"]
if len(planes) != 1 or len(cylinders) != 1:
raise ValueError("surface-limited chamfer requires one planar and one cylindrical adjacent face")
plane_face, cylinder_face = planes[0], cylinders[0]
axis = cylinder_face.axis_of_rotation
if axis is None:
raise ValueError("surface-limited chamfer cylinder has no axis")
axis_direction = axis.direction.normalized()
if abs(plane_face.normal_at().normalized().dot(axis_direction)) < 1.0 - 1e-6:
raise ValueError("surface-limited chamfer faces are not perpendicular")
boundaries = [
candidate for candidate in plane_face.edges()
if str(candidate.geom_type).split(".")[-1].lower() == "circle"
and not candidate.is_same(edge)
and (candidate.arc_center - center).length <= 1e-6
]
if len(boundaries) != 1:
raise ValueError("surface-limited chamfer plane has no unique concentric support")
support_edge = boundaries[0]
support_radius = float(support_edge.radius)
radial_span = radius - support_radius
if radial_span <= 1e-6 or distance_mm <= radial_span + 1e-6:
raise ValueError("surface-limited chamfer does not need a constrained outer transition")
cylinder_ends = [
candidate for candidate in cylinder_face.edges()
if str(candidate.geom_type).split(".")[-1].lower() == "circle"
and not candidate.is_same(edge)
and abs(float(candidate.radius) - radius) <= 1e-6
and (candidate.arc_center - center).length > 1e-6
]
if len(cylinder_ends) != 1:
raise ValueError("surface-limited chamfer cylinder has no unique opposite cap")
axial_span = cylinder_ends[0].arc_center - center
if axial_span.length <= distance_mm + 1e-6:
raise ValueError("surface-limited chamfer exceeds the selected cylindrical face")
direction = axial_span.normalized()
supported = False
for surface in surfaces:
for face in surface.faces():
if str(face.geom_type).split(".")[-1].lower() != "cylinder":
continue
surface_axis = face.axis_of_rotation
if surface_axis is None or abs(surface_axis.direction.normalized().dot(axis_direction)) < 1.0 - 1e-6:
continue
if abs(float(face.radius) - support_radius) > 1e-6:
continue
if any(
str(boundary.geom_type).split(".")[-1].lower() == "circle"
and abs(float(boundary.radius) - support_radius) <= 1e-6
and (boundary.arc_center - center).length <= 1e-6
for boundary in face.edges()
):
supported = True
break
if supported:
break
if not supported:
raise ValueError("surface-limited chamfer has no explicit surface support")
frame_x = edge.tangent_at(0.0).normalized()
first_length = distance_mm - radial_span
outer_radius = radius + max(distance_mm, 1.0)
start_plane = Plane(origin=center, x_dir=frame_x, z_dir=direction)
cone_plane = Plane(origin=center + direction * first_length, x_dir=frame_x, z_dir=direction)
outer = Solid.make_cylinder(outer_radius, distance_mm, start_plane)
core = Solid.make_cylinder(support_radius, first_length, start_plane).fuse(
Solid.make_cone(support_radius, radius, radial_span, cone_plane),
)
return outer.cut(core)
@staticmethod
def surface_limited_chamfer(body: Any, distance_mm: float, edges: Iterable[Edge], surfaces: Iterable[Any]) -> Any:
# 显式 surface shell 只在内核正常倒角失败后作为截断证据使用。每条边
# 都先从同一原 body 推导工具体,随后依序切除,避免已变形拓扑反向影响
# 另一条 source selector。
if len(Build123dGeometryAdapter.body_solids(body)) != 1:
raise ValueError("surface-limited chamfer requires one solid body")
selected = list(edges)
if not selected:
raise ValueError("surface-limited chamfer requires at least one edge")
surface_members = list(surfaces)
tools = [
Build123dGeometryAdapter._surface_limited_chamfer_tool(body, edge, distance_mm, surface_members)
for edge in selected
]
result = body
for tool in tools:
result = result.cut(tool)
if not isinstance(result, Solid) or not result.is_valid:
raise ValueError("surface-limited chamfer produced an invalid shape")
if result.volume >= body.volume - 1e-6:
raise ValueError("surface-limited chamfer removed no material")
return result
@staticmethod
def shell(body: Any, faces: Iterable[Face], thickness_mm: float, *, inward: bool = True) -> Any:
# 对单个实体移除指定面并偏置其余面,生成薄壁实体。多 body 的目标
# 选择与结果合并由 runtime 处理;OCC 的 MakeThickSolidByJoin 只接受
# 一个 Solid,不能把 Compound 直接交给内核并猜测其 body 生命周期。
selected = list(faces)
if not selected:
raise ValueError("shell requires at least one face to remove")
thickness = float(thickness_mm)
if thickness <= 0:
raise ValueError("shell thickness_mm must be > 0")
solids = Build123dGeometryAdapter.body_solids(body)
if len(solids) != 1:
raise ValueError("shell adapter requires exactly one target solid")
closing_faces = TopTools_ListOfShape()
for face in selected:
closing_faces.Append(face.wrapped)
builder = BRepOffsetAPI_MakeThickSolid()
builder.MakeThickSolidByJoin(
solids[0].wrapped,
closing_faces,
-thickness if inward else thickness,
1e-6,
BRepOffset_Skin,
False,
False,
GeomAbs_Arc,
False,
)
builder.Build()
if not builder.IsDone():
raise ValueError("OCC shell operation did not complete")
result = Solid(builder.Shape())
if not result.is_valid:
raise ValueError("OCC shell operation produced an invalid shape")
return result
@staticmethod
def sweep(
@@ -556,15 +1018,36 @@ class Build123dGeometryAdapter:
}
if transition is not None:
sweep_options["transition"] = transition
return Solid.sweep(section, spine, **sweep_options)
result = Solid.sweep(section, spine, **sweep_options)
if make_solid and (not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9):
# OCC 在截面与路径不构成有效实体 sweep 时可能返回零体积形状,
# 而不报告 Build() 失败。该结果不能作为 CADFS 的 solid body 继续传播。
raise ValueError("OCC sweep operation did not produce a solid")
return result
@staticmethod
def sweep_path(points: Iterable[Vector3]) -> Wire:
# 把三维点列连成折线 Wire,作为扫掠路径的通用构造入口。
def sweep_path(
points: Iterable[Vector3],
*,
start_tangent: Vector3 | None = None,
end_tangent: Vector3 | None = None,
parameters: list[float] | None = None,
) -> Edge | Wire:
# 两点路径保持直线;三个及以上插值点构造单段 B-spline。端切线是
# FeatureScript skFitSpline 的约束,缺失时不能伪造,交给内核自动求解。
vertices = [_vector(point) for point in points]
if len(vertices) < 2:
raise ValueError("sweep path needs at least two points")
return Wire([Edge.make_line(vertices[index], vertices[index + 1]) for index in range(len(vertices) - 1)])
if len(vertices) == 2:
if start_tangent is not None or end_tangent is not None or parameters is not None:
raise ValueError("line sweep path does not accept B-spline tangents")
return Edge.make_line(vertices[0], vertices[1])
if (start_tangent is None) != (end_tangent is None):
raise ValueError("sweep B-spline path requires both endpoint tangents")
tangents = [_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None
if parameters is not None and len(parameters) != len(vertices):
raise ValueError("sweep B-spline path parameters must match point count")
return Edge.make_spline(vertices, tangents=tangents, parameters=parameters, scale=False)
@staticmethod
def helix_path(
@@ -644,8 +1127,30 @@ class Build123dGeometryAdapter:
@staticmethod
def export(body: Any, path: str) -> None:
# 将主体导出为 STEP 文件。
export_step(body, path)
# OCCT 对大于 90 度的 SURFACE_OF_REVOLUTION 在 STEP round-trip 时会
# 丢失部分参数域,导入后该侧面退化为一条母线。只对包含这类曲面的
# 独立实体按 45 度分段,保留原始解析曲面与实体几何,避免影响其余
# B-rep 的拓扑和导出体积。
segments = []
for solid in Build123dGeometryAdapter.body_solids(body):
if not isinstance(solid, Solid):
# Shell 或只含曲面的 Compound 没有实体体积分割语义。直接交给
# STEP exporter,才能保留 pure-surface feature history 的面。
segments.append(solid); continue
has_revolution = any(
BRep_Tool.Surface_s(face.wrapped).IsKind(Geom_SurfaceOfRevolution.get_type_descriptor_s())
for face in solid.faces()
)
if not has_revolution:
segments.append(solid); continue
divider = ShapeUpgrade_ShapeDivideAngle(math.radians(45.0), solid.wrapped)
divider.SetPrecision(1e-7); divider.SetMaxTolerance(1e-5)
if not divider.Perform(): raise ValueError("STEP revolution surface segmentation failed")
segmented = Solid(divider.Result())
if not segmented.is_valid:
raise ValueError("STEP revolution surface segmentation produced an invalid solid")
segments.append(segmented)
export_step(segments[0] if len(segments) == 1 else Compound(segments), path)
@staticmethod
def body_solids(body: Any) -> list[Any]:
@@ -671,6 +1176,16 @@ class Build123dGeometryAdapter:
"solid_count": len(solids),
}
@staticmethod
def surface_geometry(surface: Any) -> dict[str, Any]:
# 曲面结果不参与实体 body 聚合;只保存后续 selector 所需的独立拓扑摘要。
bbox = surface.bounding_box()
return {
"bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
"area_mm2": float(surface.area),
"face_count": len(surface.faces()),
}
@staticmethod
def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]:
# 从主体导出全部面/边/顶点拓扑记录,供后续特征选择与引用。
@@ -757,51 +1272,56 @@ class Build123dGeometryAdapter:
elif geometry["surface_type"] in {"cylinder", "cone"}:
axis = face.axis_of_rotation
if axis is None:
raise ValueError("rotational face is missing an axis")
direction = axis.direction
origin = axis.position
geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z]
geometry["axis_direction"] = [direction.X, direction.Y, direction.Z]
raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None
if geometry["surface_type"] == "cone":
boundary_radii: list[float] = []
for edge in face.edges():
if str(edge.geom_type).split(".")[-1].lower() != "circle":
continue
try:
boundary_radii.append(float(edge.radius))
except ValueError:
continue
geometry["boundary_radii_mm"] = sorted(boundary_radii)
geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None
# ``through`` alone describes a cylinder spanning two opposed
# planar faces. That applies to both a through bore and the
# outside wall of a cylindrical extrusion. Classify the B-rep
# face by its oriented normal so downstream acceptance claims
# can prove holes without mistaking an exterior wall for one.
unit_axis = (direction.X, direction.Y, direction.Z)
radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z)
axial_projection = sum(radial[component] * unit_axis[component] for component in range(3))
radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3))
radial_length = sum(component * component for component in radial) ** 0.5
if geometry["surface_type"] == "cylinder":
# OCC can report a cylinder surface with ``radius=None``
# after a non-planar-side Boolean cut. The face centre is
# still on that cylinder, so its perpendicular distance to
# the rotation axis is an equivalent measured radius. Do
# not fail an otherwise valid build merely because that
# optional OCC convenience property is absent.
if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)):
geometry["radius_mm"] = float(raw_cylinder_radius)
elif radial_length > 1e-9:
geometry["radius_mm"] = radial_length
if radial_length > 1e-9:
normal_components = (normal.X, normal.Y, normal.Z)
alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length
geometry["radial_normal_alignment"] = alignment
geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown"
# Build123d can omit this optional OCC property for valid
# swept rotational faces. Keep their generic B-rep record
# so a selector-free workflow remains executable; do not
# invent axis/radius evidence for an axis-based selector.
pass
else:
geometry["cylinder_role"] = "unknown"
direction = axis.direction
origin = axis.position
geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z]
geometry["axis_direction"] = [direction.X, direction.Y, direction.Z]
raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None
if geometry["surface_type"] == "cone":
boundary_radii: list[float] = []
for edge in face.edges():
if str(edge.geom_type).split(".")[-1].lower() != "circle":
continue
try:
boundary_radii.append(float(edge.radius))
except ValueError:
continue
geometry["boundary_radii_mm"] = sorted(boundary_radii)
geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None
# ``through`` alone describes a cylinder spanning two opposed
# planar faces. That applies to both a through bore and the
# outside wall of a cylindrical extrusion. Classify the B-rep
# face by its oriented normal so downstream acceptance claims
# can prove holes without mistaking an exterior wall for one.
unit_axis = (direction.X, direction.Y, direction.Z)
radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z)
axial_projection = sum(radial[component] * unit_axis[component] for component in range(3))
radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3))
radial_length = sum(component * component for component in radial) ** 0.5
if geometry["surface_type"] == "cylinder":
# OCC can report a cylinder surface with ``radius=None``
# after a non-planar-side Boolean cut. The face centre is
# still on that cylinder, so its perpendicular distance to
# the rotation axis is an equivalent measured radius. Do
# not fail an otherwise valid build merely because that
# optional OCC convenience property is absent.
if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)):
geometry["radius_mm"] = float(raw_cylinder_radius)
elif radial_length > 1e-9:
geometry["radius_mm"] = radial_length
if radial_length > 1e-9:
normal_components = (normal.X, normal.Y, normal.Z)
alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length
geometry["radial_normal_alignment"] = alignment
geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown"
else:
geometry["cylinder_role"] = "unknown"
face_edge_indexes.append(set(boundary_edge_indexes))
face_geometries.append(geometry)
records.append(TopologyRecord(
+103 -24
View File
@@ -16,28 +16,28 @@ from .runtime_types import CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeD
from .operation_contracts import materialized_feature_contracts
_SELECTOR_REQUIRED = frozenset({"fillet", "chamfer"})
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_")
_SELECTOR_REQUIRED = frozenset({"extrude_add_blind_with_hole", "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_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_surface",
"extrude_cut_through",
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
"revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
})
_HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"})
_ACTIVE_BODY_REQUIRED = frozenset({
"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through",
"revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer",
"loft_add_with_cap_face", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer", "shell",
# thread_cut 是 cut 型特征:必须在已有主体(宿主)上做布尔差,不能凭空
# 造实体;无宿主时按 active_body 前置阻止而非让 executor 在 None 上崩溃。
"thread_cut",
})
_BODY_MUTATING_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add",
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", *_HOLE_ATOMICS, "fillet", "chamfer",
})
@@ -163,7 +163,7 @@ def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]:
atomic_id = str(feature.get("atomic_id") or "")
if feature.get("sketch_id") is not None and (contracts.get(atomic_id) or {}).get("requires_sketch"):
required.add(str(feature["sketch_id"]))
if atomic_id == "loft_add":
if atomic_id in {"loft_add", "loft_add_with_cap_face"}:
for sketch_id in (feature.get("params") or {}).get("profile_sketch_ids") or ():
required.add(str(sketch_id))
return frozenset(required)
@@ -313,12 +313,13 @@ class CapabilityAnalyzer:
sketch_id=node.sketch_id,
atomic_id=node.atomic_id,
))
if node.atomic_id == "loft_add":
if node.atomic_id in {"loft_add", "loft_add_with_cap_face"}:
profile_ids = params.get("profile_sketch_ids")
if not isinstance(profile_ids, list) or len(profile_ids) < 2:
minimum_profiles = 1 if node.atomic_id == "loft_add_with_cap_face" else 2
if not isinstance(profile_ids, list) or len(profile_ids) < minimum_profiles:
blockers.append(self._blocker(
node.feature_id, "invalid_loft_profiles",
"Loft requires at least two profile sketch ids",
f"Loft requires at least {minimum_profiles} profile sketch ids",
))
elif len({str(sketch_id) for sketch_id in profile_ids}) != len(profile_ids):
blockers.append(self._blocker(
@@ -361,20 +362,90 @@ class CapabilityAnalyzer:
"Loft currently requires exactly one outer profile without holes",
sketch_id=sketch_id,
))
if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES):
# #2 draft:extrudeParams.draft 在 cdsl_schema.json 中被允许,
# 但 runtime 的拉伸执行器(build123d Solid.extrude)没有锥形
# 拉伸能力,人读契约 profile_schema.json 也未声明该参数。
# 若 importer 把 SolidWorks 的 draft_angle_rad 写进 CDSL,
# 当前 runtime 会静默产出无拔模角的直壁实体。这里把它从
# "静默忽略"改为"显式拒绝"(与 unsupported_extent 同模式)。
if params.get("draft"):
if node.atomic_id == "sweep_add":
path = params.get("path")
segment = path.get("segment") if isinstance(path, dict) else None
kind = segment.get("type") if isinstance(segment, dict) else None
if kind not in {"line", "bspline"}:
blockers.append(self._blocker(
node.feature_id, "unsupported_draft",
"Extrude draft/taper is not implemented; the runtime would silently ignore it",
node.feature_id, "unsupported_sweep_path",
"Sweep requires one captured line or B-spline path",
))
elif kind == "line" and not all(
isinstance(segment.get(key), list) and len(segment[key]) == 2
for key in ("start", "end")
):
blockers.append(self._blocker(
node.feature_id, "invalid_sweep_path",
"Sweep line path requires two-dimensional start and end points",
))
elif kind == "bspline" and (
not isinstance(segment.get("points"), list)
or len(segment.get("points") or []) < 3
):
blockers.append(self._blocker(
node.feature_id, "invalid_sweep_path",
"Sweep B-spline path requires at least three interpolation points",
))
if node.atomic_id == "boolean_bodies":
target_ids = params.get("target_feature_ids")
tool_ids = params.get("tool_feature_ids")
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:
blockers.append(self._blocker(
node.feature_id, "missing_boolean_bodies",
"booleanBodies requires explicit target and tool feature ids", parameter=parameter,
))
continue
for feature_id in feature_ids:
source = nodes_by_id.get(str(feature_id))
if source is None:
blockers.append(self._blocker(
node.feature_id, "boolean_body_unavailable",
"booleanBodies source feature does not exist", 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,
))
if isinstance(target_ids, list) and isinstance(tool_ids, list) and set(target_ids) & set(tool_ids):
blockers.append(self._blocker(
node.feature_id, "boolean_body_overlap",
"booleanBodies targets and tools must be disjoint",
))
if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES):
end_condition = params.get("end_condition") or {"type": "blind"}
end_type = end_condition.get("type")
draft = params.get("draft")
if draft is not None:
# 目前只将 CADFS 单侧盲向拔模映射到 build123d
# Solid.extrude_taper。双向、到面和非实体 profile 的中性面
# 语义尚无 CDSL 表达,必须保留为明确能力缺口。
if (
node.atomic_id not in {"extrude_add_blind", "extrude_cut_blind"}
or end_type != "blind"
):
blockers.append(self._blocker(
node.feature_id, "unsupported_draft_extent",
"Draft currently supports only one-sided blind extrusions",
))
elif not (
isinstance(draft, dict)
and isinstance(draft.get("angle_deg"), (int, float))
and 0 < float(draft["angle_deg"]) < 90
and isinstance(draft.get("pull_direction"), bool)
):
blockers.append(self._blocker(
node.feature_id, "invalid_draft",
"Draft requires angle_deg in (0, 90) and boolean pull_direction",
))
required.append(f"extent:{end_type}")
if end_type not in _SUPPORTED_EXTENTS:
blockers.append(self._blocker(node.feature_id, "unsupported_extent", "The extent needs a resolved topology selector or is not implemented", extent=end_type))
@@ -430,6 +501,13 @@ 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"}:
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(
node.feature_id, "invalid_profile_hole_selector",
"Derived profile features require exactly one cap-face selector",
))
if node.atomic_id in _HOLE_ATOMICS:
required.append("selector:host_face")
if not params.get("host_face"):
@@ -529,16 +607,17 @@ class CapabilityAnalyzer:
if node.atomic_id in _BODY_MUTATING_ATOMICS:
body_available = True
body_producers = {
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add",
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"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", "gear_add", "rack_add",
# thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由
# active_body 前置阻止,文档含该类特征即视为携带可执行几何。
"thread_cut",
}
surface_producers = {"extrude_surface", "revolve_surface"}
document_blockers: list[RuntimeDiagnostic] = []
if not any(node.atomic_id in body_producers for node in plan):
if not any(node.atomic_id in body_producers | surface_producers for node in plan):
document_blockers.append(RuntimeDiagnostic(
"no_solid_feature", "CDSL contains no feature capable of creating a solid body",
))
+101 -9
View File
@@ -85,7 +85,8 @@
"properties": {
"type": {"type": "string", "minLength": 1},
"solidworks_code": {"type": "integer"},
"reference": {"$ref": "#/$defs/selectorRef"}
"reference": {"$ref": "#/$defs/selectorRef"},
"offset_mm": {"type": "number", "minimum": 0}
},
"required": ["type", "solidworks_code"],
"additionalProperties": false
@@ -98,10 +99,16 @@
},
"extrudeParams": {
"type": "object",
"properties": {"distance_mm": {"$ref": "#/$defs/number"}, "reverse": {"type": "boolean"}, "reverse_distance_mm": {"$ref": "#/$defs/number"}, "end_condition": {"$ref": "#/$defs/endCondition"}, "reverse_end_condition": {"$ref": "#/$defs/endCondition"}, "draft": {"type": "object"}},
"properties": {"distance_mm": {"$ref": "#/$defs/number"}, "reverse": {"type": "boolean"}, "reverse_distance_mm": {"$ref": "#/$defs/number"}, "end_condition": {"$ref": "#/$defs/endCondition"}, "reverse_end_condition": {"$ref": "#/$defs/endCondition"}, "draft": {"$ref": "#/$defs/extrudeDraft"}, "result_mode": {"enum": ["fuse", "new_body"]}},
"required": ["distance_mm"],
"additionalProperties": false
},
"extrudeDraft": {
"type": "object",
"properties": {"angle_deg": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 90}, "pull_direction": {"type": "boolean"}},
"required": ["angle_deg", "pull_direction"],
"additionalProperties": false
},
"extrudeCutThroughParams": {
"type": "object",
"properties": {"reverse": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}},
@@ -122,12 +129,57 @@
"required": ["profile_sketch_ids"],
"additionalProperties": false
},
"loftCapFaceParams": {
"type": "object",
"properties": {
"profile_sketch_ids": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"uniqueItems": true,
"items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
}
},
"required": ["profile_sketch_ids"],
"additionalProperties": false
},
"sweepPath": {
"type": "object",
"properties": {
"workplane": {"$ref": "#/$defs/workplane"},
"segment": {"$ref": "#/$defs/analyticSegment"}
},
"required": ["workplane", "segment"],
"additionalProperties": false
},
"sweepParams": {
"type": "object",
"properties": {
"path": {"$ref": "#/$defs/sweepPath"},
"is_frenet": {"type": "boolean"},
"result_mode": {"enum": ["fuse", "new_body"]}
},
"required": ["path"],
"additionalProperties": false
},
"revolveParams": {
"type": "object",
"properties": {"angle_deg": {"type": "number", "minimum": 0, "maximum": 360}, "axis": {"$ref": "#/$defs/axis"}, "reverse": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}},
"properties": {"angle_deg": {"type": "number", "minimum": 0, "maximum": 360}, "axis": {"$ref": "#/$defs/axis"}, "reverse": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}, "result_mode": {"enum": ["fuse", "new_body"]}},
"required": ["angle_deg", "axis"],
"additionalProperties": false
},
"revolveSurfaceParams": {
"type": "object",
"properties": {"angle_deg": {"type": "number", "minimum": 0, "maximum": 360}, "axis": {"$ref": "#/$defs/axis"}, "reverse": {"type": "boolean"}},
"required": ["angle_deg", "axis"],
"additionalProperties": false
},
"extrudeSurfaceParams": {
"type": "object",
"properties": {"distance_mm": {"type": "number", "exclusiveMinimum": 0}, "reverse": {"type": "boolean"}, "reverse_distance_mm": {"type": "number", "exclusiveMinimum": 0}},
"required": ["distance_mm"],
"additionalProperties": false
},
"sphereParams": {
"type": "object",
"properties": {
@@ -314,10 +366,27 @@
},
"chamferParams": {
"type": "object",
"properties": {"distance_mm": {"type": "number", "minimum": 0}, "distance_2_mm": {"type": "number", "minimum": 0}, "angle_rad": {"type": "number", "minimum": 0, "maximum": 3.141592653589793}},
"properties": {"distance_mm": {"type": "number", "minimum": 0}, "distance_2_mm": {"type": "number", "minimum": 0}, "angle_rad": {"type": "number", "minimum": 0, "maximum": 3.141592653589793}, "tangent_propagation": {"type": "boolean"}},
"required": ["distance_mm"],
"additionalProperties": false
},
"shellParams": {
"type": "object",
"properties": {"thickness_mm": {"type": "number", "exclusiveMinimum": 0}, "inward": {"type": "boolean"}},
"required": ["thickness_mm"],
"additionalProperties": false
},
"booleanBodiesParams": {
"type": "object",
"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}$"}},
"tool_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"keep_tools": {"type": "boolean"}
},
"required": ["operation", "target_feature_ids", "tool_feature_ids"],
"additionalProperties": false
},
"linearPatternParams": {
"type": "object",
"properties": {
@@ -336,7 +405,8 @@
"type": "object",
"properties": {
"source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"mirror_plane": {"$ref": "#/$defs/selectorOrUnresolved"}
"mirror_plane": {"$ref": "#/$defs/selectorOrUnresolved"},
"mirror_current_body": {"type": "boolean"}
},
"required": ["source_feature_ids", "mirror_plane"],
"additionalProperties": false
@@ -347,7 +417,9 @@
"source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"axis": {"$ref": "#/$defs/axis"},
"pattern_count": {"type": "integer", "minimum": 1},
"sweep_angle_deg": {"type": "number", "minimum": -360, "maximum": 360}
"sweep_angle_deg": {"type": "number", "minimum": -360, "maximum": 360},
"operation_mode": {"enum": ["add", "remove"]},
"excluded_instance_indices": {"type": "array", "items": {"type": "integer", "minimum": 1}, "uniqueItems": true}
},
"required": ["source_feature_ids", "axis", "pattern_count"],
"additionalProperties": false
@@ -395,10 +467,14 @@
"properties": {
"kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]},
"stable_id": {"type": "string", "minLength": 1},
"owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_.:-]{1,160}$"},
"geometry": {"type": "object"},
"source": {"enum": ["solidworks", "inferred_from_step", "runtime_snapshot", "viewer_selection"]},
"snapshot_id": {"type": "string", "minLength": 1},
"binding_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"match_mode": {"enum": ["unique", "all"]},
"matched_selectors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorRef"}},
"intersection_of": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}},
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
},
"required": ["kind", "stable_id", "source", "confidence"],
@@ -407,19 +483,28 @@
"analyticSegment": {
"type": "object",
"properties": {
"type": {"enum": ["line", "arc", "circle", "bspline"]},
"type": {"enum": ["line", "arc", "circle", "ellipse", "bspline"]},
"start": {"$ref": "#/$defs/point2"},
"end": {"$ref": "#/$defs/point2"},
"center": {"$ref": "#/$defs/point2"},
"radius_mm": {"$ref": "#/$defs/positive"},
"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"}},
"clockwise": {"type": "boolean"}
"parameters": {"type": "array", "minItems": 3, "items": {"type": "number"}},
"periodic": {"type": "boolean"},
"parameterization": {"enum": ["chord", "centripetal"]},
"clockwise": {"type": "boolean"},
"start_tangent": {"$ref": "#/$defs/point2"},
"end_tangent": {"$ref": "#/$defs/point2"}
},
"required": ["type"],
"allOf": [
{"if": {"properties": {"type": {"const": "line"}}}, "then": {"required": ["start", "end"]}},
{"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"]}}
],
"additionalProperties": false
@@ -481,13 +566,18 @@
"additionalProperties": false,
"allOf": [
{"if": {"properties": {"atomic_id": {"const": "extrude_add_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_add_blind_with_hole"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_add_two_sided"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"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": "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"}}}},
{"if": {"properties": {"atomic_id": {"const": "revolve_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "revolve_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "revolve_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveSurfaceParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeSurfaceParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "sphere_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sphereParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "box_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/boxParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "cylinder_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/cylinderParams"}}}},
@@ -501,6 +591,8 @@
{"if": {"properties": {"atomic_id": {"const": "hole_counterbore"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeCounterboreParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "fillet"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/filletParams"}}}},
{"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": "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"}}}},
+14 -7
View File
@@ -6,14 +6,19 @@
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours"],
"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"}},"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_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_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"]},
"extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]},
"loft_add_with_cap_face": {"atomic_id":"loft_add_with_cap_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":1,"maxItems":1,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["loft_cap_face","loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"]},
"sweep_add": {"atomic_id":"sweep_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body"]},
"extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]},
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"]},
"revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"]},
"revolve_cut": {"atomic_id":"revolve_cut","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"revolve_surface": {"atomic_id":"revolve_surface","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch","single_closed_profile_without_holes"],"candidate_verifiers":["surface_shell"]},
"hole_blind": {"atomic_id":"hole_blind","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore","through_cylindrical_bore"]},
"hole_countersink": {"atomic_id":"hole_countersink","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"countersink_diameter_mm":{"type":"number","exclusiveMinimum":0},"countersink_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","countersink_diameter_mm","countersink_angle_rad"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]},
"hole_counterbore": {"atomic_id":"hole_counterbore","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"counterbore_diameter_mm":{"type":"number","exclusiveMinimum":0},"counterbore_depth_mm":{"type":"number","exclusiveMinimum":0},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","counterbore_diameter_mm","counterbore_depth_mm"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]},
@@ -58,10 +63,12 @@
"candidate_verifiers": ["cylindrical_bore"]
},
"fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]},
"chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"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"]},
"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":[]},
"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}},"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.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},"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}},"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":[]}
"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":[]}
},
"profiles": {
"circle": {
@@ -76,7 +83,7 @@
"constraints": ["vertices contains at least three [u, v] points"]
},
"analytic_contours": {
"summary": "Closed executable line, arc, circle and interpolation B-spline contours. Executable B-splines preserve ordered interpolation points and must be closed; 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. B-splines may explicitly use chord or centripetal parameterization; CADFS closed skFitSpline uses centripetal. Imported construction B-splines remain non-executable audit geometry."
}
}
}
+541 -35
View File
@@ -20,13 +20,13 @@ from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
ALL_ATOMIC_IDS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add",
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
"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",
"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", "pattern_linear", "pattern_mirror",
"pattern_circular",
"hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror",
"pattern_circular", "boolean_bodies",
"thread_add", "thread_cut",
"bend_add",
"gear_add", "rack_add",
@@ -83,12 +83,24 @@ class GeometryAdapter(Protocol):
def topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ...
def body_solids(self, body: Any) -> list[Any]: ...
def body_geometry(self, body: Any) -> dict[str, Any]: ...
def surface_geometry(self, surface: Any) -> dict[str, Any]: ...
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
def face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ...
def loft(self, sketches: list[dict[str, Any]]) -> Any: ...
def loft_with_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_path(self, points: list[Vector3], *, start_tangent: Vector3 | None = None, end_tangent: Vector3 | None = None, parameters: list[float] | None = None) -> Any: ...
def extrude(self, face: Any, direction: Vector3) -> Any: ...
def extrude_taper(self, face: Any, direction: Vector3, taper_deg: float) -> Any: ...
def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ...
def surface_wires_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
def extrude_surface(self, wires: list[Any], direction: Vector3) -> Any: ...
def combine_surfaces(self, *surfaces: Any) -> Any: ...
def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def revolve_surface(self, wire: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def intersect(self, left: Any, right: Any) -> Any: ...
def fuse(self, body: Any | None, solid: Any) -> Any: ...
def combine(self, body: Any | None, solid: Any) -> Any: ...
def cut(self, body: Any, tool: Any) -> Any: ...
def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ...
def thread_solid(self, spec: ThreadSpec) -> Any: ...
@@ -99,11 +111,16 @@ class GeometryAdapter(Protocol):
def body_center(self, body: Any) -> Vector3: ...
def body_span(self, body: Any, direction: Vector3) -> float: ...
def vertex_coordinates(self, vertex: Any) -> Vector3: ...
def intersection_vertex(self, body: Any, face_sets: list[list[Any]]) -> Any: ...
def profile_sample_points(self, face: Any) -> list[Any]: ...
def profile_touches_target(self, target: Any, faces: list[Any]) -> bool: ...
def next_body_face_after(self, body: Any, faces: list[Any], direction: Vector3, *, excluded_face: Any) -> Any: ...
def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ...
def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ...
def 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 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 export(self, body: Any, path: str) -> None: ...
@@ -117,27 +134,39 @@ class ExecutionSession:
body_id: str | None = None
results: dict[str, FeatureResult] = field(default_factory=dict)
replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict)
body_members: dict[str, Any] = field(default_factory=dict)
surface_members: dict[str, Any] = field(default_factory=dict)
selector_resolutions: list[dict[str, Any]] = field(default_factory=list)
active_feature_id: str = ""
def register_body(self, feature_id: str, body: Any, *, replay_node: FeaturePlanNode | None = None) -> None:
def register_body(
self,
feature_id: str,
body: Any,
*,
replay_node: FeaturePlanNode | None = None,
body_members: dict[str, Any] | None = None,
) -> None:
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id":
# 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标
# 实体;单体保持 body:{feature}(与历史行为完全一致)。
self.body = body
self.body_id = f"body:{feature_id}"
self.body_members = dict(body_members) if body_members is not None else {feature_id: body}
solids = self.adapter.body_solids(body)
if len(solids) <= 1:
self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id))
else:
for index, solid in enumerate(solids):
member_id = f"{self.body_id}:{index}"
self.topology.replace_body_topology(
feature_id, member_id,
self.adapter.topology_records(solid, feature_id, member_id),
active_body_id=self.body_id,
)
# 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让
# 已登记的本轮成员成为下一个成员的 predecessor,进而把 pattern copy
# 的 owner 错误转移到相邻实例。必须原子替换整个多 body 拓扑快照。
members = [
(member_id, self.adapter.topology_records(solid, feature_id, member_id))
for index, solid in enumerate(solids)
for member_id in [f"{self.body_id}:{index}"]
]
self.topology.replace_body_topologies(feature_id, members, active_body_id=self.body_id)
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,),
@@ -145,16 +174,99 @@ class ExecutionSession:
if replay_node is not None:
self.replay_definitions[feature_id] = replay_node
def resolve(self, selector: dict[str, Any]) -> SelectorResolution:
resolution = self.topology.resolve(selector, active_body_id=self.body_id)
def register_surface(self, feature_id: str, surface: Any) -> str:
# 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body,
# 否则 surface 会覆盖 active solid 并改变最终 STEP 的实体结果。
surface_id = f"surface:{feature_id}"
self.surface_members[feature_id] = surface
for record in self.adapter.topology_records(surface, feature_id, surface_id):
self.topology.register(record)
self.topology.register(TopologyRecord(
record_id=surface_id, kind="surface", feature_id=feature_id, body_id=surface_id,
geometry=self.adapter.surface_geometry(surface), value=surface, owner_feature_ids=(feature_id,),
))
return surface_id
def _record_selector_resolution(self, resolution: SelectorResolution) -> SelectorResolution:
evidence = resolution.as_dict()
evidence["feature_id"] = self.active_feature_id
self.selector_resolutions.append(evidence)
return resolution
def result(self, node: FeaturePlanNode, *, context: PlaneSpec | AxisSpec | None = None, diagnostics: list[RuntimeDiagnostic] | None = None) -> FeatureResult:
def _intersection_component_records(self, selector: dict[str, Any]) -> list[TopologyRecord]:
matched = selector.get("matched_selectors") if selector.get("match_mode") == "all" else None
if matched is not None:
if not isinstance(matched, list) or not matched:
raise FeatureExecutionError("intersection_selector_unbound", "Intersection selector has no bound face matches")
resolved = [self._record_selector_resolution(self.topology.resolve(item, active_body_id=self.body_id)) for item in matched]
else:
binding_feature_id = selector.get("binding_feature_id")
active_body_id = None if binding_feature_id and self.body_id != f"body:{binding_feature_id}" else self.body_id
resolved = [self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))]
failures = [item for item in resolved if item.status != "resolved" or item.record is None]
if failures:
detail = failures[0].diagnostic.message if failures[0].diagnostic else "intersection selector component was not resolved"
raise FeatureExecutionError("intersection_selector_component_unresolved", detail)
return [item.record for item in resolved if item.record is not None]
def _resolve_intersection_vertex(self, selector: dict[str, Any]) -> SelectorResolution:
components = selector.get("intersection_of")
if self.body is None:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic("missing_extent_body", "Intersection selector requires an existing body"),
)
if not isinstance(components, list) or len(components) < 2:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic("intersection_selector_incomplete", "Intersection selector requires at least two face components"),
)
try:
face_sets = [self._intersection_component_records(component) for component in components]
if any(record.kind != "face" for records in face_sets for record in records):
raise FeatureExecutionError("intersection_selector_kind", "Intersection selector components must resolve to faces")
vertex = self.adapter.intersection_vertex(self.body, [[record.value for record in records] for records in face_sets])
except FeatureExecutionError as error:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic(error.code, str(error), detail=error.detail),
)
except ValueError as error:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic("intersection_vertex_unresolved", str(error)),
)
point = self.adapter.vertex_coordinates(vertex)
record = TopologyRecord(
record_id=str(selector.get("stable_id") or f"intersection:{id(vertex)}"),
kind="vertex", feature_id=self.active_feature_id, body_id=self.body_id,
geometry={"center_mm": list(point)}, value=vertex,
owner_feature_ids=tuple(filter(None, [str(selector.get("owner_feature_id") or "")])),
)
return SelectorResolution(
selector=selector, status="resolved", record=record,
candidates=({"score": 1.0, **record.public_dict()},),
)
def resolve(self, selector: dict[str, Any]) -> SelectorResolution:
if selector.get("intersection_of") is not None:
return self._record_selector_resolution(self._resolve_intersection_vertex(selector))
owner = str(selector.get("owner_feature_id") or "")
active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id
return self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))
def result(
self,
node: FeaturePlanNode,
*,
context: PlaneSpec | AxisSpec | None = None,
diagnostics: list[RuntimeDiagnostic] | None = None,
include_body: bool = True,
surface_id: str | None = None,
) -> FeatureResult:
result = FeatureResult(
feature_id=node.feature_id, atomic_id=node.atomic_id, status="executed", body_id=self.body_id,
feature_id=node.feature_id, atomic_id=node.atomic_id, status="executed",
body_id=self.body_id if include_body else None, surface_id=surface_id,
context=context, replay_definition={"atomic_id": node.atomic_id, "params": deepcopy(node.params), "sketch_id": node.sketch_id},
diagnostics=diagnostics or [],
)
@@ -237,6 +349,17 @@ def _targeted_extent_vector(
raise FeatureExecutionError("non_uniform_extent_target", "The target vertex does not define one extrusion distance", extent=condition)
distance = sum(projections) / len(projections)
else:
if condition == "up_to_surface" and session.adapter.profile_touches_target(target, faces):
# 草图轮廓本身就在所选终止面上时,selected face 只是拉伸的起始
# 边界。应沿实际拉伸方向穿过当前 body,取下一张完整截获 profile
# 的边界面作为终止面;直接裁剪到 selected face 会生成零厚度工具体。
try:
next_face = session.adapter.next_body_face_after(
session.body, faces, direction, excluded_face=target,
)
except ValueError as error:
raise FeatureExecutionError("extent_target_not_reached", str(error), extent=condition) from error
return ExtentVector(vector_scale(direction, 1.0), trim_to=next_face)
try:
distance = session.adapter.uniform_intersection_distance(target, faces, direction)
except ValueError as error:
@@ -255,13 +378,15 @@ def _targeted_extent_vector(
# 裁剪体层,仍保持显式拒绝。
return ExtentVector(vector_scale(direction, 1.0), trim_to=target)
raise FeatureExecutionError(code, message, extent=condition) from error
offset = abs(float((end_condition or {}).get("offset_mm") or 0.0))
if condition == "offset_from_surface":
offset = abs(float(offset_mm if offset_mm is not None else node.params.get("distance_mm") or 0.0))
offset = abs(float(offset_mm if offset_mm is not None else offset or node.params.get("distance_mm") or 0.0))
if offset:
distance -= offset
if distance <= 1e-6:
raise FeatureExecutionError(
"invalid_extent_offset",
"Offset distance reaches or passes the target surface",
"Offset distance reaches or passes the target extent",
extent=condition, offset_mm=offset,
)
return ExtentVector(vector_scale(direction, distance))
@@ -415,6 +540,14 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
faces = session.adapter.faces_for_sketch(selected_sketch)
if not faces:
raise ValueError("sketch does not create a closed profile region")
if node.atomic_id == "extrude_add_blind_with_hole":
resolved = [session.resolve(selector) for selector in node.selectors]
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face":
raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "profile hole selector is unresolved")
if len(faces) != 1:
raise ValueError("profile hole extrusion requires exactly one outer sketch region")
faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])]
# 3. 按特征类型生成子实体:
if node.atomic_id.startswith("extrude_"):
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
@@ -422,10 +555,18 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
# 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 extent.trim_to is None:
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))
@@ -456,11 +597,106 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
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)
session.register_body(node.feature_id, body, replay_node=node, body_members=members)
return session.result(node)
def _execute_revolve_surface(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# Surface revolve 的 profile 是单一闭合 wire。它只生成独立 shell,不能参与
# 当前实体 body 的 fuse/cut,也不能把其结果误报为新的实体 body。
sketch = session.sketches.get(str(node.sketch_id))
if sketch is None:
raise ValueError("surface revolve has no resolved sketch")
faces = session.adapter.faces_for_sketch(sketch)
if len(faces) != 1 or faces[0].inner_wires():
raise ValueError("surface revolve requires exactly one closed profile without holes")
axis = _revolve_axis(node, session)
_validate_revolve_axis_in_sketch_plane(axis, sketch)
angle = float(node.params.get("angle_deg") or 0.0)
if angle <= 0:
raise ValueError("surface revolve requires angle_deg > 0")
if bool(node.params.get("reverse")):
angle = -angle
surface_id = session.register_surface(
node.feature_id,
session.adapter.revolve_surface(faces[0].outer_wire(), angle, axis),
)
return session.result(node, include_body=False, surface_id=surface_id)
def _execute_extrude_surface(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# surfaceEntities 的曲面拉伸沿用实体特征已 lower 的距离,但始终独立登记为
# shell。它既不改变 active solid,也不以曲面参与实体 fuse/cut。
sketch = session.sketches.get(str(node.sketch_id))
if sketch is None:
raise ValueError("surface extrude has no resolved sketch")
direction = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal")
if bool(node.params.get("reverse")):
direction = vector_scale(direction, -1)
distance = float(node.params.get("distance_mm") or 0.0)
if distance <= 0:
raise ValueError("surface extrude requires distance_mm > 0")
wires = session.adapter.surface_wires_for_sketch(sketch)
surface = session.adapter.extrude_surface(wires, vector_scale(direction, distance))
reverse_distance = float(node.params.get("reverse_distance_mm") or 0.0)
if reverse_distance > 0:
opposite = session.adapter.extrude_surface(wires, vector_scale(direction, -reverse_distance))
surface = session.adapter.combine_surfaces(surface, opposite)
surface_id = session.register_surface(node.feature_id, surface)
return session.result(node, include_body=False, surface_id=surface_id)
def _combine_members(session: ExecutionSession, members: dict[str, Any]) -> Any:
body = None
for member in members.values():
body = session.adapter.combine(body, member)
if body is None:
raise ValueError("booleanBodies produced no result bodies")
return body
def _execute_boolean_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# booleanBodies 总是作用于 source feature 的明确 body 输出,不能回退为
# 当前聚合 body。这样相邻独立实体不会意外成为工具或目标。
params = node.params
target_ids = [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))
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 "")
if operation == "union":
result = session.adapter.fuse(target, tool)
elif operation == "subtract":
result = session.adapter.cut(target, tool)
elif operation == "intersect":
result = session.adapter.intersect(target, tool)
else:
raise ValueError(f"unsupported booleanBodies operation {operation!r}")
members = {
feature_id: body
for feature_id, body in session.body_members.items()
if feature_id not in set(target_ids + tool_ids)
}
members[node.feature_id] = result
if bool(params.get("keep_tools")):
members.update(tools)
session.register_body(node.feature_id, _combine_members(session, members), body_members=members)
return session.result(node)
@@ -479,6 +715,79 @@ def _execute_loft_add(node: FeaturePlanNode, session: ExecutionSession) -> Featu
return session.result(node)
def _execute_loft_add_with_cap_face(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
resolved = [session.resolve(selector) for selector in node.selectors]
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face":
raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "cap-face loft selector is unresolved")
profile_ids = node.params.get("profile_sketch_ids") or []
profiles: list[dict[str, Any]] = []
for sketch_id in profile_ids:
sketch = session.sketches.get(str(sketch_id))
if sketch is None:
raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved")
profiles.append(sketch)
solid = session.adapter.loft_with_cap_face(resolved[0].record.value, profiles)
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
return session.result(node)
def _sweep_path(node: FeaturePlanNode, session: ExecutionSession) -> Any:
# 路径是 self-contained CDSL 数据,避免重放时依赖临时草图或 source id。
path = node.params.get("path") or {}
if not isinstance(path, dict):
raise ValueError("sweep path must be an object")
plane = PlaneSpec.from_mapping(path.get("workplane") or {})
segment = path.get("segment") or {}
if not isinstance(segment, dict):
raise ValueError("sweep path segment must be an object")
kind = str(segment.get("type") or "")
if kind == "line":
local_points = [segment.get("start"), segment.get("end")]
elif kind == "bspline":
local_points = segment.get("points") or []
else:
raise ValueError(f"unsupported sweep path segment {kind!r}")
if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points):
raise ValueError("sweep path requires two-dimensional points")
def point(value: list[float]) -> Vector3:
return vector_add(
plane.origin_mm,
vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))),
)
def tangent(value: Any) -> Vector3 | None:
if value is None:
return None
if not isinstance(value, list) or len(value) != 2:
raise ValueError("sweep path tangent must contain two coordinates")
return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1])))
return session.adapter.sweep_path(
[point(value) for value in local_points],
start_tangent=tangent(segment.get("start_tangent")),
end_tangent=tangent(segment.get("end_tangent")),
parameters=[float(value) for value in segment.get("parameters") or []] or None,
)
def _execute_sweep_add(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None = None) -> FeatureResult:
profile = sketch or session.sketches.get(str(node.sketch_id))
if profile is None:
raise ValueError("sweep has no resolved profile sketch")
faces = session.adapter.faces_for_sketch(profile)
if len(faces) != 1:
raise ValueError("sweep requires exactly one closed profile region")
solid = session.adapter.sweep(
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)
return session.result(node)
def _execute_reference_plane(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 基准面特征(reference_plane)执行入口:从参数解析平面并登记为拓扑上下文。
@@ -775,17 +1084,93 @@ def _selector_edges(node: FeaturePlanNode, session: ExecutionSession, *, tangent
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed:
raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed")
def is_body_boundary(edge: Any) -> bool:
# 圆柱、圆锥等周期面会带一条仅属于自身的参数 seam。该线不是实体
# 边界;FeatureScript 以 FACE 选择倒角时不应将其当作额外的待倒角边,
# 否则连续的锥面会被错误切成两段。显式 EDGE selector 仍可表达真正的
# 单边选择,所以这里只约束由 FACE 展开的候选边。
face_count = sum(
1
for face in session.body.faces()
if any(candidate.is_same(edge) for candidate in face.edges())
)
return face_count >= 2
edges: list[Any] = []
for item in resolved:
if item.record.kind == "edge":
edges.append(item.record.value)
elif item.record.kind == "face":
edges.extend(item.record.value.edges())
edges.extend(edge for edge in item.record.value.edges() if is_body_boundary(edge))
if not edges:
raise ValueError("selectors did not resolve any edges")
return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges
def _shell_target(node: FeaturePlanNode, session: ExecutionSession) -> tuple[Any, list[Any]]:
# shell 的 remove-face selector 必须全部属于同一实体。CADFS 允许一个
# Compound 中保留多个独立 body,不能将整组 body 交给 OCC 后由内核猜测
# 应抽壳的成员。
resolved = [session.resolve(selector) for selector in node.selectors]
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed:
raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed")
records = [item.record for item in resolved if item.record is not None]
if not records or any(record.kind != "face" for record in records):
raise ValueError("shell selectors must resolve to faces")
target_ids = {record.body_id for record in records}
if len(target_ids) != 1:
raise ValueError("shell faces must belong to one target body")
target_id = next(iter(target_ids))
members = session.adapter.body_solids(session.body)
if len(members) == 1:
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]
def _replace_shell_target(session: ExecutionSession, target: Any, replacement: Any) -> Any:
# 仅替换抽壳目标实体;其他独立实体保持原样和原有相对顺序。
members = session.adapter.body_solids(session.body)
if len(members) == 1:
return replacement
replaced = False
result = None
for member in members:
if member.is_same(target):
result = session.adapter.combine(result, replacement)
replaced = True
else:
result = session.adapter.combine(result, member)
if not replaced or result is None:
raise ValueError("shell target solid is no longer part of the active body")
return result
def _execute_shell(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 抽壳特征:移除 selector 所指面,并按 CADFS thickness 向实体内部偏置。
if session.body is None:
raise ValueError("shell has no body")
thickness = float(node.params.get("thickness_mm") or 0)
if thickness <= 0:
raise ValueError("shell thickness_mm must be > 0")
target, faces = _shell_target(node, session)
result = 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)
return session.result(node)
def _execute_fillet(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。
@@ -825,13 +1210,31 @@ def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> Featur
if distance_2 is None and angle_rad is not None:
distance_2 = distance * math.tan(float(angle_rad))
# 4. 解析目标边(支持相切传播),执行倒角。
body = session.adapter.chamfer(
session.body, distance, distance_2,
_selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
)
edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation")))
diagnostics: list[RuntimeDiagnostic] = []
try:
body = session.adapter.chamfer(session.body, distance, distance_2, edges)
except ValueError as error:
# 显式 surfaceEntities 可以在后续实体上留下曲面分区边界。若标准
# OCC 倒角因环域宽度不足而拒绝,只允许在该 shell 给出同轴边界证据
# 时按原始距离构造受限倒角;没有证明时仍保留原始内核失败。
if distance_2 is not None or not session.surface_members:
raise
try:
body = session.adapter.surface_limited_chamfer(
session.body, distance, edges, list(session.surface_members.values()),
)
except ValueError:
raise error
diagnostics.append(RuntimeDiagnostic(
"chamfer_surface_limited",
"Chamfer was limited by an explicit coaxial surface boundary",
feature_id=node.feature_id,
detail={"distance_mm": distance, "surface_count": len(session.surface_members)},
))
# 5. 登记新主体并返回结果。
session.register_body(node.feature_id, body, replay_node=node)
return session.result(node)
return session.result(node, diagnostics=diagnostics)
def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any]:
@@ -1178,6 +1581,15 @@ 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")
if node.params.get("mirror_current_body"):
# CADFS SWEPT_BODY 表示被后续 feature 持续修改的同一实体。这里复制
# 当前 B-rep 再镜像并合并,不能重放其初始 additive feature,否则会
# 丢失后续 cut/fillet 并生成独立错误实体。
if session.body is None:
raise ValueError("mirror current body has no active body")
mirrored = session.adapter.mirror(session.body, resolution.record.value)
session.register_body(node.feature_id, session.adapter.fuse(session.body, mirrored), replay_node=node)
return session.result(node)
sources = session.replay_sources(node.params.get("source_feature_ids") or [])
if not sources:
raise ValueError("mirror pattern source features have no replay definitions")
@@ -1277,6 +1689,14 @@ def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle
plane[key] = _rotated_point(plane[key], axis, angle_rad)
else:
plane[key] = list(_rotated_vector(tuple(float(v) for v in plane[key]), axis, angle_rad))
path = params.get("path")
path_plane = path.get("workplane") if isinstance(path, dict) else None
if isinstance(path_plane, dict):
if path_plane.get("origin_mm"):
path_plane["origin_mm"] = _rotated_point(path_plane["origin_mm"], axis, angle_rad)
for key in ("x_dir", "y_dir", "normal"):
if path_plane.get(key):
path_plane[key] = list(_rotated_vector(tuple(float(v) for v in path_plane[key]), axis, angle_rad))
host = params.get("host_face")
host_frame = host.get("frame") if isinstance(host, dict) else None
positions_are_local = isinstance(host_frame, dict) and all(
@@ -1347,6 +1767,25 @@ def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle
return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature)
def _pattern_operation_node(node: FeaturePlanNode, operation_mode: str) -> FeaturePlanNode:
# REMOVE pattern 的实例必须沿用 source 的 profile/extent,但以 cut 而不是
# add 写入当前主体。lowering 已将初始 source 同步改写,运行时保留此处以
# 支持完整的 CDSL replay contract。
if operation_mode != "remove": return node
atomic_id = {
"extrude_add_blind": "extrude_cut_blind",
"extrude_add_two_sided": "extrude_cut_two_sided",
"revolve_add": "revolve_cut",
}.get(node.atomic_id, node.atomic_id)
if atomic_id == node.atomic_id and "cut" not in atomic_id:
raise ValueError("REMOVE pattern source is not a replayable cutting feature")
params = {key: value for key, value in node.params.items() if key != "result_mode"}
return FeaturePlanNode(
node.feature_id, atomic_id, node.name, node.depends_on, params,
node.selectors, node.sketch_id, node.declared_status, node.source_feature,
)
def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult:
# 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征
# 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。
@@ -1359,10 +1798,18 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession,
if count < 1:
raise ValueError("circular pattern pattern_count must be >= 1")
sweep_angle_deg = float(params.get("sweep_angle_deg") or 360.0)
operation_mode = str(params.get("operation_mode") or "add")
if operation_mode not in {"add", "remove"}:
raise ValueError("circular pattern operation_mode must be add or remove")
excluded = {int(value) for value in params.get("excluded_instance_indices") or []}
if any(instance < 1 or instance >= count for instance in excluded):
raise ValueError("circular pattern excluded instance is outside the generated range")
sources = session.replay_sources(params.get("source_feature_ids") or [])
if not sources:
raise ValueError("circular pattern source features have no replay definitions")
for instance in range(1, count):
if instance in excluded:
continue
# 实例 i 位于包角 sweep_angle_deg 的 i/count 处(i=0 即源特征本身)。
angle_deg = sweep_angle_deg * instance / count
angle_rad = math.radians(angle_deg)
@@ -1376,10 +1823,25 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession,
"by multiples of 180 degrees"
)
cloned = _rotated_node(source, f"{node.feature_id}.c{instance}.{source.feature_id}", axis, angle_rad, session)
cloned = _pattern_operation_node(cloned, operation_mode)
# CADFS pattern instances are copies of the source result, not
# independent `NEW` operations. Replay them through normal add
# semantics: intersecting or face-sharing instances fuse, while
# spatially separate copies remain separate solids in the result.
if cloned.params.get("result_mode") == "new_body":
cloned = FeaturePlanNode(
cloned.feature_id, cloned.atomic_id, cloned.name, cloned.depends_on,
{key: value for key, value in cloned.params.items() if key != "result_mode"},
cloned.selectors, cloned.sketch_id, cloned.declared_status, cloned.source_feature,
)
sketch = session.sketches.get(str(source.sketch_id))
execute(cloned, session, _rotated_sketch(sketch, axis, angle_rad) if sketch else None)
# 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放。
session.replay_definitions[node.feature_id] = node
# 环形阵列本身是完整 B-rep 结果的 producer。每个 replay 子特征都会更新
# active body;循环结束后必须用 pattern feature 重新登记最终快照,否则后续
# selector binding 会只保留最后一个实例的 body id,漏掉其它 COPY 实例。
if session.body is None:
raise ValueError("circular pattern produced no body")
session.register_body(node.feature_id, session.body, replay_node=node)
return session.result(node)
@@ -1418,11 +1880,30 @@ def _primary_executor(node: FeaturePlanNode, session: ExecutionSession, sketch:
return _shape_from_primary(node, session, sketch=sketch)
def _revolve_surface_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_revolve_surface(node, session)
def _extrude_surface_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_extrude_surface(node, session)
def _loft_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_loft_add(node, session)
def _loft_cap_face_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_loft_add_with_cap_face(node, session)
def _sweep_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
return _execute_sweep_add(node, session, sketch)
def _reference_plane_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_reference_plane(node, session)
@@ -1468,6 +1949,11 @@ def _chamfer_executor(node: FeaturePlanNode, session: ExecutionSession, sketch:
return _execute_chamfer(node, session)
def _shell_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_shell(node, session)
def _linear_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_linear_pattern(node, session, _execute_node)
@@ -1490,19 +1976,26 @@ EXECUTORS: dict[str, ExecutorFunction] = {
"gear_add": _gear_executor,
"rack_add": _rack_executor,
"extrude_add_blind": _primary_executor,
"extrude_add_blind_with_hole": _primary_executor,
"extrude_add_two_sided": _primary_executor,
"extrude_cut_blind": _primary_executor,
"extrude_cut_two_sided": _primary_executor,
"extrude_cut_through": _primary_executor,
"loft_add": _loft_executor,
"loft_add_with_cap_face": _loft_cap_face_executor,
"sweep_add": _sweep_executor,
"revolve_add": _primary_executor,
"revolve_cut": _primary_executor,
"revolve_surface": _revolve_surface_executor,
"extrude_surface": _extrude_surface_executor,
"hole_blind": _hole_executor,
"hole_countersink": _hole_executor,
"hole_counterbore": _hole_executor,
"hole_wizard": _hole_wizard_executor,
"fillet": _fillet_executor,
"chamfer": _chamfer_executor,
"shell": _shell_executor,
"boolean_bodies": lambda node, session, sketch: _execute_boolean_bodies(node, session),
"pattern_linear": _linear_pattern_executor,
"pattern_mirror": _mirror_pattern_executor,
"pattern_circular": _circular_pattern_executor,
@@ -1567,20 +2060,33 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
diagnostics.append(diagnostic)
if strict:
raise RuntimeExecutionError(diagnostic, list(session.selector_resolutions)) from error
if session.body is None:
raise ValueError("CDSL execution produced no body")
output = session.body
surface_geometry: dict[str, Any] | None = None
if output is None:
if not session.surface_members:
raise ValueError("CDSL execution produced no body")
# 纯曲面文档没有 active solid,但依然是可执行的 CAD 结果。只有在
# 没有实体时才将 surface members 作为 STEP 输出,混合模型继续只导出
# 实体,避免曲面意外改变既有实体比较和下游消费语义。
output = session.adapter.combine_surfaces(*session.surface_members.values())
surface_geometry = session.adapter.surface_geometry(output)
out_step.parent.mkdir(parents=True, exist_ok=True)
session.adapter.export(session.body, str(out_step))
geometry = session.adapter.body_geometry(session.body)
session.adapter.export(output, str(out_step))
geometry = session.adapter.body_geometry(session.body) if session.body is not None else surface_geometry
if geometry is None:
raise ValueError("CDSL execution produced no exportable geometry")
bbox = geometry["bbox_mm"]
return {
"engine": "cdsl_session_runtime",
"out_step": str(out_step),
"volume_mm3": float(geometry["volume_mm3"]),
"volume_mm3": float(geometry.get("volume_mm3") or 0.0),
"bbox_mm": {"min": bbox[:3], "max": bbox[3:]},
# #7 multi-body:重建结果里的独立实体数(Compound 成员数),
# 与 batch 验证的 document_truth.geometry.solid_body_count 对齐。
"solid_count": len(session.adapter.body_solids(session.body)),
"solid_count": len(session.adapter.body_solids(session.body)) if session.body is not None else 0,
"surface_count": len(session.surface_members),
"surface_face_count": int(surface_geometry["face_count"]) if surface_geometry is not None else 0,
"surface_area_mm2": float(surface_geometry["area_mm2"]) if surface_geometry is not None else 0.0,
"feature_results": [result.as_dict() for result in session.results.values()],
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics],
"topology_records": [record.public_dict() for record in session.topology.records()],
+37 -11
View File
@@ -656,6 +656,7 @@ class FeatureResult:
atomic_id: str
status: str
body_id: str | None = None
surface_id: str | None = None
context: PlaneSpec | AxisSpec | None = None
replay_definition: dict[str, Any] | None = None
diagnostics: list[RuntimeDiagnostic] = field(default_factory=list)
@@ -669,6 +670,8 @@ class FeatureResult:
}
if self.body_id is not None:
output["body_id"] = self.body_id
if self.surface_id is not None:
output["surface_id"] = self.surface_id
if self.context is not None:
output["context"] = self.context.as_dict()
if self.replay_definition is not None:
@@ -780,6 +783,12 @@ class TopologyRegistry:
def replace_body_topology(
self, feature_id: str, body_id: str, records: Iterable[TopologyRecord],
*, active_body_id: str | None = None,
) -> None:
self.replace_body_topologies(feature_id, [(body_id, records)], active_body_id=active_body_id)
def replace_body_topologies(
self, feature_id: str, bodies: Iterable[tuple[str, Iterable[TopologyRecord]]],
*, active_body_id: str | None = None,
) -> None:
"""Record a fresh B-rep snapshot after a feature mutates the body.
@@ -803,15 +812,31 @@ class TopologyRegistry:
or record.body_id.startswith(f"{self._active_body_id}:")
)
]
records = list(records)
# 同一 source feature 的 pattern copy 可以产生完全相同的几何面。它们
# 必须保留为多个实例,不能在跨 body 的全局 predecessor 匹配中互相消费。
# pattern 的 Compound 成员顺序是稳定的:已有实例以同一 member index
# 延续,新增实例只会出现在末尾。按该 index 限定后继匹配。
current = [(body_id, list(records)) for body_id, records in bodies]
previous_member_ids = {
suffix for record in previous
for suffix in [str(record.body_id).rsplit(":", 1)[-1]]
if suffix.isdigit()
}
use_member_indexes = len(current) > 1 and previous_member_ids
consumed_predecessors: set[str] = set()
for record in records:
predecessor = self._unique_equivalent_predecessor(record, previous, consumed_predecessors)
owners = predecessor.owners if predecessor is not None else (feature_id,)
if predecessor is not None:
consumed_predecessors.add(predecessor.record_id)
self.register(
TopologyRecord(
registered: list[TopologyRecord] = []
for body_id, records in current:
member_id = str(body_id).rsplit(":", 1)[-1]
local_predecessors = [
record for record in 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)
owners = predecessor.owners if predecessor is not None else (feature_id,)
if predecessor is not None:
consumed_predecessors.add(predecessor.record_id)
registered.append(TopologyRecord(
record_id=record.record_id,
kind=record.kind,
feature_id=feature_id,
@@ -819,8 +844,9 @@ class TopologyRegistry:
geometry=dict(record.geometry),
value=record.value,
owner_feature_ids=owners,
)
)
))
for record in registered:
self.register(record)
# #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续
# selector 的 stable_id 引用解析到 active body 内的新形态。多条演化
# 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段
@@ -832,7 +858,7 @@ class TopologyRegistry:
candidates = sorted(
(
(self._evolved_drift(prior, record), record.record_id)
for record in records if self._evolved_equivalent(prior, record)
for record in registered if self._evolved_equivalent(prior, record)
),
key=lambda item: item[0],
)
@@ -87,7 +87,8 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
deferred.append(fid)
for index, selector in enumerate(feature.get("selectors") or []):
owner = selector.get("owner_feature_id")
if owner is not None and owner not in feature_ids:
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 feature.get("unresolved"):
unresolved.append({"feature_id": fid, "reasons": list(feature["unresolved"])})
+123 -7
View File
@@ -1,7 +1,7 @@
"""Core CDSL sketch resolver.
The runtime accepts only direct geometric descriptions: circles, straight-edge
polygons, and closed analytic line/arc/circle/B-spline contours. Semantic
polygons, and closed analytic line/arc/circle/ellipse/B-spline contours. Semantic
shapes and historical profile macros belong to the importer compatibility
layer and must be lowered before this module is invoked.
"""
@@ -58,6 +58,12 @@ def _to_3d(workplane: _Ctx, u: float, v: float) -> list[float]:
return [origin[0] + u * x_dir[0] + v * y_dir[0], origin[1] + u * x_dir[1] + v * y_dir[1], origin[2] + u * x_dir[2] + v * y_dir[2]]
def _to_3d_vector(workplane: _Ctx, u: float, v: float) -> list[float]:
origin = _to_3d(workplane, 0.0, 0.0)
target = _to_3d(workplane, u, v)
return [target[index] - origin[index] for index in range(3)]
def _dot(left: Iterable[float], right: Iterable[float]) -> float:
return sum(a * b for a, b in zip(left, right))
@@ -81,11 +87,19 @@ def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]:
if edge["type"] == "arc":
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
output["normal"] = list(normal)
elif edge["type"] == "ellipse":
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
output["major_axis_mm"] = _to_3d_vector(workplane, edge["major_axis_mm"][0], edge["major_axis_mm"][1])
output["normal"] = list(normal)
elif edge["type"] == "bspline":
output["points_mm"] = [
_to_3d(workplane, point[0], point[1])
for point in edge["points_mm"]
]
for key in ("start_tangent_mm", "end_tangent_mm"):
if key in edge:
tangent = edge[key]
output[key] = _to_3d_vector(workplane, tangent[0], tangent[1])
transformed.append(output)
return transformed
@@ -96,8 +110,9 @@ def _gen_circle(profile: _Ctx, _: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
if radius <= 0:
raise ValueError("circle radius must be > 0")
cx, cy = float(center[0]), float(center[1])
points = [[cx + radius, cy, 0.0], [cx, cy + radius, 0.0], [cx - radius, cy, 0.0], [cx, cy - radius, 0.0], [cx + radius, cy, 0.0]]
return [_circle([cx, cy], radius)], [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius) for index in range(4)]
# 直接圆 profile 必须保留为一条完整的圆边。若拆成四条圆弧,后续按边
# 选择的圆角/倒角会把同一拓扑圆误解为四个独立目标。
return [_circle([cx, cy], radius)], []
def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
@@ -118,6 +133,19 @@ def _distance(left: list[float], right: list[float]) -> float:
return math.hypot(float(left[0]) - float(right[0]), float(left[1]) - float(right[1]))
def _centripetal_parameters(points: list[list[float]], periodic: bool) -> list[float]:
pairs = list(zip(points, points[1:]))
if periodic:
pairs.append((points[-1], points[0]))
parameters = [0.0]
for start, end in pairs:
distance = math.dist(start, end)
if distance <= _TOLERANCE_MM:
raise ValueError("analytic_contours: centripetal bspline has coincident interpolation points")
parameters.append(parameters[-1] + math.sqrt(distance))
return parameters
def _reverse(edge: _Ctx) -> _Ctx:
output = deepcopy(edge)
output["start_mm"], output["end_mm"] = output["end_mm"], output["start_mm"]
@@ -125,6 +153,16 @@ def _reverse(edge: _Ctx) -> _Ctx:
output["clockwise"] = not bool(output["clockwise"])
if output.get("type") == "bspline":
output["points_mm"] = list(reversed(output["points_mm"]))
parameters = output.get("parameters")
if parameters is not None:
final_parameter = float(parameters[-1])
output["parameters"] = [final_parameter - float(value) for value in reversed(parameters)]
start_tangent = output.pop("start_tangent_mm", None)
end_tangent = output.pop("end_tangent_mm", None)
if end_tangent is not None:
output["start_tangent_mm"] = [-float(value) for value in end_tangent]
if start_tangent is not None:
output["end_tangent_mm"] = [-float(value) for value in start_tangent]
return output
@@ -174,6 +212,27 @@ def _circle_edges(segment: _Ctx) -> list[_Ctx]:
return [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)]
def _ellipse_edges(segment: _Ctx) -> list[_Ctx]:
center = segment.get("center") or [0.0, 0.0]
major_radius = float(segment.get("major_radius_mm") or 0.0)
minor_radius = float(segment.get("minor_radius_mm") or 0.0)
major_axis = segment.get("major_axis") or []
if major_radius <= 0 or minor_radius <= 0:
raise ValueError("analytic_contours: ellipse radii must be > 0")
if len(major_axis) < 2:
raise ValueError("analytic_contours: ellipse major_axis must have two components")
axis_length = math.hypot(float(major_axis[0]), float(major_axis[1]))
if axis_length <= _TOLERANCE_MM:
raise ValueError("analytic_contours: ellipse major_axis is degenerate")
cx, cy = float(center[0]), float(center[1])
ux, uy = float(major_axis[0]) / axis_length, float(major_axis[1]) / axis_length
start = [cx + major_radius * ux, cy + major_radius * uy, 0.0]
return [{
"type": "ellipse", "start_mm": start, "end_mm": list(start), "center_mm": [cx, cy, 0.0],
"major_axis_mm": [ux, uy, 0.0], "major_radius_mm": major_radius, "minor_radius_mm": minor_radius,
}]
def _segment_edges(segment: _Ctx) -> list[_Ctx]:
kind = segment.get("type")
if kind == "line":
@@ -182,17 +241,55 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
if kind == "circle":
return _circle_edges(segment)
if kind == "ellipse":
return _ellipse_edges(segment)
if kind == "bspline":
points = segment.get("points") or []
if len(points) < 3:
raise ValueError("analytic_contours: bspline needs at least 3 interpolation points")
converted = [_point(point) for point in points]
return [{
periodic = bool(segment.get("periodic"))
if periodic:
if _distance(converted[0], converted[-1]) > _TOLERANCE_MM:
raise ValueError("analytic_contours: periodic bspline endpoints do not meet")
# The duplicated closing interpolation point describes topology,
# not an additional periodic interpolation constraint. OCC's
# periodic interpolator receives each unique point exactly once.
interpolation_points = converted[:-1]
else:
interpolation_points = converted
parameterization = segment.get("parameterization")
if parameterization not in {None, "chord", "centripetal"}:
raise ValueError(f"analytic_contours: unsupported bspline parameterization {parameterization!r}")
parameters = segment.get("parameters")
if parameters is not None:
expected_count = len(interpolation_points) + int(periodic)
if len(parameters) != expected_count:
raise ValueError("analytic_contours: bspline parameter count does not match interpolation points")
parameters = [float(value) for value in parameters]
if not all(math.isfinite(value) for value in parameters):
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")
output: _Ctx = {
"type": "bspline",
"start_mm": converted[0],
"end_mm": converted[-1],
"points_mm": converted,
}]
"points_mm": interpolation_points,
"periodic": periodic,
**({"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:
if periodic:
raise ValueError("analytic_contours: periodic bspline does not accept endpoint tangents")
output["start_tangent_mm"] = _point(start_tangent)
output["end_tangent_mm"] = _point(end_tangent)
return [output]
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
@@ -204,6 +301,25 @@ def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]:
if edge.get("type") == "bspline":
points.extend((float(point[0]), float(point[1])) for point in edge["points_mm"][1:-1])
continue
if edge.get("type") == "ellipse":
center, axis = edge["center_mm"], edge["major_axis_mm"]
major_radius = float(edge["major_radius_mm"])
minor_radius = float(edge["minor_radius_mm"])
normal = edge.get("normal") or [0.0, 0.0, 1.0]
axis_length = math.sqrt(sum(float(value) * float(value) for value in axis))
normal_length = math.sqrt(sum(float(value) * float(value) for value in normal))
if axis_length <= _TOLERANCE_MM or normal_length <= _TOLERANCE_MM:
raise ValueError("analytic_contours: ellipse axis is degenerate")
x_axis = [float(value) / axis_length for value in axis]
z_axis = [float(value) / normal_length for value in normal]
y_axis = [z_axis[1] * x_axis[2] - z_axis[2] * x_axis[1], z_axis[2] * x_axis[0] - z_axis[0] * x_axis[2], z_axis[0] * x_axis[1] - z_axis[1] * x_axis[0]]
for step in range(1, 8):
angle = math.tau * step / 8
points.append((
float(center[0]) + major_radius * math.cos(angle) * x_axis[0] + minor_radius * math.sin(angle) * y_axis[0],
float(center[1]) + major_radius * math.cos(angle) * x_axis[1] + minor_radius * math.sin(angle) * y_axis[1],
))
continue
if edge.get("type") != "arc":
continue
center, end = edge["center_mm"], edge["end_mm"]
@@ -328,7 +444,7 @@ SHAPE_GENERATORS = CORE_SHAPE_GENERATORS
SHAPE_CAPABILITIES: dict[str, _Ctx] = {
"circle": {"detectable": True, "arity": "circle", "description": "single circular contour"},
"polygon": {"detectable": True, "arity": "polygon", "description": "closed straight-edge contour"},
"analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, and circle contours"},
"analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, circle, ellipse and B-spline contours"},
}