解决合并冲突
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
))
|
||||
|
||||
@@ -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"}}}},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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"])})
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user