解决合并冲突

This commit is contained in:
2026-09-08 13:58:58 +08:00
28 changed files with 5529 additions and 336 deletions
+587 -67
View File
@@ -5,7 +5,21 @@ from __future__ import annotations
import math import math
from typing import Any, Iterable 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_bend import build_bend_solid
from .parametric_gears import build_gear_solid, build_rack_solid from .parametric_gears import build_gear_solid, build_rack_solid
@@ -67,14 +81,34 @@ class Build123dGeometryAdapter:
@staticmethod @staticmethod
def _wire(edges: list[dict[str, Any]]) -> Wire: def _wire(edges: list[dict[str, Any]]) -> Wire:
# 将边字典列表(直线/圆弧/插值 B 样条)组装成 build123d 的 Wire 线框。 # 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。
built: list[Edge] = [] built: list[Edge] = []
for edge in edges: for edge in edges:
if edge.get("type") == "bspline": if edge.get("type") == "bspline":
points = [_vector(point) for point in edge.get("points_mm") or []] points = [_vector(point) for point in edge.get("points_mm") or []]
if len(points) < 3: if len(points) < 3:
raise ValueError("bspline contour edge needs at least 3 points") 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 continue
start = _vector(edge["start_mm"]) start = _vector(edge["start_mm"])
end = _vector(edge["end_mm"]) end = _vector(edge["end_mm"])
@@ -135,6 +169,30 @@ class Build123dGeometryAdapter:
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Face]: 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(外轮廓 + 孔洞列表)。 # 1. 优先使用预计算的轮廓区域 contour_regions_mm(外轮廓 + 孔洞列表)。
regions = sketch.get("contour_regions_mm") or [] regions = sketch.get("contour_regions_mm") or []
if regions: if regions:
@@ -156,6 +214,20 @@ class Build123dGeometryAdapter:
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
return self._faces_from_circles(sketch.get("entities") or [], plane) 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: def loft(self, sketches: list[dict[str, Any]]) -> Solid:
"""由多条简单闭合草图轮廓生成实体放样。""" """由多条简单闭合草图轮廓生成实体放样。"""
wires: list[Wire] = [] wires: list[Wire] = []
@@ -173,6 +245,20 @@ class Build123dGeometryAdapter:
raise ValueError("loft requires at least two profile sketches") raise ValueError("loft requires at least two profile sketches")
return Solid.make_loft(wires) 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 @staticmethod
def _coerce_single_or_compound(result: Any, *, empty_error: str | None = None) -> Any: def _coerce_single_or_compound(result: Any, *, empty_error: str | None = None) -> Any:
"""规整一次布尔结果:None/空视为失败(可选报错),多成员合并为 Compound。""" """规整一次布尔结果:None/空视为失败(可选报错),多成员合并为 Compound。"""
@@ -189,17 +275,44 @@ class Build123dGeometryAdapter:
return None return None
if len(members) == 1: if len(members) == 1:
return members[0] return members[0]
# build123d 0.11 的有效运行时合并 API 是 Compound.make_composite(多体域 # ``Compound`` constructor accepts an iterable on both supported
# 合并为 Compound/Part);Compound.make_compound 在类型桩与运行时均不存在, # Build123d runtimes. ``make_composite`` is not available in every
# 此前的调用在多成员 ShapeList 下会 AttributeError。类型桩未声明 # deployed version, so using it here breaks valid multi-solid cuts.
# make_composite,保留宽泛类型桩噪音抑制。 return Compound(members)
return Compound.make_composite(members)
@staticmethod @staticmethod
def extrude(face: Face, direction: Vector3) -> Solid: def extrude(face: Face, direction: Vector3) -> Solid:
# 沿给定方向向量拉伸一个面,生成实体。 # 沿给定方向向量拉伸一个面,生成实体。
return Solid.extrude(face, _vector(direction)) 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 @staticmethod
def extrude_trimmed(face: Face, target: Any, direction: Vector3) -> Any: def extrude_trimmed(face: Face, target: Any, direction: Vector3) -> Any:
"""Extrude the profile to the target face, trimming unreached regions. """Extrude the profile to the target face, trimming unreached regions.
@@ -217,6 +330,7 @@ class Build123dGeometryAdapter:
# through_next 的 target 是当前主体,必须先从其面中选出实际命中的 # through_next 的 target 是当前主体,必须先从其面中选出实际命中的
# 下一终止面,不能把整个 body 当作待拉伸的 Face。 # 下一终止面,不能把整个 body 当作待拉伸的 Face。
unit = _vector(direction).normalized() unit = _vector(direction).normalized()
target_body = target if not isinstance(target, Face) else None
points = Build123dGeometryAdapter.profile_sample_points(face) points = Build123dGeometryAdapter.profile_sample_points(face)
targets = [target] if isinstance(target, Face) else list(target.faces()) targets = [target] if isinstance(target, Face) else list(target.faces())
candidates = [] candidates = []
@@ -231,15 +345,28 @@ class Build123dGeometryAdapter:
# 时取最近的正向交点,确保相邻面交界处的选择稳定。 # 时取最近的正向交点,确保相邻面交界处的选择稳定。
_, _, target, distances = max(candidates, key=lambda item: (item[0], -item[1])) _, _, target, distances = max(candidates, key=lambda item: (item[0], -item[1]))
margin = max(distances) + 2.0 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), # build123d 的布尔交方法名是 intersect(不是 OCC 的 common),
# 且多实体结果返回 ShapeList,需要规整为单个 Solid / Compound。 # 且多实体结果返回 ShapeList,需要规整为单个 Solid / Compound。
pierced = Solid.extrude(face, unit * margin) pierced = Solid.extrude(face, unit * margin)
slab = Solid.extrude(target, -unit * margin) candidates = []
trimmed = pierced.intersect(slab) for slab_direction in (-unit * margin, unit * margin):
return Build123dGeometryAdapter._coerce_single_or_compound( trimmed = Build123dGeometryAdapter._coerce_single_or_compound(pierced.intersect(Solid.extrude(target, slab_direction)))
trimmed, empty_error="extent target produced an empty trimmed solid", 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 @staticmethod
def body_center(body: Any) -> Vector3: def body_center(body: Any) -> Vector3:
@@ -265,6 +392,20 @@ class Build123dGeometryAdapter:
# 提取顶点的三维坐标元组。 # 提取顶点的三维坐标元组。
return (float(vertex.X), float(vertex.Y), float(vertex.Z)) 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 @staticmethod
def profile_sample_points(face: Face) -> list[Vector]: def profile_sample_points(face: Face) -> list[Vector]:
"""Sample a profile face before a selector-dependent termination. """Sample a profile face before a selector-dependent termination.
@@ -287,6 +428,51 @@ class Build123dGeometryAdapter:
unique.append(point) unique.append(point)
return unique 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 @staticmethod
def _forward_intersection_distance(target: Any, point: Vector, direction: Vector) -> float | None: def _forward_intersection_distance(target: Any, point: Vector, direction: Vector) -> float | None:
# 从 point 沿 direction 发一条射线,求与目标的第一个正向交点距离。 # 从 point 沿 direction 发一条射线,求与目标的第一个正向交点距离。
@@ -325,16 +511,120 @@ class Build123dGeometryAdapter:
# 绕给定轴将面旋转指定角度,生成回转实体。 # 绕给定轴将面旋转指定角度,生成回转实体。
return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis)) 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 @staticmethod
def fuse(body: Any | None, solid: Any) -> Any: def fuse(body: Any | None, solid: Any) -> Any:
# 布尔并:没有既有主体时,直接以该实体作为新主体。 # 布尔并:没有既有主体时,直接以该实体作为新主体。
# 实参类型放宽为 Any:build123d 的布尔结果可能是 Solid 或 Compound。 # 实参类型放宽为 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 @staticmethod
def cut(body: Any, tool: Any) -> Any: 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 @staticmethod
def sphere(radius_mm: float, center_mm: Vector3) -> Solid: def sphere(radius_mm: float, center_mm: Vector3) -> Solid:
@@ -533,7 +823,179 @@ class Build123dGeometryAdapter:
@staticmethod @staticmethod
def chamfer(body: Any, distance_mm: float, distance_2_mm: float | None, edges: Iterable[Edge], face: Face | None = None) -> Any: def chamfer(body: Any, distance_mm: float, distance_2_mm: float | None, edges: Iterable[Edge], face: Face | None = None) -> Any:
# 对指定边做倒角;distance_2_mm 提供时形成非对称倒角。 # 对指定边做倒角;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 @staticmethod
def sweep( def sweep(
@@ -556,15 +1018,36 @@ class Build123dGeometryAdapter:
} }
if transition is not None: if transition is not None:
sweep_options["transition"] = transition 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 @staticmethod
def sweep_path(points: Iterable[Vector3]) -> Wire: def sweep_path(
# 把三维点列连成折线 Wire,作为扫掠路径的通用构造入口。 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] vertices = [_vector(point) for point in points]
if len(vertices) < 2: if len(vertices) < 2:
raise ValueError("sweep path needs at least two points") 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 @staticmethod
def helix_path( def helix_path(
@@ -644,8 +1127,30 @@ class Build123dGeometryAdapter:
@staticmethod @staticmethod
def export(body: Any, path: str) -> None: def export(body: Any, path: str) -> None:
# 将主体导出为 STEP 文件。 # OCCT 对大于 90 度的 SURFACE_OF_REVOLUTION 在 STEP round-trip 时会
export_step(body, path) # 丢失部分参数域,导入后该侧面退化为一条母线。只对包含这类曲面的
# 独立实体按 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 @staticmethod
def body_solids(body: Any) -> list[Any]: def body_solids(body: Any) -> list[Any]:
@@ -671,6 +1176,16 @@ class Build123dGeometryAdapter:
"solid_count": len(solids), "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 @staticmethod
def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: 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"}: elif geometry["surface_type"] in {"cylinder", "cone"}:
axis = face.axis_of_rotation axis = face.axis_of_rotation
if axis is None: if axis is None:
raise ValueError("rotational face is missing an axis") # Build123d can omit this optional OCC property for valid
direction = axis.direction # swept rotational faces. Keep their generic B-rep record
origin = axis.position # so a selector-free workflow remains executable; do not
geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z] # invent axis/radius evidence for an axis-based selector.
geometry["axis_direction"] = [direction.X, direction.Y, direction.Z] pass
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: 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_edge_indexes.append(set(boundary_edge_indexes))
face_geometries.append(geometry) face_geometries.append(geometry)
records.append(TopologyRecord( records.append(TopologyRecord(
+103 -24
View File
@@ -16,28 +16,28 @@ from .runtime_types import CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeD
from .operation_contracts import materialized_feature_contracts from .operation_contracts import materialized_feature_contracts
_SELECTOR_REQUIRED = frozenset({"fillet", "chamfer"}) _SELECTOR_REQUIRED = frozenset({"extrude_add_blind_with_hole", "loft_add_with_cap_face", "fillet", "chamfer", "shell"})
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_") _SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_")
# 开放轮廓(closed=false / role=open)只有"刀具截面补槽口边闭合后作切除"的 # 开放轮廓(closed=false / role=open)只有"刀具截面补槽口边闭合后作切除"的
# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。 # 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。
_OPEN_PROFILE_ATOMICS = frozenset({"extrude_cut_blind", "extrude_cut_through"}) _OPEN_PROFILE_ATOMICS = frozenset({"extrude_cut_blind", "extrude_cut_through"})
_PRIMARY_ATOMICS = frozenset({ _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", "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_counterbore", "sphere_add", "box_add", "cylinder_add",
}) })
_HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"}) _HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"})
_ACTIVE_BODY_REQUIRED = frozenset({ _ACTIVE_BODY_REQUIRED = frozenset({
"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "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 型特征:必须在已有主体(宿主)上做布尔差,不能凭空 # thread_cut 是 cut 型特征:必须在已有主体(宿主)上做布尔差,不能凭空
# 造实体;无宿主时按 active_body 前置阻止而非让 executor 在 None 上崩溃。 # 造实体;无宿主时按 active_body 前置阻止而非让 executor 在 None 上崩溃。
"thread_cut", "thread_cut",
}) })
_BODY_MUTATING_ATOMICS = frozenset({ _BODY_MUTATING_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_cut_through", "loft_add", "extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_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", "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 "") 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"): if feature.get("sketch_id") is not None and (contracts.get(atomic_id) or {}).get("requires_sketch"):
required.add(str(feature["sketch_id"])) 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 (): for sketch_id in (feature.get("params") or {}).get("profile_sketch_ids") or ():
required.add(str(sketch_id)) required.add(str(sketch_id))
return frozenset(required) return frozenset(required)
@@ -313,12 +313,13 @@ class CapabilityAnalyzer:
sketch_id=node.sketch_id, sketch_id=node.sketch_id,
atomic_id=node.atomic_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") 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( blockers.append(self._blocker(
node.feature_id, "invalid_loft_profiles", 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): elif len({str(sketch_id) for sketch_id in profile_ids}) != len(profile_ids):
blockers.append(self._blocker( blockers.append(self._blocker(
@@ -361,20 +362,90 @@ class CapabilityAnalyzer:
"Loft currently requires exactly one outer profile without holes", "Loft currently requires exactly one outer profile without holes",
sketch_id=sketch_id, sketch_id=sketch_id,
)) ))
if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES): if node.atomic_id == "sweep_add":
# #2 draftextrudeParams.draft 在 cdsl_schema.json 中被允许, path = params.get("path")
# 但 runtime 的拉伸执行器(build123d Solid.extrude)没有锥形 segment = path.get("segment") if isinstance(path, dict) else None
# 拉伸能力,人读契约 profile_schema.json 也未声明该参数。 kind = segment.get("type") if isinstance(segment, dict) else None
# 若 importer 把 SolidWorks 的 draft_angle_rad 写进 CDSL if kind not in {"line", "bspline"}:
# 当前 runtime 会静默产出无拔模角的直壁实体。这里把它从
# "静默忽略"改为"显式拒绝"(与 unsupported_extent 同模式)。
if params.get("draft"):
blockers.append(self._blocker( blockers.append(self._blocker(
node.feature_id, "unsupported_draft", node.feature_id, "unsupported_sweep_path",
"Extrude draft/taper is not implemented; the runtime would silently ignore it", "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_condition = params.get("end_condition") or {"type": "blind"}
end_type = end_condition.get("type") 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}") required.append(f"extent:{end_type}")
if end_type not in _SUPPORTED_EXTENTS: 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)) 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: 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")) 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: if node.atomic_id in _HOLE_ATOMICS:
required.append("selector:host_face") required.append("selector:host_face")
if not params.get("host_face"): if not params.get("host_face"):
@@ -529,16 +607,17 @@ class CapabilityAnalyzer:
if node.atomic_id in _BODY_MUTATING_ATOMICS: if node.atomic_id in _BODY_MUTATING_ATOMICS:
body_available = True body_available = True
body_producers = { body_producers = {
"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_cut_through", "loft_add", "extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "boolean_bodies",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add", "revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add", "bend_add", "gear_add", "rack_add", "thread_add", "bend_add", "gear_add", "rack_add",
# thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由 # thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由
# active_body 前置阻止,文档含该类特征即视为携带可执行几何。 # active_body 前置阻止,文档含该类特征即视为携带可执行几何。
"thread_cut", "thread_cut",
} }
surface_producers = {"extrude_surface", "revolve_surface"}
document_blockers: list[RuntimeDiagnostic] = [] 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( document_blockers.append(RuntimeDiagnostic(
"no_solid_feature", "CDSL contains no feature capable of creating a solid body", "no_solid_feature", "CDSL contains no feature capable of creating a solid body",
)) ))
+101 -9
View File
@@ -85,7 +85,8 @@
"properties": { "properties": {
"type": {"type": "string", "minLength": 1}, "type": {"type": "string", "minLength": 1},
"solidworks_code": {"type": "integer"}, "solidworks_code": {"type": "integer"},
"reference": {"$ref": "#/$defs/selectorRef"} "reference": {"$ref": "#/$defs/selectorRef"},
"offset_mm": {"type": "number", "minimum": 0}
}, },
"required": ["type", "solidworks_code"], "required": ["type", "solidworks_code"],
"additionalProperties": false "additionalProperties": false
@@ -98,10 +99,16 @@
}, },
"extrudeParams": { "extrudeParams": {
"type": "object", "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"], "required": ["distance_mm"],
"additionalProperties": false "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": { "extrudeCutThroughParams": {
"type": "object", "type": "object",
"properties": {"reverse": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}}, "properties": {"reverse": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}},
@@ -122,12 +129,57 @@
"required": ["profile_sketch_ids"], "required": ["profile_sketch_ids"],
"additionalProperties": false "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": { "revolveParams": {
"type": "object", "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"], "required": ["angle_deg", "axis"],
"additionalProperties": false "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": { "sphereParams": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -314,10 +366,27 @@
}, },
"chamferParams": { "chamferParams": {
"type": "object", "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"], "required": ["distance_mm"],
"additionalProperties": false "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": { "linearPatternParams": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -336,7 +405,8 @@
"type": "object", "type": "object",
"properties": { "properties": {
"source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, "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"], "required": ["source_feature_ids", "mirror_plane"],
"additionalProperties": false "additionalProperties": false
@@ -347,7 +417,9 @@
"source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, "source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"axis": {"$ref": "#/$defs/axis"}, "axis": {"$ref": "#/$defs/axis"},
"pattern_count": {"type": "integer", "minimum": 1}, "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"], "required": ["source_feature_ids", "axis", "pattern_count"],
"additionalProperties": false "additionalProperties": false
@@ -395,10 +467,14 @@
"properties": { "properties": {
"kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]}, "kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]},
"stable_id": {"type": "string", "minLength": 1}, "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"}, "geometry": {"type": "object"},
"source": {"enum": ["solidworks", "inferred_from_step", "runtime_snapshot", "viewer_selection"]}, "source": {"enum": ["solidworks", "inferred_from_step", "runtime_snapshot", "viewer_selection"]},
"snapshot_id": {"type": "string", "minLength": 1}, "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} "confidence": {"type": "number", "minimum": 0, "maximum": 1}
}, },
"required": ["kind", "stable_id", "source", "confidence"], "required": ["kind", "stable_id", "source", "confidence"],
@@ -407,19 +483,28 @@
"analyticSegment": { "analyticSegment": {
"type": "object", "type": "object",
"properties": { "properties": {
"type": {"enum": ["line", "arc", "circle", "bspline"]}, "type": {"enum": ["line", "arc", "circle", "ellipse", "bspline"]},
"start": {"$ref": "#/$defs/point2"}, "start": {"$ref": "#/$defs/point2"},
"end": {"$ref": "#/$defs/point2"}, "end": {"$ref": "#/$defs/point2"},
"center": {"$ref": "#/$defs/point2"}, "center": {"$ref": "#/$defs/point2"},
"radius_mm": {"$ref": "#/$defs/positive"}, "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"}}, "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"], "required": ["type"],
"allOf": [ "allOf": [
{"if": {"properties": {"type": {"const": "line"}}}, "then": {"required": ["start", "end"]}}, {"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": "arc"}}}, "then": {"required": ["start", "end", "center", "radius_mm"]}},
{"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}}, {"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}},
{"if": {"properties": {"type": {"const": "ellipse"}}}, "then": {"required": ["center", "major_radius_mm", "minor_radius_mm", "major_axis"]}},
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["start", "end", "points"]}} {"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["start", "end", "points"]}}
], ],
"additionalProperties": false "additionalProperties": false
@@ -481,13 +566,18 @@
"additionalProperties": false, "additionalProperties": false,
"allOf": [ "allOf": [
{"if": {"properties": {"atomic_id": {"const": "extrude_add_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}}, {"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_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_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_through"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeCutThroughParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_two_sided"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "extrude_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"}}}, "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_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_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": "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": "box_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/boxParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "cylinder_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/cylinderParams"}}}}, {"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": "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": "fillet"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/filletParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "chamfer"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/chamferParams"}}}}, {"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_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_mirror"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/mirrorPatternParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "pattern_circular"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/circularPatternParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "pattern_circular"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/circularPatternParams"}}}},
+14 -7
View File
@@ -6,14 +6,19 @@
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.", "coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours"], "runtime_supported_profiles": ["circle", "polygon", "analytic_contours"],
"operation_contracts": { "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"]}, "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_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_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"]}, "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_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_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_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"]}, "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"] "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"]}, "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_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_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.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_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": { "profiles": {
"circle": { "circle": {
@@ -76,7 +83,7 @@
"constraints": ["vertices contains at least three [u, v] points"] "constraints": ["vertices contains at least three [u, v] points"]
}, },
"analytic_contours": { "analytic_contours": {
"summary": "Closed executable line, arc, circle and interpolation B-spline contours. Executable B-splines preserve ordered interpolation points and must be closed; imported construction B-splines remain non-executable audit geometry." "summary": "Closed executable line, arc, circle, ellipse and interpolation B-spline contours. An ellipse retains its local center, radii and major-axis direction. Executable B-splines preserve ordered interpolation points; periodic contours repeat their first point only to state closure and are interpolated from unique points. B-splines may explicitly use chord or centripetal parameterization; CADFS closed skFitSpline uses centripetal. Imported construction B-splines remain non-executable audit geometry."
} }
} }
} }
+541 -35
View File
@@ -20,13 +20,13 @@ from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
ALL_ATOMIC_IDS = frozenset({ ALL_ATOMIC_IDS = 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", "loft_add", "extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add",
"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_counterbore", "sphere_add", "box_add", "cylinder_add",
"reference_plane", "reference_axis", "reference_plane", "reference_axis",
"hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror",
"pattern_circular", "pattern_circular", "boolean_bodies",
"thread_add", "thread_cut", "thread_add", "thread_cut",
"bend_add", "bend_add",
"gear_add", "rack_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 topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ...
def body_solids(self, body: Any) -> list[Any]: ... def body_solids(self, body: Any) -> list[Any]: ...
def body_geometry(self, body: Any) -> dict[str, 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 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(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(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 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(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 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 cut(self, body: Any, tool: Any) -> Any: ...
def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ... def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ...
def thread_solid(self, spec: ThreadSpec) -> Any: ... def thread_solid(self, spec: ThreadSpec) -> Any: ...
@@ -99,11 +111,16 @@ class GeometryAdapter(Protocol):
def body_center(self, body: Any) -> Vector3: ... def body_center(self, body: Any) -> Vector3: ...
def body_span(self, body: Any, direction: Vector3) -> float: ... def body_span(self, body: Any, direction: Vector3) -> float: ...
def vertex_coordinates(self, vertex: Any) -> Vector3: ... 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_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 uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ...
def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ... def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ...
def tangent_edges(self, body: Any, seeds: list[Any]) -> list[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 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: ... def export(self, body: Any, path: str) -> None: ...
@@ -117,27 +134,39 @@ class ExecutionSession:
body_id: str | None = None body_id: str | None = None
results: dict[str, FeatureResult] = field(default_factory=dict) results: dict[str, FeatureResult] = field(default_factory=dict)
replay_definitions: dict[str, FeaturePlanNode] = 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) selector_resolutions: list[dict[str, Any]] = field(default_factory=list)
active_feature_id: str = "" 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(多个独立实体,例如两个不相交的 # #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id" # 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id"
# 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标 # 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标
# 实体;单体保持 body:{feature}(与历史行为完全一致)。 # 实体;单体保持 body:{feature}(与历史行为完全一致)。
self.body = body self.body = body
self.body_id = f"body:{feature_id}" 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) solids = self.adapter.body_solids(body)
if len(solids) <= 1: if len(solids) <= 1:
self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id)) self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id))
else: else:
for index, solid in enumerate(solids): # 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让
member_id = f"{self.body_id}:{index}" # 已登记的本轮成员成为下一个成员的 predecessor,进而把 pattern copy
self.topology.replace_body_topology( # 的 owner 错误转移到相邻实例。必须原子替换整个多 body 拓扑快照。
feature_id, member_id, members = [
self.adapter.topology_records(solid, feature_id, member_id), (member_id, self.adapter.topology_records(solid, feature_id, member_id))
active_body_id=self.body_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( self.topology.register(TopologyRecord(
record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id, 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,), 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: if replay_node is not None:
self.replay_definitions[feature_id] = replay_node self.replay_definitions[feature_id] = replay_node
def resolve(self, selector: dict[str, Any]) -> SelectorResolution: def register_surface(self, feature_id: str, surface: Any) -> str:
resolution = self.topology.resolve(selector, active_body_id=self.body_id) # 曲面 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 = resolution.as_dict()
evidence["feature_id"] = self.active_feature_id evidence["feature_id"] = self.active_feature_id
self.selector_resolutions.append(evidence) self.selector_resolutions.append(evidence)
return resolution 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( 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}, context=context, replay_definition={"atomic_id": node.atomic_id, "params": deepcopy(node.params), "sketch_id": node.sketch_id},
diagnostics=diagnostics or [], 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) raise FeatureExecutionError("non_uniform_extent_target", "The target vertex does not define one extrusion distance", extent=condition)
distance = sum(projections) / len(projections) distance = sum(projections) / len(projections)
else: 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: try:
distance = session.adapter.uniform_intersection_distance(target, faces, direction) distance = session.adapter.uniform_intersection_distance(target, faces, direction)
except ValueError as error: except ValueError as error:
@@ -255,13 +378,15 @@ def _targeted_extent_vector(
# 裁剪体层,仍保持显式拒绝。 # 裁剪体层,仍保持显式拒绝。
return ExtentVector(vector_scale(direction, 1.0), trim_to=target) return ExtentVector(vector_scale(direction, 1.0), trim_to=target)
raise FeatureExecutionError(code, message, extent=condition) from error 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": 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 distance -= offset
if distance <= 1e-6: if distance <= 1e-6:
raise FeatureExecutionError( raise FeatureExecutionError(
"invalid_extent_offset", "invalid_extent_offset",
"Offset distance reaches or passes the target surface", "Offset distance reaches or passes the target extent",
extent=condition, offset_mm=offset, extent=condition, offset_mm=offset,
) )
return ExtentVector(vector_scale(direction, distance)) 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) faces = session.adapter.faces_for_sketch(selected_sketch)
if not faces: if not faces:
raise ValueError("sketch does not create a closed profile region") 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. 按特征类型生成子实体: # 3. 按特征类型生成子实体:
if node.atomic_id.startswith("extrude_"): if node.atomic_id.startswith("extrude_"):
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量, # 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
@@ -422,10 +555,18 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
# profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪 # profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。 # 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
extents = _extent_vectors(node, faces, selected_sketch, session) 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] = [] solids: list[Any] = []
for face in faces: for face in faces:
for extent in extents: 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)) solids.append(session.adapter.extrude(face, extent.vector))
else: else:
solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector)) 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: if session.body is None:
raise ValueError("cut feature has no body") raise ValueError("cut feature has no body")
body = session.adapter.cut(session.body, tool) 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: else:
# 添加类特征:将工具体并到当前主体上(fuse),首个特征时 body 为 None 也能直接成立。 # 添加类特征:将工具体并到当前主体上(fuse),首个特征时 body 为 None 也能直接成立。
body = session.adapter.fuse(session.body, tool) body = session.adapter.fuse(session.body, tool)
members = {node.feature_id: body}
# 6. 登记新主体(更新拓扑、记录重放定义),并返回该特征的结果对象。 # 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) return session.result(node)
@@ -479,6 +715,79 @@ def _execute_loft_add(node: FeaturePlanNode, session: ExecutionSession) -> Featu
return session.result(node) 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: def _execute_reference_plane(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 基准面特征(reference_plane)执行入口:从参数解析平面并登记为拓扑上下文。 # 基准面特征(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) failed = next((item for item in resolved if item.status != "resolved"), None)
if failed: if failed:
raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution 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] = [] edges: list[Any] = []
for item in resolved: for item in resolved:
if item.record.kind == "edge": if item.record.kind == "edge":
edges.append(item.record.value) edges.append(item.record.value)
elif item.record.kind == "face": 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: if not edges:
raise ValueError("selectors did not resolve any edges") raise ValueError("selectors did not resolve any edges")
return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else 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: def _execute_fillet(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。 # 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。
@@ -825,13 +1210,31 @@ def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> Featur
if distance_2 is None and angle_rad is not None: if distance_2 is None and angle_rad is not None:
distance_2 = distance * math.tan(float(angle_rad)) distance_2 = distance * math.tan(float(angle_rad))
# 4. 解析目标边(支持相切传播),执行倒角。 # 4. 解析目标边(支持相切传播),执行倒角。
body = session.adapter.chamfer( edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation")))
session.body, distance, distance_2, diagnostics: list[RuntimeDiagnostic] = []
_selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))), 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. 登记新主体并返回结果。 # 5. 登记新主体并返回结果。
session.register_body(node.feature_id, body, replay_node=node) 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]: 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) resolution = session.resolve(mirror)
if resolution.status != "resolved" or not isinstance(resolution.record.value, PlaneSpec): 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") 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 []) sources = session.replay_sources(node.params.get("source_feature_ids") or [])
if not sources: if not sources:
raise ValueError("mirror pattern source features have no replay definitions") 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) plane[key] = _rotated_point(plane[key], axis, angle_rad)
else: else:
plane[key] = list(_rotated_vector(tuple(float(v) for v in plane[key]), axis, angle_rad)) 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 = params.get("host_face")
host_frame = host.get("frame") if isinstance(host, dict) else None host_frame = host.get("frame") if isinstance(host, dict) else None
positions_are_local = isinstance(host_frame, dict) and all( 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) 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: def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult:
# 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征 # 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征
# 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。 # 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。
@@ -1359,10 +1798,18 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession,
if count < 1: if count < 1:
raise ValueError("circular pattern pattern_count must be >= 1") raise ValueError("circular pattern pattern_count must be >= 1")
sweep_angle_deg = float(params.get("sweep_angle_deg") or 360.0) 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 []) sources = session.replay_sources(params.get("source_feature_ids") or [])
if not sources: if not sources:
raise ValueError("circular pattern source features have no replay definitions") raise ValueError("circular pattern source features have no replay definitions")
for instance in range(1, count): for instance in range(1, count):
if instance in excluded:
continue
# 实例 i 位于包角 sweep_angle_deg 的 i/count 处(i=0 即源特征本身)。 # 实例 i 位于包角 sweep_angle_deg 的 i/count 处(i=0 即源特征本身)。
angle_deg = sweep_angle_deg * instance / count angle_deg = sweep_angle_deg * instance / count
angle_rad = math.radians(angle_deg) angle_rad = math.radians(angle_deg)
@@ -1376,10 +1823,25 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession,
"by multiples of 180 degrees" "by multiples of 180 degrees"
) )
cloned = _rotated_node(source, f"{node.feature_id}.c{instance}.{source.feature_id}", axis, angle_rad, session) 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)) sketch = session.sketches.get(str(source.sketch_id))
execute(cloned, session, _rotated_sketch(sketch, axis, angle_rad) if sketch else None) execute(cloned, session, _rotated_sketch(sketch, axis, angle_rad) if sketch else None)
# 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放。 # 环形阵列本身是完整 B-rep 结果的 producer。每个 replay 子特征都会更新
session.replay_definitions[node.feature_id] = node # 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) return session.result(node)
@@ -1418,11 +1880,30 @@ def _primary_executor(node: FeaturePlanNode, session: ExecutionSession, sketch:
return _shape_from_primary(node, session, sketch=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: def _loft_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch del sketch
return _execute_loft_add(node, session) 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: def _reference_plane_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch del sketch
return _execute_reference_plane(node, session) return _execute_reference_plane(node, session)
@@ -1468,6 +1949,11 @@ def _chamfer_executor(node: FeaturePlanNode, session: ExecutionSession, sketch:
return _execute_chamfer(node, session) 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: def _linear_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch del sketch
return _execute_linear_pattern(node, session, _execute_node) return _execute_linear_pattern(node, session, _execute_node)
@@ -1490,19 +1976,26 @@ EXECUTORS: dict[str, ExecutorFunction] = {
"gear_add": _gear_executor, "gear_add": _gear_executor,
"rack_add": _rack_executor, "rack_add": _rack_executor,
"extrude_add_blind": _primary_executor, "extrude_add_blind": _primary_executor,
"extrude_add_blind_with_hole": _primary_executor,
"extrude_add_two_sided": _primary_executor, "extrude_add_two_sided": _primary_executor,
"extrude_cut_blind": _primary_executor, "extrude_cut_blind": _primary_executor,
"extrude_cut_two_sided": _primary_executor, "extrude_cut_two_sided": _primary_executor,
"extrude_cut_through": _primary_executor, "extrude_cut_through": _primary_executor,
"loft_add": _loft_executor, "loft_add": _loft_executor,
"loft_add_with_cap_face": _loft_cap_face_executor,
"sweep_add": _sweep_executor,
"revolve_add": _primary_executor, "revolve_add": _primary_executor,
"revolve_cut": _primary_executor, "revolve_cut": _primary_executor,
"revolve_surface": _revolve_surface_executor,
"extrude_surface": _extrude_surface_executor,
"hole_blind": _hole_executor, "hole_blind": _hole_executor,
"hole_countersink": _hole_executor, "hole_countersink": _hole_executor,
"hole_counterbore": _hole_executor, "hole_counterbore": _hole_executor,
"hole_wizard": _hole_wizard_executor, "hole_wizard": _hole_wizard_executor,
"fillet": _fillet_executor, "fillet": _fillet_executor,
"chamfer": _chamfer_executor, "chamfer": _chamfer_executor,
"shell": _shell_executor,
"boolean_bodies": lambda node, session, sketch: _execute_boolean_bodies(node, session),
"pattern_linear": _linear_pattern_executor, "pattern_linear": _linear_pattern_executor,
"pattern_mirror": _mirror_pattern_executor, "pattern_mirror": _mirror_pattern_executor,
"pattern_circular": _circular_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) diagnostics.append(diagnostic)
if strict: if strict:
raise RuntimeExecutionError(diagnostic, list(session.selector_resolutions)) from error raise RuntimeExecutionError(diagnostic, list(session.selector_resolutions)) from error
if session.body is None: output = session.body
raise ValueError("CDSL execution produced no 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) out_step.parent.mkdir(parents=True, exist_ok=True)
session.adapter.export(session.body, str(out_step)) session.adapter.export(output, str(out_step))
geometry = session.adapter.body_geometry(session.body) 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"] bbox = geometry["bbox_mm"]
return { return {
"engine": "cdsl_session_runtime", "engine": "cdsl_session_runtime",
"out_step": str(out_step), "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:]}, "bbox_mm": {"min": bbox[:3], "max": bbox[3:]},
# #7 multi-body:重建结果里的独立实体数(Compound 成员数), # #7 multi-body:重建结果里的独立实体数(Compound 成员数),
# 与 batch 验证的 document_truth.geometry.solid_body_count 对齐。 # 与 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()], "feature_results": [result.as_dict() for result in session.results.values()],
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics], "runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics],
"topology_records": [record.public_dict() for record in session.topology.records()], "topology_records": [record.public_dict() for record in session.topology.records()],
+37 -11
View File
@@ -656,6 +656,7 @@ class FeatureResult:
atomic_id: str atomic_id: str
status: str status: str
body_id: str | None = None body_id: str | None = None
surface_id: str | None = None
context: PlaneSpec | AxisSpec | None = None context: PlaneSpec | AxisSpec | None = None
replay_definition: dict[str, Any] | None = None replay_definition: dict[str, Any] | None = None
diagnostics: list[RuntimeDiagnostic] = field(default_factory=list) diagnostics: list[RuntimeDiagnostic] = field(default_factory=list)
@@ -669,6 +670,8 @@ class FeatureResult:
} }
if self.body_id is not None: if self.body_id is not None:
output["body_id"] = self.body_id 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: if self.context is not None:
output["context"] = self.context.as_dict() output["context"] = self.context.as_dict()
if self.replay_definition is not None: if self.replay_definition is not None:
@@ -780,6 +783,12 @@ class TopologyRegistry:
def replace_body_topology( def replace_body_topology(
self, feature_id: str, body_id: str, records: Iterable[TopologyRecord], self, feature_id: str, body_id: str, records: Iterable[TopologyRecord],
*, active_body_id: str | None = None, *, 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: ) -> None:
"""Record a fresh B-rep snapshot after a feature mutates the body. """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}:") 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() consumed_predecessors: set[str] = set()
for record in records: registered: list[TopologyRecord] = []
predecessor = self._unique_equivalent_predecessor(record, previous, consumed_predecessors) for body_id, records in current:
owners = predecessor.owners if predecessor is not None else (feature_id,) member_id = str(body_id).rsplit(":", 1)[-1]
if predecessor is not None: local_predecessors = [
consumed_predecessors.add(predecessor.record_id) record for record in previous
self.register( if not use_member_indexes or str(record.body_id).rsplit(":", 1)[-1] == member_id
TopologyRecord( ]
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, record_id=record.record_id,
kind=record.kind, kind=record.kind,
feature_id=feature_id, feature_id=feature_id,
@@ -819,8 +844,9 @@ class TopologyRegistry:
geometry=dict(record.geometry), geometry=dict(record.geometry),
value=record.value, value=record.value,
owner_feature_ids=owners, owner_feature_ids=owners,
) ))
) for record in registered:
self.register(record)
# #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续 # #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续
# selector 的 stable_id 引用解析到 active body 内的新形态。多条演化 # selector 的 stable_id 引用解析到 active body 内的新形态。多条演化
# 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段 # 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段
@@ -832,7 +858,7 @@ class TopologyRegistry:
candidates = sorted( candidates = sorted(
( (
(self._evolved_drift(prior, record), record.record_id) (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], key=lambda item: item[0],
) )
@@ -87,7 +87,8 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
deferred.append(fid) deferred.append(fid)
for index, selector in enumerate(feature.get("selectors") or []): for index, selector in enumerate(feature.get("selectors") or []):
owner = selector.get("owner_feature_id") 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") raise ValueError(f"Feature {fid} selector {index} has a forward or missing owner_feature_id")
if feature.get("unresolved"): if feature.get("unresolved"):
unresolved.append({"feature_id": fid, "reasons": list(feature["unresolved"])}) unresolved.append({"feature_id": fid, "reasons": list(feature["unresolved"])})
+123 -7
View File
@@ -1,7 +1,7 @@
"""Core CDSL sketch resolver. """Core CDSL sketch resolver.
The runtime accepts only direct geometric descriptions: circles, straight-edge 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 shapes and historical profile macros belong to the importer compatibility
layer and must be lowered before this module is invoked. 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]] 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: def _dot(left: Iterable[float], right: Iterable[float]) -> float:
return sum(a * b for a, b in zip(left, right)) 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": if edge["type"] == "arc":
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1]) output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
output["normal"] = list(normal) 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": elif edge["type"] == "bspline":
output["points_mm"] = [ output["points_mm"] = [
_to_3d(workplane, point[0], point[1]) _to_3d(workplane, point[0], point[1])
for point in edge["points_mm"] 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) transformed.append(output)
return transformed return transformed
@@ -96,8 +110,9 @@ def _gen_circle(profile: _Ctx, _: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
if radius <= 0: if radius <= 0:
raise ValueError("circle radius must be > 0") raise ValueError("circle radius must be > 0")
cx, cy = float(center[0]), float(center[1]) 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]] # 直接圆 profile 必须保留为一条完整的圆边。若拆成四条圆弧,后续按边
return [_circle([cx, cy], radius)], [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius) for index in range(4)] # 选择的圆角/倒角会把同一拓扑圆误解为四个独立目标。
return [_circle([cx, cy], radius)], []
def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: 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])) 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: def _reverse(edge: _Ctx) -> _Ctx:
output = deepcopy(edge) output = deepcopy(edge)
output["start_mm"], output["end_mm"] = output["end_mm"], output["start_mm"] 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"]) output["clockwise"] = not bool(output["clockwise"])
if output.get("type") == "bspline": if output.get("type") == "bspline":
output["points_mm"] = list(reversed(output["points_mm"])) 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 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)] 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]: def _segment_edges(segment: _Ctx) -> list[_Ctx]:
kind = segment.get("type") kind = segment.get("type")
if kind == "line": 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"))] return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
if kind == "circle": if kind == "circle":
return _circle_edges(segment) return _circle_edges(segment)
if kind == "ellipse":
return _ellipse_edges(segment)
if kind == "bspline": if kind == "bspline":
points = segment.get("points") or [] points = segment.get("points") or []
if len(points) < 3: if len(points) < 3:
raise ValueError("analytic_contours: bspline needs at least 3 interpolation points") raise ValueError("analytic_contours: bspline needs at least 3 interpolation points")
converted = [_point(point) for point in 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", "type": "bspline",
"start_mm": converted[0], "start_mm": converted[0],
"end_mm": converted[-1], "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}") 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": if edge.get("type") == "bspline":
points.extend((float(point[0]), float(point[1])) for point in edge["points_mm"][1:-1]) points.extend((float(point[0]), float(point[1])) for point in edge["points_mm"][1:-1])
continue 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": if edge.get("type") != "arc":
continue continue
center, end = edge["center_mm"], edge["end_mm"] center, end = edge["center_mm"], edge["end_mm"]
@@ -328,7 +444,7 @@ SHAPE_GENERATORS = CORE_SHAPE_GENERATORS
SHAPE_CAPABILITIES: dict[str, _Ctx] = { SHAPE_CAPABILITIES: dict[str, _Ctx] = {
"circle": {"detectable": True, "arity": "circle", "description": "single circular contour"}, "circle": {"detectable": True, "arity": "circle", "description": "single circular contour"},
"polygon": {"detectable": True, "arity": "polygon", "description": "closed straight-edge 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"},
} }
@@ -5,6 +5,7 @@ import unittest
from pathlib import Path from pathlib import Path
from engine.cdsl_engine.runtime import rebuild_cdsl from engine.cdsl_engine.runtime import rebuild_cdsl
from engine.cdsl_engine.sketch_solver import resolve_profile
def _spline_profile(sketch_id: str, z: float) -> dict: def _spline_profile(sketch_id: str, z: float) -> dict:
@@ -28,6 +29,22 @@ def _spline_profile(sketch_id: str, z: float) -> dict:
class LoftGeometryTests(unittest.TestCase): class LoftGeometryTests(unittest.TestCase):
def test_periodic_bspline_supports_centripetal_parameterization(self):
sketch = {
"id": "profile",
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
"profile": {"type": "analytic_contours", "contours": [{
"role": "outer", "closed": True, "segments": [{
"type": "bspline", "periodic": True, "parameterization": "centripetal",
"start": [0, 0], "end": [0, 0],
"points": [[0, 0], [16, 0], [16, 9], [0, 9], [0, 0]],
}],
}]},
}
resolved = resolve_profile(sketch)
parameters = resolved["contour_regions_mm"][0]["outer"][0]["parameters"]
self.assertEqual(parameters, [0.0, 4.0, 7.0, 11.0, 14.0])
def test_open_bspline_segment_can_close_with_other_contour_edges(self): def test_open_bspline_segment_can_close_with_other_contour_edges(self):
cdsl = { cdsl = {
"schema": "cad.cdsl.llm.v1", "schema": "cad.cdsl.llm.v1",
@@ -58,6 +75,32 @@ class LoftGeometryTests(unittest.TestCase):
rebuilt = rebuild_cdsl(cdsl, Path(tmp) / "open-bspline-contour.step") rebuilt = rebuild_cdsl(cdsl, Path(tmp) / "open-bspline-contour.step")
self.assertGreater(rebuilt["volume_mm3"], 1.0) self.assertGreater(rebuilt["volume_mm3"], 1.0)
def test_reversed_bspline_transports_the_parameter_domain(self):
sketch = {
"id": "profile",
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
"profile": {"type": "analytic_contours", "contours": [{
"role": "outer", "closed": True,
"segments": [
{"type": "line", "start": [0, 0], "end": [0, 4]},
{
"type": "bspline", "start": [3, 0], "end": [0, 4],
"points": [[3, 0], [3, 2], [1, 4], [0, 4]],
"parameters": [0, 2, 4, 6],
"start_tangent": [2, 0], "end_tangent": [0, -3],
},
{"type": "line", "start": [3, 0], "end": [0, 0]},
],
}]},
}
resolved = resolve_profile(sketch)
spline = next(edge for edge in resolved["contour_regions_mm"][0]["outer"] if edge["type"] == "bspline")
self.assertEqual(spline["points_mm"], [[0.0, 4.0, 0.0], [1.0, 4.0, 0.0], [3.0, 2.0, 0.0], [3.0, 0.0, 0.0]])
self.assertEqual(spline["parameters"], [0.0, 2.0, 4.0, 6.0])
self.assertEqual(spline["start_tangent_mm"], [0.0, 3.0, 0.0])
self.assertEqual(spline["end_tangent_mm"], [-2.0, 0.0, 0.0])
def test_bspline_loft_and_mirror_replay_produce_step(self): def test_bspline_loft_and_mirror_replay_produce_step(self):
cdsl = { cdsl = {
"schema": "cad.cdsl.llm.v1", "schema": "cad.cdsl.llm.v1",
@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import math
import sys import sys
import tempfile import tempfile
import unittest import unittest
@@ -129,6 +130,133 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
self.assertEqual(len(sketch["contour_regions_mm"]), 2) self.assertEqual(len(sketch["contour_regions_mm"]), 2)
self.assertTrue(all(not region["holes"] for region in sketch["contour_regions_mm"])) self.assertTrue(all(not region["holes"] for region in sketch["contour_regions_mm"]))
def test_analytic_circle_contours_preserve_single_circular_wire_edges(self) -> None:
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
cdsl = {
"schema": "cad.cdsl.llm.v1",
"geometry": {"sketches": [{
"id": "sketch", "workplane": _workplane(),
"profile": {"type": "analytic_contours", "contours": [
{"role": "outer", "closed": True, "segments": [
{"type": "circle", "center": [-10, 0], "radius_mm": 2},
]},
{"role": "outer", "closed": True, "segments": [
{"type": "circle", "center": [10, 0], "radius_mm": 2},
]},
]},
}]},
"features": [],
}
sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0]
faces = Build123dGeometryAdapter().faces_for_sketch(sketch)
self.assertEqual(len(faces), 2)
self.assertTrue(all(len(face.outer_wire().edges()) == 1 for face in faces))
def test_analytic_ellipse_preserves_its_workplane_orientation_and_volume(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = self._base_block()
cdsl["geometry"]["sketches"][0] = {
"id": "ellipse",
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [0, 1, 0], "normal": [0, 0, 1]},
"profile": {"type": "analytic_contours", "contours": [{
"role": "outer", "closed": True, "segments": [{
"type": "ellipse", "center": [0, 0], "major_radius_mm": 5,
"minor_radius_mm": 2, "major_axis": [3 / 5, 4 / 5],
}],
}]},
}
cdsl["features"][0]["sketch_id"] = "ellipse"
cdsl["features"][0]["params"] = {"distance_mm": 4}
sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0]
edge = sketch["contour_regions_mm"][0]["outer"][0]
self.assertEqual(edge["major_axis_mm"], [-0.8, 0.6, 0.0])
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "ellipse.step")
self.assertEqual(result["solid_count"], 1)
self.assertAlmostEqual(result["volume_mm3"], math.pi * 5 * 2 * 4, places=5)
def test_bspline_profile_preserves_endpoint_tangents(self) -> None:
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
cdsl = {
"schema": "cad.cdsl.llm.v1",
"geometry": {"sketches": [{
"id": "spline", "workplane": _workplane(),
"profile": {"type": "analytic_contours", "contours": [{
"role": "outer", "closed": True, "segments": [
{"type": "line", "start": [0, 0], "end": [4, 0]},
{"type": "bspline", "start": [4, 0], "end": [0, 0],
"points": [[4, 0], [4, 3], [0, 0]],
"start_tangent": [0, 5], "end_tangent": [-5, 0]},
],
}]},
}]},
"features": [],
}
sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0]
spline = next(edge for edge in sketch["contour_regions_mm"][0]["outer"] if edge["type"] == "bspline")
self.assertEqual(spline["start_tangent_mm"], [0.0, 5.0, 0.0])
self.assertEqual(spline["end_tangent_mm"], [-5.0, 0.0, 0.0])
edge = Build123dGeometryAdapter()._wire([spline]).edges()[0]
self.assertAlmostEqual(edge.tangent_at(0).Y, 1.0, places=6)
self.assertAlmostEqual(edge.tangent_at(1).X, -1.0, places=6)
def test_drafted_ellipse_exports_as_a_solid(self) -> None:
from build123d import import_step
from cdsl_engine.runtime import rebuild_cdsl
cdsl = self._base_block()
cdsl["geometry"]["sketches"].append({
"id": "ellipse", "workplane": {"origin_mm": [0, 0, 10], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
"profile": {"type": "analytic_contours", "contours": [{
"role": "outer", "closed": True, "segments": [{
"type": "ellipse", "center": [0, 0], "major_radius_mm": 3,
"minor_radius_mm": 2, "major_axis": [1, 0],
}],
}]},
})
cdsl["features"].append({
"id": "drafted_ellipse", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
"params": {"distance_mm": 4, "draft": {"angle_deg": 1, "pull_direction": False}}, "sketch_id": "ellipse",
})
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "drafted-ellipse.step"
result = rebuild_cdsl(cdsl, output)
exported = import_step(str(output))
self.assertEqual(result["solid_count"], 1)
self.assertEqual(len(exported.solids()), 1)
self.assertGreater(float(exported.volume), 1000.0)
def test_boolean_bodies_subtracts_explicit_new_body_sources(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "boolean-bodies",
"meta": {"unit": "mm"},
"geometry": {"sketches": [
{"id": "outer", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 10}},
{"id": "inner", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 4}},
]},
"features": [
{"id": "outer_body", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "outer", "params": {"distance_mm": 10, "result_mode": "new_body"}},
{"id": "inner_body", "atomic_id": "extrude_add_blind", "depends_on": ["outer_body"], "sketch_id": "inner", "params": {"distance_mm": 10, "result_mode": "new_body"}},
{"id": "cut", "atomic_id": "boolean_bodies", "depends_on": ["outer_body", "inner_body"], "params": {"operation": "subtract", "target_feature_ids": ["outer_body"], "tool_feature_ids": ["inner_body"], "keep_tools": False}},
],
}
with tempfile.TemporaryDirectory() as tmp:
rebuilt = rebuild_cdsl(cdsl, Path(tmp) / "boolean.step")
self.assertEqual(rebuilt["solid_count"], 1)
self.assertAlmostEqual(rebuilt["volume_mm3"], math.pi * (10 ** 2 - 4 ** 2) * 10)
self.assertEqual([item["feature_id"] for item in rebuilt["feature_results"]], ["outer_body", "inner_body", "cut"])
def test_deferred_reference_is_currently_executable_without_a_sketch(self) -> None: def test_deferred_reference_is_currently_executable_without_a_sketch(self) -> None:
cdsl = { cdsl = {
"schema": "cad.cdsl.llm.v1", "schema": "cad.cdsl.llm.v1",
@@ -598,6 +726,81 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
self.assertAlmostEqual(result["volume_mm3"], 200 - 4 * 3.141592653589793, places=5) self.assertAlmostEqual(result["volume_mm3"], 200 - 4 * 3.141592653589793, places=5)
self.assertIn("mirror", [item["feature_id"] for item in result["feature_results"]]) self.assertIn("mirror", [item["feature_id"] for item in result["feature_results"]])
def test_circular_pattern_fuses_face_sharing_new_body_instances(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "circular-fuse",
"meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "wedge", "workplane": _workplane(),
"profile": {"type": "polygon", "vertices": [[0, 0], [10, 0], [0, 10]]},
}]},
"features": [
{"id": "wedge_add", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 10, "result_mode": "new_body"}, "sketch_id": "wedge"},
{"id": "wedge_pattern", "atomic_id": "pattern_circular", "depends_on": ["wedge_add"], "params": {
"source_feature_ids": ["wedge_add"], "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]},
"pattern_count": 4, "sweep_angle_deg": 360,
}},
],
}
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "circular-fuse.step")
self.assertEqual(result["solid_count"], 1)
self.assertAlmostEqual(result["volume_mm3"], 2000.0, places=5)
def test_circular_pattern_skips_explicitly_deleted_copy_instances(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "circular-delete-copy",
"meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "wedge", "workplane": _workplane(),
"profile": {"type": "polygon", "vertices": [[0, 0], [10, 0], [0, 10]]},
}]},
"features": [
{"id": "wedge_add", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 10, "result_mode": "new_body"}, "sketch_id": "wedge"},
{"id": "wedge_pattern", "atomic_id": "pattern_circular", "depends_on": ["wedge_add"], "params": {
"source_feature_ids": ["wedge_add"], "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]},
"pattern_count": 4, "sweep_angle_deg": 360, "excluded_instance_indices": [2],
}},
],
}
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "circular-delete-copy.step")
self.assertAlmostEqual(result["volume_mm3"], 1500.0, places=5)
def test_mirror_pattern_can_union_the_active_body(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "mirror-active-body",
"meta": {"unit": "mm"},
"geometry": {"sketches": [
{"id": "base", "workplane": _workplane(), "profile": _rectangle([-5, -5], [5, 5])},
{"id": "half_cut", "workplane": _workplane(), "profile": _rectangle([-5, 0], [5, 5])},
{"id": "hole", "workplane": _workplane(), "profile": {"type": "circle", "center": [0, -2], "radius_mm": 1}},
]},
"features": [
{"id": "mirror_plane", "atomic_id": "reference_plane", "depends_on": [], "params": {"plane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 1, 0]}}, "execution_status": "supported"},
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": ["mirror_plane"], "params": {"distance_mm": 2}, "sketch_id": "base", "execution_status": "supported"},
{"id": "half_remove", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 2}, "sketch_id": "half_cut", "execution_status": "supported"},
{"id": "hole_remove", "atomic_id": "extrude_cut_blind", "depends_on": ["half_remove"], "params": {"distance_mm": 2}, "sketch_id": "hole", "execution_status": "supported"},
{
"id": "mirror", "atomic_id": "pattern_mirror", "depends_on": ["hole_remove", "mirror_plane"],
"params": {"source_feature_ids": ["base_add"], "mirror_current_body": True, "mirror_plane": {"kind": "plane", "owner_feature_id": "mirror_plane"}},
"selectors": [{"kind": "plane", "owner_feature_id": "mirror_plane"}], "execution_status": "supported",
},
],
}
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "mirror-active-body.step")
self.assertAlmostEqual(result["volume_mm3"], 200 - 4 * 3.141592653589793, places=5)
self.assertEqual(result["solid_count"], 1)
def test_mirrored_local_circle_preserves_its_reflected_world_position(self) -> None: def test_mirrored_local_circle_preserves_its_reflected_world_position(self) -> None:
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
from cdsl_engine.runtime import _mirrored_sketch from cdsl_engine.runtime import _mirrored_sketch
@@ -622,6 +825,120 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
self.assertAlmostEqual(center.Y, 3.0) self.assertAlmostEqual(center.Y, 3.0)
self.assertAlmostEqual(center.Z, 0.0) self.assertAlmostEqual(center.Z, 0.0)
def test_circle_profile_retains_one_topological_circle_edge(self) -> None:
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
cdsl = {
"schema": "cad.cdsl.llm.v1",
"geometry": {"sketches": [{
"id": "circle", "workplane": _workplane(),
"profile": {"type": "circle", "center": [2, 3], "radius_mm": 1},
}]},
"features": [],
}
sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0]
self.assertNotIn("contour_edges_mm", sketch)
face = Build123dGeometryAdapter().faces_for_sketch(sketch)[0]
self.assertEqual(len(face.outer_wire().edges()), 1)
def test_new_body_mode_preserves_an_overlapping_result_body(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = self._base_block()
cdsl["geometry"]["sketches"].append({
"id": "overlap", "workplane": _workplane(),
"profile": _rectangle([-2, -5], [8, 5]),
})
cdsl["features"].append({
"id": "second_body", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
"params": {"distance_mm": 10, "result_mode": "new_body"}, "sketch_id": "overlap",
})
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "two-bodies.step")
self.assertEqual(result["solid_count"], 2)
def test_sweep_add_builds_a_solid_from_a_closed_profile_and_bspline_path(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
profile_plane = {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 1, 0]}
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "sweep-add",
"meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "profile", "workplane": profile_plane,
"profile": {"type": "circle", "radius_mm": 2},
}]},
"features": [{
"id": "sweep", "atomic_id": "sweep_add", "depends_on": [], "sketch_id": "profile",
"params": {"path": {
"workplane": _workplane(),
"segment": {
"type": "bspline", "start": [0, 0], "end": [0, 20],
"points": [[0, 0], [0, 10], [0, 20]],
"start_tangent": [0, 10], "end_tangent": [0, 10],
},
}},
}],
}
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "sweep.step")
self.assertEqual(result["solid_count"], 1)
self.assertAlmostEqual(result["volume_mm3"], 80 * math.pi, delta=1e-4)
def test_circular_pattern_rotates_a_sweep_profile_and_path(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "sweep-pattern",
"meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "profile", "workplane": {"origin_mm": [10, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, -1]},
"profile": {"type": "circle", "radius_mm": 1},
}]},
"features": [
{"id": "sweep", "atomic_id": "sweep_add", "depends_on": [], "sketch_id": "profile", "params": {"path": {
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 1, 0]},
"segment": {"type": "line", "start": [10, 0], "end": [10, 10]},
}}},
{"id": "pattern", "atomic_id": "pattern_circular", "depends_on": ["sweep"], "params": {
"source_feature_ids": ["sweep"], "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]},
"pattern_count": 3, "sweep_angle_deg": 360,
}},
],
}
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "sweep-pattern.step")
self.assertEqual(result["solid_count"], 3)
self.assertAlmostEqual(result["volume_mm3"], 30 * math.pi, delta=1e-4)
def test_shell_removes_a_selected_cap_and_offsets_inward(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
base = self._base_block()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(base, root / "baseline.step")
top = next(
item for item in baseline["topology_records"]
if item["kind"] == "face"
and item["feature_id"] == "base_add"
and item["geometry"].get("surface_type") == "plane"
and item["geometry"].get("normal", [0, 0, 0])[2] > 0.9
)
shelled = deepcopy(base)
shelled["features"].append({
"id": "shell", "atomic_id": "shell", "depends_on": ["base_add"],
"params": {"thickness_mm": 1, "inward": True},
"selectors": [{
"kind": "face", "stable_id": top["record_id"], "snapshot_id": top["record_id"],
"source": "runtime_snapshot", "confidence": 1, "owner_feature_id": "base_add",
"geometry": top["geometry"],
}],
})
result = rebuild_cdsl(shelled, root / "shell.step")
self.assertEqual(result["solid_count"], 1)
self.assertAlmostEqual(result["volume_mm3"], 424.0, places=5)
def test_fillet_and_hole_wizard_use_resolved_face_edge_selectors(self) -> None: def test_fillet_and_hole_wizard_use_resolved_face_edge_selectors(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl from cdsl_engine.runtime import rebuild_cdsl
@@ -983,6 +1300,131 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
self.assertLess(result["volume_mm3"], 1000.0) self.assertLess(result["volume_mm3"], 1000.0)
self.assertIn("revolve_cut", [item["feature_id"] for item in result["feature_results"]]) self.assertIn("revolve_cut", [item["feature_id"] for item in result["feature_results"]])
def test_revolve_surface_preserves_the_active_solid_and_registers_a_shell(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = self._base_block()
cdsl["geometry"]["sketches"].append({
"id": "surface_profile", "workplane": _workplane(),
"profile": _rectangle([2, 1], [4, 2]),
})
cdsl["features"].append({
"id": "surface_revolve", "atomic_id": "revolve_surface", "depends_on": ["base_add"],
"sketch_id": "surface_profile",
"params": {"angle_deg": 360, "axis": {"origin_mm": [0, 0, 0], "direction": [1, 0, 0]}},
})
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "surface-revolve.step")
surface = next(item for item in result["feature_results"] if item["feature_id"] == "surface_revolve")
self.assertNotIn("body_id", surface)
self.assertEqual(surface["surface_id"], "surface:surface_revolve")
self.assertEqual(result["solid_count"], 1)
self.assertAlmostEqual(result["volume_mm3"], 1000.0)
faces = [
item for item in result["topology_records"]
if item["feature_id"] == "surface_revolve" and item["kind"] == "face"
]
self.assertEqual(len(faces), 4)
def test_extrude_surface_preserves_the_active_solid_and_registers_a_shell(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = self._base_block()
cdsl["geometry"]["sketches"].append({
"id": "surface_profile", "workplane": _workplane(),
"profile": {
"type": "analytic_contours",
"contours": [{
"role": "unknown", "closed": True,
"segments": [{"type": "circle", "center": [5, 5], "radius_mm": 2}],
}],
},
})
cdsl["features"].append({
"id": "surface_extrude", "atomic_id": "extrude_surface", "depends_on": ["base_add"],
"sketch_id": "surface_profile", "params": {"distance_mm": 10},
})
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "surface-extrude.step")
surface = next(item for item in result["feature_results"] if item["feature_id"] == "surface_extrude")
self.assertNotIn("body_id", surface)
self.assertEqual(surface["surface_id"], "surface:surface_extrude")
self.assertEqual(result["solid_count"], 1)
self.assertAlmostEqual(result["volume_mm3"], 1000.0)
faces = [
item for item in result["topology_records"]
if item["feature_id"] == "surface_extrude" and item["kind"] == "face"
]
self.assertEqual(len(faces), 1)
def test_extrude_surface_exports_a_surface_only_step(self) -> None:
from build123d import import_step
from cdsl_engine.runtime import rebuild_cdsl
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "surface-only",
"meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "surface_profile", "workplane": _workplane(),
"profile": {
"type": "analytic_contours",
"contours": [{
"role": "unknown", "closed": True,
"segments": [{"type": "circle", "center": [5, 5], "radius_mm": 2}],
}],
},
}]},
"features": [{
"id": "surface_extrude", "atomic_id": "extrude_surface", "depends_on": [],
"sketch_id": "surface_profile", "params": {"distance_mm": 10},
}],
}
with tempfile.TemporaryDirectory() as directory:
out_step = Path(directory) / "surface-only.step"
result = rebuild_cdsl(cdsl, out_step)
rebuilt = import_step(str(out_step))
self.assertEqual(result["solid_count"], 0)
self.assertEqual(result["surface_count"], 1)
self.assertEqual(result["surface_face_count"], 1)
self.assertAlmostEqual(result["volume_mm3"], 0.0)
self.assertAlmostEqual(result["surface_area_mm2"], 40 * math.pi)
self.assertEqual(len(rebuilt.solids()), 0)
self.assertEqual(len(rebuilt.faces()), 1)
def test_surface_limited_chamfer_requires_an_explicit_shell_boundary(self) -> None:
from build123d import Plane, Solid
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
adapter = Build123dGeometryAdapter()
base = Solid.make_cylinder(19, 20, Plane(origin=(0, 0, -10))).cut(
Solid.make_cylinder(16, 20, Plane(origin=(0, 0, -10))),
)
lower = Solid.make_cylinder(28.5, 20, Plane(origin=(0, 0, -20))).cut(
Solid.make_cylinder(16, 20, Plane(origin=(0, 0, -20))),
)
upper = Solid.make_cylinder(25.5, 25, Plane(origin=(0, 0, 10))).cut(
Solid.make_cylinder(17.5, 25, Plane(origin=(0, 0, 10))),
)
body = adapter.fuse(adapter.fuse(base, lower), upper)
selected = [
edge for edge in body.edges()
if str(edge.geom_type).split(".")[-1].lower() == "circle"
and abs(float(edge.radius) - 28.5) <= 1e-6
and abs(edge.arc_center.Z) <= 1e-6
]
self.assertEqual(len(selected), 1)
with self.assertRaisesRegex(ValueError, "explicit surface support"):
adapter.surface_limited_chamfer(body, 10, selected, [])
wire = adapter._circle_wire([0, 0], 19, PlaneSpec.from_mapping(_workplane()))
support = adapter.extrude_surface([wire], [0, 0, 10])
rebuilt = adapter.surface_limited_chamfer(body, 10, selected, [support])
self.assertLess(rebuilt.volume, body.volume)
cones = [face for face in rebuilt.faces() if str(face.geom_type).split(".")[-1].lower() == "cone"]
self.assertEqual(len(cones), 1)
def test_unowned_revolve_feature_selector_is_preflight_blocked(self) -> None: def test_unowned_revolve_feature_selector_is_preflight_blocked(self) -> None:
from cdsl_engine.runtime import analyze_cdsl from cdsl_engine.runtime import analyze_cdsl
@@ -1057,6 +1499,53 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
result = rebuild_cdsl(cdsl, root / f"{name}.step") result = rebuild_cdsl(cdsl, root / f"{name}.step")
self.assertAlmostEqual(result["volume_mm3"], 1000 - expected_depth * 3.141592653589793, places=5) self.assertAlmostEqual(result["volume_mm3"], 1000 - expected_depth * 3.141592653589793, places=5)
def test_up_to_vertex_intersection_requires_one_shared_current_body_vertex(self) -> None:
from cdsl_engine.runtime import RuntimeExecutionError, rebuild_cdsl
base = self._base_block()
base["geometry"]["sketches"].append({
"id": "cut", "workplane": _workplane(),
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 1},
})
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(base, root / "intersection-baseline.step")
faces = [item for item in baseline["topology_records"] if item["kind"] == "face"]
top = next(item for item in faces if item["geometry"]["normal"][2] > 0.9)
right = next(item for item in faces if item["geometry"]["normal"][0] > 0.9)
back = next(item for item in faces if item["geometry"]["normal"][1] > 0.9)
def face_selector(face: dict) -> dict:
return {
"kind": "face", "owner_feature_id": "base_add",
"stable_id": f"intersection-{face['record_id']}",
"source": "runtime_snapshot", "confidence": 1.0,
"geometry": face["geometry"],
}
cdsl = deepcopy(base)
cdsl["features"].append({
"id": "cut_to_intersection", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"],
"params": {"distance_mm": 0, "end_condition": {
"type": "up_to_vertex", "reference": {
"kind": "vertex", "owner_feature_id": "base_add",
"stable_id": "top-right-back", "source": "runtime_snapshot", "confidence": 1.0,
"intersection_of": [face_selector(top), face_selector(right), face_selector(back)],
},
}},
"sketch_id": "cut",
})
result = rebuild_cdsl(cdsl, root / "intersection.step")
self.assertAlmostEqual(result["volume_mm3"], 1000 - 10 * 3.141592653589793, places=5)
ambiguous = deepcopy(cdsl)
ambiguous["features"][-1]["params"]["end_condition"]["reference"]["intersection_of"] = [
face_selector(right), face_selector(back),
]
with self.assertRaises(RuntimeExecutionError) as error:
rebuild_cdsl(ambiguous, root / "ambiguous-intersection.step")
self.assertEqual(error.exception.diagnostic.code, "intersection_vertex_unresolved")
def test_through_next_trims_a_partially_overlapping_profile_to_the_next_body_face(self) -> None: def test_through_next_trims_a_partially_overlapping_profile_to_the_next_body_face(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl from cdsl_engine.runtime import rebuild_cdsl
@@ -1073,6 +1562,71 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
result = rebuild_cdsl(base, Path(directory) / "partial-through-next.step") result = rebuild_cdsl(base, Path(directory) / "partial-through-next.step")
self.assertAlmostEqual(result["volume_mm3"], 1000 + 24, places=5) self.assertAlmostEqual(result["volume_mm3"], 1000 + 24, places=5)
def test_through_next_keeps_leading_material_before_a_cylindrical_body(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "leading-through-next",
"meta": {"unit": "mm"},
"geometry": {"sketches": [
{"id": "base", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 10}},
{"id": "lead", "workplane": {
"origin_mm": [0, -50, 25], "x_dir": [1, 0, 0], "normal": [0, 1, 0],
}, "profile": {"type": "circle", "radius_mm": 2}},
]},
"features": [
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 50}, "sketch_id": "base"},
{"id": "lead_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
"params": {"distance_mm": 0, "end_condition": {"type": "through_next"}, "result_mode": "new_body"},
"sketch_id": "lead"},
],
}
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "leading-through-next.step")
self.assertEqual(result["solid_count"], 2)
self.assertAlmostEqual(result["bbox_mm"]["min"][1], -50.0, places=5)
def test_up_to_surface_from_a_cylindrical_wall_uses_the_next_body_face(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
base = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "wall-hole",
"meta": {"unit": "mm"},
"geometry": {"sketches": [
{"id": "tube", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 10}},
{"id": "tube_bore", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 7}},
{"id": "wall_hole", "workplane": {
"origin_mm": [10, 0, 5], "x_dir": [0, 1, 0], "normal": [-1, 0, 0],
}, "profile": {"type": "circle", "radius_mm": 1}},
]},
"features": [
{"id": "tube_add", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 10}, "sketch_id": "tube"},
{"id": "tube_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["tube_add"],
"params": {"distance_mm": 10}, "sketch_id": "tube_bore"},
],
}
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(base, root / "tube.step")
outer_face = next(
item for item in baseline["topology_records"]
if item["kind"] == "face" and item["geometry"]["surface_type"] == "cylinder"
and abs(item["geometry"]["radius_mm"] - 10) <= 1e-6
)
cdsl = deepcopy(base)
cdsl["features"].append({
"id": "wall_hole_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["tube_cut"],
"params": {"distance_mm": 0, "end_condition": {"type": "up_to_surface", "reference": {
"kind": "face", "owner_feature_id": "tube_add", "geometry": outer_face["geometry"],
}}},
"sketch_id": "wall_hole",
})
result = rebuild_cdsl(cdsl, root / "wall-hole.step")
self.assertLess(result["volume_mm3"], baseline["volume_mm3"] - 8)
self.assertGreater(result["volume_mm3"], baseline["volume_mm3"] - 12)
def test_up_to_body_extent_uses_a_uniquely_resolved_body_record(self) -> None: def test_up_to_body_extent_uses_a_uniquely_resolved_body_record(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl from cdsl_engine.runtime import rebuild_cdsl
+1
View File
@@ -1,3 +1,4 @@
output/ output/
output-history/
__pycache__/ __pycache__/
*.pyc *.pyc
@@ -0,0 +1,195 @@
# CADFS 全量重建目标与验收计划
## 目标
将 CADFS FeatureScript 历史稳定转换为 CDSL,由 engine 重建 STEP,并与
原始 STEP 做严格几何比较。近期交付门槛是 17 个代表性样本全部严格通过;
最终交付门槛是当前 CADFS 全量语料中的每个可比较模型都完成同一条链路。
这里的“重建成功”仅指 `comparison.json``strict.passed == true`,不是
仅生成 `rebuild.step`,也不是只通过 RP 宽松阈值。
本文件记录目标、阶段门槛和验收口径。实现进度、能力边界和每次代码变更
后的证据记录在本地台账
`ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`;两者必须保持一致。
## 当前基线
- 全量输出快照:9,347 个 CADFS 样本,见 `output/summary.json`。产品目标可
称为“一万条全量数据”,但验收时必须以本次扫描得到的实际样本总数为分母,
不能将 10,000 写成未验证的固定数量。
- 代表性回归池:17 个样本,见 `regression/manifest.json`。它覆盖当前观察到的
FeatureScript 操作、草图实体、已 lower 的 engine atomic 和未支持能力变体。
- 本地能力台账记录该池已有 `17/17 rebuilt` 的可执行 STEP 证据;当前 checked-in
manifest 保留的是历史选择时的 `3` 个 engine baseline / `17` 个 conversion
样本分类。因此,开始严格回归前必须重新生成并核对 manifest,不能将旧分类
当作当前通过结果。
- 当前全量报告中仍有 `rebuild_failed``rebuild_timeout``comparison_timeout`
`rebuilt_rejected`。这些都是待消除的问题,不可因保留了可执行 STEP 而视为完成。
## 验收口径
一个样本必须同时具备以下工件和结果,才计入严格重建成功:
1. `candidate.cdsl.json` 通过 schema 和语义验证,且没有以跳过后续几何换取成功。
2. `bound.cdsl.json`(若样本需要 selector 绑定)可复现产生,绑定证据可追溯到
feature、owner 和 topology snapshot。
3. `rebuild.step` 由完整的 CDSL feature history 生成;中间失败不能被最终可执行
前缀掩盖。
4. `comparison.json``decision``strict_pass`,并同时满足:
`surface_max <= 0.01 mm``surface_p99 <= 0.01 mm`
`bbox_max_delta <= 0.01 mm`、体积和表面积相对误差均不超过 `1e-5`,以及实体数相等。
5. `status.json`、转换诊断、重建报告和比较报告保存于该样本目录;可执行但不相似
的模型必须保留,而不能在失败时删除。
`approximate_pass` 仅用于定位接近结果,不能通过本计划的 17 样本或全量目标。
缺失原始 STEP、源数据损坏或外部资产不可取得时,必须单独列为 source exception
附原始证据;不得伪装成 engine 或 converter 已完成。
## 第一阶段:17 个代表样本
阶段完成条件:下面 17 个样本全部以 `--compare-mode strict` 重新执行并严格通过,
同时 manifest 的覆盖标签与当前输出一致。每次修复只针对实际失败根因扩展能力;
一个样本可能由多个能力共同阻塞。
| 样本 | 代表的操作或几何 | 当前需要验证或补齐的门槛 |
| --- | --- | --- |
| `00002243` | extrude、hole、chamfer | 孔宿主、端盖/边 selector、倒角参数与稳定选边。 |
| `00035682` | cPlane、extrude、revolve | `line_point` 基准面及其后续特征引用。 |
| `00054089` | extrude、revolve | `swept_edge` selector。 |
| `00111611` | extrude、revolve、fillet、chamfer | 复杂草图、回转轴、圆角和倒角的拓扑稳定性。 |
| `00129362` | cPlane、extrude、revolve | 可复用基准面 frame、回转 add/cut。 |
| `00159804` | circularPattern、through-all add | 环形阵列 replay、`line_angle` 和 through-all 加料。 |
| `00192744` | circularPattern、extrude | 已执行 `curve_point` 附着 frame、`up_to_next` 和 pattern copy deletion;仍缺多 body transform 与 source/STEP 一致性证据。 |
| `00212904` | shell、two-sided cut | shell、`three_point`、双向切除终止条件。 |
| `00287955` | loft、cPlane、revolve | 多 profile loft、`cap_edge``mid_plane`。 |
| `00423838` | circularPattern、extrude | `up_to_vertex` 与 two-sided cut 的非盲终止。 |
| `00542223` | shell、sweep、circularPattern | sweep path/profile、shell、`plane_point`、through-all cut。 |
| `00612529` | loft、mirror、cPlane | loft profile 对齐、镜像 replay、`mid_cap_edge`。 |
| `00694309` | extrude、fillet | `up_to_body` 和 feature 后继 selector。 |
| `00710855` | extrude、chamfer | 曲面拉伸与 surface/solid 混合拓扑。 |
| `00789939` | shell、extrude、fillet | shell、`offset_face``swept_face`。 |
| `00835610` | booleanBodies、revolve | 多 body boolean、`cap_face``intersect`、revolve surface。 |
| `00925274` | mirror、through-all cut | 镜像 replay、`up_to_surface``line_angle`。 |
推荐的完整验收命令:
```bash
PYTHONPATH=backend:. python -m cadfs_to_cdsl regression \
--tier all --stage pipeline --compare-mode strict --timeout-seconds 60
```
命令结束后应检查 17 个 `comparison.json`,而不是只检查 CLI 的进程退出码。
## 能力完成目标
能力的完成定义为同一能力的 CADFS lowering、CDSL contract、runtime/adapter、
selector 或 body 语义(需要时)、严格比较回归均具备。仅实现其中一层时只能记为
“部分完成”。优先级依据全量影响数和 17 样本依赖关系确定。
### P0:使特征历史成为完整实体
1. 复杂草图与 B-spline:稳定的多 wire、孔洞、闭合排序、退化诊断;开口 wire
可作为 sweep path,闭合 wire 可作为实体 profile。
2. 显式多 body 结果模型:body ID、选择集、复制、保留和生命周期;这是
`booleanBodies`、transform、pattern copy 的前置条件。
3. sweep、完整 loft、shell 和 booleanBodies:包含实体/曲面模式、导轨或 profile
对齐、移除面、结果 body 和可诊断的失败边界。
4. circularPattern 与 mirrorsource replay、完整轴/面引用和嵌套 pattern;实例必须
是独立的几何结果,不能依赖隐式全局主体。
### P0:补齐拉伸终止语义
按全量影响数优先完成复杂 `extrude`,以及 through-all、two-sided、up-to-surface、
up-to-next、up-to-body、up-to-vertex 和 surface/mixed 模式。终止距离必须由目标 body
的真实相交结果或明确的源参考决定,不能用固定默认值近似。
### P1:让后续特征引用稳定
1. 记录内核拓扑 delta`preserved``modified``generated``deleted` 和 feature
输出角色;不唯一的后继必须诊断为歧义。
2. 补齐 CAP、SWEPT、OFFSET、INTERSECT、MID_CAP 的 face/edge selector,并保留
owner 与几何签名。
3. 完成 line-angle、plane-point、three-point、mid-plane、line-point、curve-point
基准面,覆盖曲面和曲线切线引用及退化输入。
4. 完成 fillet、chamfer、hole、revolve/revolve_surface 的参数变体、可行性预检查
和拓扑后继处理。
### P2:清除全量语料的剩余 FeatureScript 缺口
实现 draft、thicken、split、moveFace、deleteFace、replaceFace、完整 transform
以及 derive/import 的受控外部资产协议。任何依赖外部模型的操作只有在资产身份、
输入、版本和失败方式均可复现时,才能计入全量成功。
## 实现参考与边界
`/Users/lk/Downloads/SimpleCADAPI-master 4` 可作为 OCP 调用和拓扑建模的实现
参考,而不是可直接替换的 engine:
- sweep、loft、shell 分别可参考 `BRepOffsetAPI_MakePipeShell`
`BRepOffsetAPI_ThruSections` 和 shell builder 的调用边界、失败处理和 shape
输出方式。
- boolean、fillet、chamfer 应参考其内核 `Modified``Generated``IsDeleted`
的拓扑 delta 采集方式,落实本计划要求的 feature 输出角色和 selector 后继证据。
- transform 和 pattern 应作用于明确的 shape/body 输入,并保留每个实例的独立结果。
SimpleCADAPI 强制将 boolean 结果收敛为单个 `Solid`,不满足 CADFS 的多 body、
保留工具体、复制和 body 生命周期语义。实现时只能借鉴 OCP 调用与拓扑证据模型,
必须保留本项目的 CDSL schema、runtime adapter 和显式 body-result contract。
## Source Exception Evidence
严格重建失败必须先按下表归类。只有“已验证 source exception”能够从全量
成功分母移除;候选项仍是未完成样本,必须保留 source、CDSL、STEP 和比较工件。
| 分类 | 样本 | 当前证据 | 处理要求 |
| --- | --- | --- | --- |
| 已验证 source exception | 无 | 当前 17 样本没有已经证明可豁免的 source exception。 | 不得从验收分母移除。 |
| engine / converter 缺陷(受限路径已修复) | `00710855` | F5 的 CADFS IMPRINT cut 已由错误圆盘改为 `r=19..25.5 mm` 环。对于 F10runtime 仅在显式同轴 surface shell 在 selected plane 提供内边界证据时,才以源 `width=10 mm` 构造受限圆锥倒角;完整 history 已可执行。 | 将该受限模式扩展为一般 surface/solid split、topology delta 和后继 selector;不得缩小源倒角宽度。 |
| source / STEP 精度候选 | `00710855` | 完整 history 的 surface 最大差为 `0.0035 mm`,面积、bbox 与实体数满足 strict;但 source F8 E14 是 `r=22 mm`gold STEP 对应圆柱是 `r=21.9965 mm`,导致体积相对误差 `7.998e-5` 超过 strict。 | 保存 source literal 与 STEP 测量证据;未获得 source export 版本证明前不得回填 gold 半径,也不得从验收分母移除。 |
| source / STEP 差异候选 | `00035682` | FeatureScript 圆柱/孔径 literal 的精度低于原 STEP 的测量值;现有拓扑类型和数量相同,但严格体积/面积不一致。 | 保存原始精度证据;复核 source 导出版本后才能定为 exception。 |
| source / STEP 差异候选 | `00192744` | F3/F5 的 frame 与 `UP_TO_NEXT` 已修复,F7/F12 的 `COPY(BODY)` 删除也已 replay,完整 history 可执行且 bbox 对齐;但 source F15 将 F1 移动约 `-10000 mm`gold STEP 仍位于约 `y=[-500,500]`。按 source 缺省 `NEW` 保留 8 个实体,gold 只有 1 个,体积相对误差仍为 `1.001e-3`。 | 保持严格失败并完成通用 multi-body transform/body lifecycle;不以 gold STEP 反推、伪造 transform 参数或把 source `NEW` 改成 `ADD`。 |
| source / STEP 差异候选 | `00212904` | 原 STEP 出现 source FeatureScript history 未表达的正交 cut strip。 | 保留对比工件;除非 source 能提供缺失 feature,不得在 converter 伪造几何。 |
| source / STEP 精度候选 | `00835610` | 完整 history 可执行且 RP 通过,严格最大表面差约 `0.014 mm`FeatureScript 值与 STEP 存在约 `0.014 mm` 的 literal 差异。 | 不能从 gold STEP 回填尺寸;继续标为严格失败,等待 source 版本证据。 |
## 全量完成门槛
在 17 样本严格通过后,按 capability family 对全量语料滚动运行,并按失败原因选择
新的最小代表样本。最终报告必须以扫描快照中的全部样本为分母,同时满足:
1. 每个有效源样本生成语义有效、可执行的 CDSL;`parse_failed`
`unsupported_operation``unsupported_engine_capability` 和因 converter 丢失
feature 而导致的 `converted_partial` 均为零。
2. 每个拥有原始 STEP 的样本生成完整 `rebuild.step``rebuild_failed`
`rebuild_timeout` 均为零。
3. 每个可比较样本严格通过比较;`comparison_timeout``rebuilt_rejected` 和仅
`rebuilt_approximate` 均不计入完成。
4. 任何合法 source exception 都有单独清单、原始证据和可复现原因;它不进入成功
分母,也不得吞并为“未分类失败”。
5. 生成全量 capability-gap、比较汇总和逐样本证据,确保任一退化都可定位到
converter、engine、源数据或比较基础设施。
17 个样本覆盖了已观察到的特征类别,但不是全量正确性的数学证明。只有滚动的
全量比较通过,才能声称完成全量重建。
## 每次修复的执行规则
1. 先重跑发生退化的代表样本,并读取 `history.json``candidate.cdsl.json`
`bound.cdsl.json``rebuild.json``comparison.json`
2. 将问题归类为:CADFS parser/lowering 丢失或误译、CDSL schema/contract 无表达、
engine/adapter 几何能力不足、selector/body 引用错误、源数据异常,或比较基础设施
问题。证据不足时不得猜测分类。
3. 只在责任层修复:转换问题修改 CADFS lowering;执行问题扩展 engine;两者都涉及
时分开提交证据和测试。保留最新可执行 STEP/GLB 和失败诊断。
4. 每项代码改动至少重跑受影响样本、17 样本严格回归和相应单元测试;能力状态与
证据同步到本地能力台账。全量统计变化后重新生成 `regression/manifest.json`
5. 不以关闭诊断、跳过 feature、降低 strict 阈值或用未说明的默认尺寸换取通过。
## 阶段交付物
| 阶段 | 必须交付 | 通过条件 |
| --- | --- | --- |
| R0:证据稳定 | 刷新的 regression manifest、17 份完整工件 | 每个样本可重复运行并可定位当前失败。 |
| R1:代表样本 | 17 个严格比较报告、能力台账更新 | `17/17 strict_pass`。 |
| R2:能力扩展 | 按能力 family 的 converter/engine/selector 实现及回归 | 对应缺口不再造成该 family 的失败。 |
| R3:全量验证 | 全量 CDSL、STEP、比较结果和汇总报告 | 满足“全量完成门槛”的五项条件。 |
+15
View File
@@ -15,6 +15,21 @@ All stages are resumable. Use `--force` after changing converter behavior.
The original CADFS directory is read-only; generated evidence is written under The original CADFS directory is read-only; generated evidence is written under
`cadfs_to_cdsl/output/samples/<sample_id>/`. `cadfs_to_cdsl/output/samples/<sample_id>/`.
## Full corpus one-command runner
Run the full conversion, rebuild, comparison, and Markdown report with:
```bash
./cadfs_to_cdsl/run_full_rebuild.sh
```
The default is a forced fresh pass. Before it begins, the previous
`cadfs_to_cdsl/output` directory is moved to
`cadfs_to_cdsl/output-history/<timestamp>/`; it is never deleted or mixed
with the new result. Use `--resume` to reuse the existing output without
archiving it, or `--compare-mode strict` for strict acceptance. See
`./cadfs_to_cdsl/run_full_rebuild.sh --help` for worker and timeout options.
## Representative regression pool ## Representative regression pool
Generate the checked-in representative manifest after a full corpus conversion: Generate the checked-in representative manifest after a full corpus conversion:
+13 -6
View File
@@ -9,11 +9,13 @@ RP = {"surface_mm": 0.02, "bbox_mm": 0.02, "volume_relative": 0.005, "area_relat
def _assess(report: dict[str, Any], limits: dict[str, float]) -> dict[str, Any]: def _assess(report: dict[str, Any], limits: dict[str, float]) -> dict[str, Any]:
surface = report["surface"]; metrics = report["metrics"] surface = report["surface"]; metrics = report["metrics"]
maximum = max(surface["gold_to_rebuilt"]["max_mm"], surface["rebuilt_to_gold"]["max_mm"]) maximum = [surface["gold_to_rebuilt"]["max_mm"], surface["rebuilt_to_gold"]["max_mm"]]
p99 = max(surface["gold_to_rebuilt"]["p99_mm"], surface["rebuilt_to_gold"]["p99_mm"]) p99 = [surface["gold_to_rebuilt"]["p99_mm"], surface["rebuilt_to_gold"]["p99_mm"]]
surface_ready = all(isinstance(value, (int, float)) for value in [*maximum, *p99])
checks = { checks = {
"surface_max": maximum <= limits["surface_mm"], "surface_p99": p99 <= limits["surface_mm"], "surface_max": surface_ready and max(maximum) <= limits["surface_mm"],
"bbox": metrics["bbox_max_delta_mm"] <= limits["bbox_mm"], "surface_p99": surface_ready and max(p99) <= limits["surface_mm"],
"bbox": isinstance(metrics["bbox_max_delta_mm"], (int, float)) and metrics["bbox_max_delta_mm"] <= limits["bbox_mm"],
"volume": metrics["volume_relative_error"] <= limits["volume_relative"], "volume": metrics["volume_relative_error"] <= limits["volume_relative"],
"area": metrics["surface_area_relative_error"] <= limits["area_relative"], "area": metrics["surface_area_relative_error"] <= limits["area_relative"],
"solid_count": metrics["gold_solid_count"] == metrics["rebuilt_solid_count"], "solid_count": metrics["gold_solid_count"] == metrics["rebuilt_solid_count"],
@@ -22,8 +24,13 @@ def _assess(report: dict[str, Any], limits: dict[str, float]) -> dict[str, Any]:
def compare_steps(gold_step: Path, rebuilt_step: Path, *, surface_tessellation_mm: float = 0.05) -> dict[str, Any]: def compare_steps(gold_step: Path, rebuilt_step: Path, *, surface_tessellation_mm: float = 0.05) -> dict[str, Any]:
from onshape_to_cdsl.src.onshape_to_cdsl.compare import strict_compare from onshape_to_cdsl.compare import strict_compare
raw = strict_compare(gold_step, rebuilt_step, surface_tolerance_mm=surface_tessellation_mm) raw = strict_compare(
gold_step,
rebuilt_step,
surface_tolerance_mm=surface_tessellation_mm,
fast_reject_relative_error=RP["volume_relative"],
)
strict, rp = _assess(raw, STRICT), _assess(raw, RP) strict, rp = _assess(raw, STRICT), _assess(raw, RP)
decision = "strict_pass" if strict["passed"] else "approximate_pass" if rp["passed"] else "rejected" decision = "strict_pass" if strict["passed"] else "approximate_pass" if rp["passed"] else "rejected"
return {"schema": "cadfs_to_cdsl.comparison.v1", "gold_step": str(gold_step), "rebuilt_step": str(rebuilt_step), "raw": raw, "strict": strict, "rp": rp, "decision": decision} return {"schema": "cadfs_to_cdsl.comparison.v1", "gold_step": str(gold_step), "rebuilt_step": str(rebuilt_step), "raw": raw, "strict": strict, "rp": rp, "decision": decision}
+30 -7
View File
@@ -63,21 +63,44 @@ class Parser:
return left return left
def statements(self) -> list[Call]: def statements(self) -> list[Call]:
found: list[Call] = []; environment: dict[str, Any] = {} found: list[Call] = []; scopes: list[dict[str, Any]] = [{}]
def environment() -> dict[str, Any]:
resolved: dict[str, Any] = {}
for scope in scopes: resolved.update(scope)
return resolved
def assignment_scope(name: str) -> dict[str, Any]:
return next((scope for scope in reversed(scopes) if name in scope), scopes[-1])
while self.peek().kind != "eof": while self.peek().kind != "eof":
if self.accept("{"):
scopes.append({}); continue
if self.accept("}"):
if len(scopes) > 1: scopes.pop()
continue
if self.peek().value == "var":
self.pop()
if self.peek().kind == "ident":
name = self.pop().value
if self.accept("="):
scopes[-1][name] = _resolve(self.expression(), environment())
else: scopes[-1][name] = name
if isinstance(scopes[-1][name], Call) and scopes[-1][name].name == "newSketch":
found.append(scopes[-1][name])
continue
if self.peek().kind == "ident" and self.i + 1 < len(self.tokens) and self.tokens[self.i + 1].value == "=": if self.peek().kind == "ident" and self.i + 1 < len(self.tokens) and self.tokens[self.i + 1].value == "=":
name = self.pop().value; self.pop() name = self.pop().value; self.pop()
try: try:
environment[name] = self.expression() scope = assignment_scope(name); scope[name] = _resolve(self.expression(), environment())
if isinstance(environment[name], Call) and environment[name].name == "newSketch": if isinstance(scope[name], Call) and scope[name].name == "newSketch":
value = environment[name] value = scope[name]
value.args = [_resolve(arg, environment) for arg in value.args]
found.append(value) found.append(value)
except Exception: pass except Exception: pass
elif self.peek().kind == "ident" and self.i + 1 < len(self.tokens) and self.tokens[self.i + 1].value == "(": elif self.peek().kind == "ident" and self.i + 1 < len(self.tokens) and self.tokens[self.i + 1].value == "(":
value = self.expression() value = self.expression()
if isinstance(value, Call): if isinstance(value, Call):
value.args = [_resolve(arg, environment) for arg in value.args] value.args = [_resolve(arg, environment()) for arg in value.args]
found.append(value) found.append(value)
else: self.i += 1 else: self.i += 1
return found return found
@@ -119,7 +142,7 @@ def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR:
if model.sketches: if model.sketches:
args = _arg_map(call); eid = str(call.args[1]) if len(call.args) > 1 else f"E{len(model.sketches[-1].entities)}" args = _arg_map(call); eid = str(call.args[1]) if len(call.args) > 1 else f"E{len(model.sketches[-1].entities)}"
model.sketches[-1].entities.append(FeatureIR(eid, call.name, args, line_start=call.line, raw_source=call.name)) model.sketches[-1].entities.append(FeatureIR(eid, call.name, args, line_start=call.line, raw_source=call.name))
elif call.name in {"extrude", "revolve", "fillet", "chamfer", "hole", "linearPattern", "mirror", "cPlane", "referenceAxis", "shell", "loft", "sweep", "circularPattern", "booleanBodies", "transform"}: elif call.name in {"extrude", "revolve", "fillet", "chamfer", "hole", "linearPattern", "mirror", "cPlane", "referenceAxis", "shell", "loft", "sweep", "circularPattern", "booleanBodies", "deleteBodies", "transform", "draft", "thicken", "split", "moveFace", "replaceFace", "deleteFace", "derive"}:
fid = symbolic_string(call.args[1]) if len(call.args) > 1 else f"feature_{len(model.features)}" fid = symbolic_string(call.args[1]) if len(call.args) > 1 else f"feature_{len(model.features)}"
feature_ir = FeatureIR(fid, call.name, _arg_map(call), line_start=call.line, raw_source=call.name) feature_ir = FeatureIR(fid, call.name, _arg_map(call), line_start=call.line, raw_source=call.name)
model.features.append(feature_ir); model.steps.append(feature_ir) model.features.append(feature_ir); model.steps.append(feature_ir)
+2058 -84
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -102,7 +102,7 @@ def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_sec
def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_seconds: float = 30.0) -> dict[str, Any]: def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_seconds: float = 30.0) -> dict[str, Any]:
directory = _sample_dir(output, sample.sample_id); status_path = directory / "status.json" directory = _sample_dir(output, sample.sample_id); status_path = directory / "status.json"
status = read_json(status_path) if status_path.exists() else convert_one(sample, output, force=force) status = convert_one(sample, output, force=True) if force else read_json(status_path) if status_path.exists() else convert_one(sample, output)
# A partial conversion may still retain a self-contained, semantically # A partial conversion may still retain a self-contained, semantically
# executable CDSL prefix. That prefix is valuable engine evidence and # executable CDSL prefix. That prefix is valuable engine evidence and
# must be rebuilt instead of being hidden behind the conversion label. # must be rebuilt instead of being hidden behind the conversion label.
@@ -146,7 +146,7 @@ def compare_one(sample: Sample, output: Path, *, force: bool = False, compare_mo
else: comparison = read_json(comparison_path) else: comparison = read_json(comparison_path)
status["comparison_decision"] = comparison["decision"] status["comparison_decision"] = comparison["decision"]
accepted = comparison[compare_mode]["passed"] accepted = comparison[compare_mode]["passed"]
status["status"] = "rebuilt_approximate" if accepted else "rebuilt_rejected" status["status"] = "rebuilt_strict" if comparison["strict"]["passed"] else "rebuilt_approximate" if accepted else "rebuilt_rejected"
write_json(status_path, status); return status write_json(status_path, status); return status
+2 -1
View File
@@ -45,7 +45,8 @@ def parse_query(value: Any) -> QueryInfo:
elif "isStart" in definition: info.is_start = bool(definition["isStart"]) elif "isStart" in definition: info.is_start = bool(definition["isStart"])
if call.name in {"sQuery", "sketchEntityQuery"} and len(call.args) >= 3: if call.name in {"sQuery", "sketchEntityQuery"} and len(call.args) >= 3:
sketch = symbolic_string(call.args[0]); info.source_sketch = sketch.split(".", 1)[0] sketch = symbolic_string(call.args[0]); info.source_sketch = sketch.split(".", 1)[0]
info.kind = str(call.args[1]).lower(); info.source_entity = str(call.args[2]) if info.topology_type is None: info.kind = str(call.args[1]).lower()
info.source_entity = str(call.args[2])
if call.name == "qSketchRegion" and call.args: if call.name == "qSketchRegion" and call.args:
info.source_sketch = symbolic_string(call.args[0]); info.kind = "face" info.source_sketch = symbolic_string(call.args[0]); info.kind = "face"
return info return info
+11 -4
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
import csv, json, re import csv, json, re, tempfile
from collections import Counter, defaultdict from collections import Counter, defaultdict
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -9,9 +9,16 @@ from typing import Any
def write_json(path: Path, value: Any) -> None: def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp") temporary: Path | None = None
temporary.write_text(json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8") try:
temporary.replace(path) with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False,
) as handle:
temporary = Path(handle.name)
handle.write(json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True) + "\n")
temporary.replace(path)
finally:
if temporary is not None: temporary.unlink(missing_ok=True)
def read_json(path: Path) -> Any: return json.loads(path.read_text(encoding="utf-8")) def read_json(path: Path) -> Any: return json.loads(path.read_text(encoding="utf-8"))
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Run the complete CADFS corpus while retaining a snapshot of the prior run.
set -Eeuo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
INPUT_DIR="${ROOT_DIR}/data/cadfs-sample/CADFS_test"
OUTPUT_DIR="${ROOT_DIR}/cadfs_to_cdsl/output"
HISTORY_DIR="${ROOT_DIR}/cadfs_to_cdsl/output-history"
WORKERS=4
TIMEOUT_SECONDS=60
COMPARE_MODE=rp
FORCE=true
usage() {
cat <<'EOF'
Usage: ./cadfs_to_cdsl/run_full_rebuild.sh [options]
Runs conversion, CDSL rebuild, STEP comparison, and the Markdown report for
every sample in data/cadfs-sample/CADFS_test.
By default the existing cadfs_to_cdsl/output directory is moved to
cadfs_to_cdsl/output-history/<timestamp>/ before a forced full rerun.
Options:
--resume Reuse cached results in output; do not archive or force rerun.
--workers N Concurrent samples (1-8, default: 4).
--timeout-seconds N Per-model rebuild timeout in seconds (default: 60).
--compare-mode rp|strict Comparison acceptance mode (default: rp).
-h, --help Show this help message.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--resume)
FORCE=false
shift
;;
--workers)
[[ $# -ge 2 ]] || { echo "--workers requires a value" >&2; exit 2; }
WORKERS="$2"
shift 2
;;
--timeout-seconds)
[[ $# -ge 2 ]] || { echo "--timeout-seconds requires a value" >&2; exit 2; }
TIMEOUT_SECONDS="$2"
shift 2
;;
--compare-mode)
[[ $# -ge 2 ]] || { echo "--compare-mode requires a value" >&2; exit 2; }
COMPARE_MODE="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
[[ "$WORKERS" =~ ^[1-8]$ ]] || { echo "--workers must be an integer between 1 and 8" >&2; exit 2; }
[[ "$TIMEOUT_SECONDS" =~ ^[0-9]+([.][0-9]+)?$ ]] && (( $(awk "BEGIN { print (${TIMEOUT_SECONDS} > 0) }") )) || {
echo "--timeout-seconds must be positive" >&2
exit 2
}
[[ "$COMPARE_MODE" == "rp" || "$COMPARE_MODE" == "strict" ]] || {
echo "--compare-mode must be rp or strict" >&2
exit 2
}
[[ -d "$INPUT_DIR" ]] || { echo "CADFS input directory not found: $INPUT_DIR" >&2; exit 1; }
[[ -f "$INPUT_DIR/CADFS_text_test.jsonl" ]] || { echo "CADFS text manifest not found: $INPUT_DIR/CADFS_text_test.jsonl" >&2; exit 1; }
if [[ -x "${ROOT_DIR}/backend/.venv/bin/python" ]]; then
PYTHON_BIN="${PYTHON_BIN:-${ROOT_DIR}/backend/.venv/bin/python}"
else
PYTHON_BIN="${PYTHON_BIN:-python3}"
fi
command -v "$PYTHON_BIN" >/dev/null 2>&1 || { echo "Python executable not found: $PYTHON_BIN" >&2; exit 1; }
archive_output() {
[[ -d "$OUTPUT_DIR" ]] || return
[[ -n "$(find "$OUTPUT_DIR" -mindepth 1 -maxdepth 1 -print -quit)" ]] || return
mkdir -p "$HISTORY_DIR"
local timestamp archive_dir suffix=1
timestamp="$(date +%Y%m%d-%H%M%S)"
archive_dir="${HISTORY_DIR}/${timestamp}"
while [[ -e "$archive_dir" ]]; do
archive_dir="${HISTORY_DIR}/${timestamp}-${suffix}"
((suffix += 1))
done
mv "$OUTPUT_DIR" "$archive_dir"
echo "Previous output archived at: $archive_dir"
}
on_error() {
local code=$?
echo "Run stopped with exit code ${code}. Partial artifacts remain in: $OUTPUT_DIR" >&2
exit "$code"
}
trap on_error ERR
if [[ "$FORCE" == true ]]; then
archive_output
echo "Starting forced full corpus rebuild."
else
echo "Resuming existing full corpus rebuild without archiving output."
fi
mkdir -p "$OUTPUT_DIR"
cd "$ROOT_DIR"
pipeline_args=(
pipeline
--input "$INPUT_DIR"
--output "$OUTPUT_DIR"
--workers "$WORKERS"
--timeout-seconds "$TIMEOUT_SECONDS"
--compare-mode "$COMPARE_MODE"
)
if [[ "$FORCE" == true ]]; then
pipeline_args+=(--force)
fi
PYTHONPATH="${ROOT_DIR}/backend:${ROOT_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \
"$PYTHON_BIN" -m cadfs_to_cdsl "${pipeline_args[@]}"
PYTHONPATH="${ROOT_DIR}/backend:${ROOT_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \
"$PYTHON_BIN" -m cadfs_to_cdsl report --input "$INPUT_DIR" --output "$OUTPUT_DIR" --markdown
echo "Current results: $OUTPUT_DIR"
echo "Full report: $OUTPUT_DIR/full_run_report.md"
+88 -30
View File
@@ -9,23 +9,40 @@ from typing import Any
def _score(expected: dict[str, Any], actual: dict[str, Any]) -> float | None: def _score(expected: dict[str, Any], actual: dict[str, Any]) -> float | None:
scores: list[float] = [] scores: list[float] = []
for key in ("center_mm", "start_mm", "end_mm", "normal", "axis_direction"): reversed_plane_normal = False
for key in ("center_mm", "start_mm", "end_mm", "normal", "axis_origin_mm", "axis_direction"):
if key in expected: if key in expected:
left, right = expected[key], actual.get(key) # plane_offset_mm 与平面方程绑定,必须使用记录平面方程时采用的
# plane_normal。face 的局部采样 normal 在 OCC 中可能与其相反。
use_plane_normal = key == "normal" and "plane_offset_mm" in expected and actual.get("plane_normal") is not None
right = actual.get("plane_normal") if use_plane_normal else actual.get(key)
left = expected[key]
if not isinstance(right, (list, tuple)) or len(left) != len(right): return None if not isinstance(right, (list, tuple)) or len(left) != len(right): return None
delta = math.sqrt(sum((float(a) - float(b)) ** 2 for a, b in zip(left, right))) delta = math.sqrt(sum((float(a) - float(b)) ** 2 for a, b in zip(left, right)))
if key == "normal": if key in {"normal", "axis_direction"}:
# A source CAP_FACE identifies a geometric plane, not the OCC # A source CAP_FACE identifies a geometric plane, not the OCC
# orientation of the resulting face. The two kernels may # orientation of the resulting face. The two kernels may
# report the same cap with inverse normals, especially for a # report the same cap with inverse normals, especially for a
# start cap. Keep axis direction orientation-sensitive. # start cap. A rotational-face axis is likewise a geometric
delta = min(delta, math.sqrt(sum((float(a) + float(b)) ** 2 for a, b in zip(left, right)))) # line, whose direction may be reversed by OCC.
reversed_delta = math.sqrt(sum((float(a) + float(b)) ** 2 for a, b in zip(left, right)))
if key == "normal" and use_plane_normal and reversed_delta < delta: reversed_plane_normal = True
delta = min(delta, reversed_delta)
scores.append(max(0.0, 1.0 - delta / 0.05)) scores.append(max(0.0, 1.0 - delta / 0.05))
for key in ("radius_mm", "plane_offset_mm"): for key in ("radius_mm", "plane_offset_mm"):
if key in expected: if key in expected:
try: delta = abs(float(expected[key]) - float(actual[key])) try:
value = float(actual[key])
# 平面偏移是 normal · point。面法向反向时,同一几何平面的
# 有符号偏移也必须同步反号,不能只放宽 normal 的比较。
if key == "plane_offset_mm" and reversed_plane_normal: value = -value
delta = abs(float(expected[key]) - value)
except Exception: return None except Exception: return None
scores.append(max(0.0, 1.0 - delta / 0.05)) scores.append(max(0.0, 1.0 - delta / 0.05))
if "minimum_area_mm2" in expected:
try:
if float(actual["area_mm2"]) + 1e-6 < float(expected["minimum_area_mm2"]): return None
except Exception: return None
if "bbox_mm" in expected: if "bbox_mm" in expected:
actual_box = actual.get("bbox_mm") actual_box = actual.get("bbox_mm")
if not isinstance(actual_box, (list, tuple)) or len(actual_box) != 6: return None if not isinstance(actual_box, (list, tuple)) or len(actual_box) != 6: return None
@@ -69,30 +86,70 @@ def _circle_records(expected: dict[str, Any], records: list[dict[str, Any]]) ->
return matches return matches
def _binding_targets(selector: dict[str, Any]):
components = selector.get("intersection_of")
if isinstance(components, list):
for component in components:
if isinstance(component, dict): yield from _binding_targets(component)
return
yield selector
def _bound_selector(placeholder: dict[str, Any], records: list[dict[str, Any]]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
bound = []
for record in records:
owners = record.get("owner_feature_ids") or [record.get("feature_id")]
selector = {"kind": placeholder["kind"], "owner_feature_id": str(owners[0]), "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "runtime_snapshot", "confidence": 1.0, "geometry": record.get("geometry") or {}}
if placeholder.get("binding_feature_id") is not None: selector["binding_feature_id"] = placeholder["binding_feature_id"]
bound.append(selector)
if placeholder.get("match_mode") == "all":
selector = dict(placeholder); selector["matched_selectors"] = bound
return selector, bound
return bound[0], bound
def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]: def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Rebuild every selector-bearing prefix and bind against its active body.""" """Rebuild every selector-bearing prefix and bind against its active body."""
from engine.cdsl_engine.runtime import rebuild_cdsl from engine.cdsl_engine.runtime import rebuild_cdsl
bound = deepcopy(cdsl); evidence = [] bound = deepcopy(cdsl); evidence = []
with tempfile.TemporaryDirectory(prefix="cadfs-bind-") as temporary: with tempfile.TemporaryDirectory(prefix="cadfs-bind-") as temporary:
for index, feature in enumerate(bound.get("features") or []): for index, feature in enumerate(bound.get("features") or []):
placeholders = list(feature.get("selectors") or []) roots = list(feature.get("selectors") or [])
if not placeholders: continue for name in ("end_condition", "reverse_end_condition"):
prefix = deepcopy(bound); prefix["features"] = bound["features"][:index] condition = (feature.get("params") or {}).get(name)
if not prefix["features"]: raise ValueError(f"{feature['id']}: selector has no executable prefix") reference = condition.get("reference") if isinstance(condition, dict) else None
report = rebuild_cdsl(prefix, Path(temporary) / f"prefix-{index}.step", strict=True) if isinstance(reference, dict): roots.append(reference)
body_id = next((item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")), None) targets = [target for root in roots for target in _binding_targets(root)]
records = [ if not targets: continue
item for item in report.get("topology_records") or [] prefix_cache: dict[int, tuple[list[dict[str, Any]], str | None]] = {}
# Reference planes and axes are session context, not body
# topology. They must remain available while binding a mirror def prefix_records(binding_feature_id: str | None, owner_feature_id: str | None) -> list[dict[str, Any]]:
# or extent selector against a body-bearing prefix. prefix_count = index
if item.get("kind") in {"plane", "axis"} if binding_feature_id is not None:
or not body_id binding_index = next((item_index for item_index, item in enumerate(bound["features"][:index]) if item["id"] == binding_feature_id), None)
or item.get("body_id") == body_id if binding_index is None: raise ValueError(f"{feature['id']}: selector binding feature is missing or forward")
or str(item.get("body_id") or "").startswith(f"{body_id}:") prefix_count = binding_index + 1
] if prefix_count not in prefix_cache:
prefix = deepcopy(bound); prefix["features"] = bound["features"][:prefix_count]
if not prefix["features"]: raise ValueError(f"{feature['id']}: selector has no executable prefix")
report = rebuild_cdsl(prefix, Path(temporary) / f"prefix-{index}-{prefix_count}.step", strict=True)
body_id = next((item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")), None)
prefix_cache[prefix_count] = (list(report.get("topology_records") or []), body_id)
all_records, body_id = prefix_cache[prefix_count]
return [
item for item in all_records
# Reference planes and axes are session context, not body
# topology. They must remain available while binding a mirror
# or extent selector against a body-bearing prefix.
if item.get("kind") in {"plane", "axis"}
or not body_id
or item.get("body_id") == body_id
or str(item.get("body_id") or "").startswith(f"{body_id}:")
]
resolved = [] resolved = []
for placeholder in placeholders: for placeholder in targets:
records = prefix_records(placeholder.get("binding_feature_id"), placeholder.get("owner_feature_id"))
geometry = placeholder.get("geometry") or {} geometry = placeholder.get("geometry") or {}
candidates = _circle_records(geometry, records) if geometry.get("source_circle_radius_mm") else [] candidates = _circle_records(geometry, records) if geometry.get("source_circle_radius_mm") else []
if not candidates: if not candidates:
@@ -112,18 +169,19 @@ def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list
scored = [(score, record) for record in pool if (score := _score(geometry, record.get("geometry") or {})) is not None and score >= 0.8] scored = [(score, record) for record in pool if (score := _score(geometry, record.get("geometry") or {})) is not None and score >= 0.8]
scored.sort(key=lambda value: (-value[0], str(value[1].get("record_id")))) scored.sort(key=lambda value: (-value[0], str(value[1].get("record_id"))))
if scored: if scored:
if len(scored) > 1 and abs(scored[0][0] - scored[1][0]) <= 1e-9: if placeholder.get("match_mode") != "all" and len(scored) > 1 and abs(scored[0][0] - scored[1][0]) <= 1e-9:
raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild") raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild")
candidates = [scored[0][1]] candidates = [item[1] for item in scored] if placeholder.get("match_mode") == "all" else [scored[0][1]]
if not candidates: raise ValueError(f"{feature['id']}: selector_not_found after prefix rebuild") if not candidates: raise ValueError(f"{feature['id']}: selector_not_found after prefix rebuild")
for record in candidates: selector, bound_selectors = _bound_selector(placeholder, candidates)
owners = record.get("owner_feature_ids") or [record.get("feature_id")] placeholder.clear(); placeholder.update(selector)
resolved.append({"kind": placeholder["kind"], "owner_feature_id": str(owners[0]), "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "runtime_snapshot", "confidence": 1.0, "geometry": record.get("geometry") or {}}) resolved.extend(bound_selectors)
unique = {selector["stable_id"]: selector for selector in resolved}; feature["selectors"] = list(unique.values()) feature_selectors = feature.get("selectors") or []
unique = {selector["stable_id"]: selector for selector in feature_selectors}; feature["selectors"] = list(unique.values())
if feature.get("atomic_id") == "pattern_mirror": if feature.get("atomic_id") == "pattern_mirror":
planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"] planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"]
if len(planes) != 1: if len(planes) != 1:
raise ValueError(f"{feature['id']}: mirror plane binding is not unique") raise ValueError(f"{feature['id']}: mirror plane binding is not unique")
feature.setdefault("params", {})["mirror_plane"] = planes[0] feature.setdefault("params", {})["mirror_plane"] = planes[0]
evidence.append({"feature_id": feature["id"], "prefix_feature_count": index, "selectors": feature["selectors"]}) evidence.append({"feature_id": feature["id"], "prefix_feature_count": index, "selectors": feature["selectors"], "resolved": resolved})
return bound, evidence return bound, evidence
+158 -6
View File
@@ -23,7 +23,7 @@ class IntegrationTests(unittest.TestCase):
self.assertTrue(comparison["rp"]["passed"]) self.assertTrue(comparison["rp"]["passed"])
self.assertEqual(comparison["decision"], "approximate_pass") self.assertEqual(comparison["decision"], "approximate_pass")
def test_counterbore_abbreviation_00002243(self): def test_standard_tapped_through_counterbore_00002243(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0000/00002243.txt" feature = root / "featurescript_rp/0000/00002243.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed") if not feature.exists(): self.skipTest("CADFS sample is not installed")
@@ -32,13 +32,165 @@ class IntegrationTests(unittest.TestCase):
self.assertEqual(first["atomic_id"], "extrude_add_two_sided") self.assertEqual(first["atomic_id"], "extrude_add_two_sided")
self.assertEqual(first["params"]["distance_mm"], 125.0) self.assertEqual(first["params"]["distance_mm"], 125.0)
self.assertEqual(first["params"]["reverse_distance_mm"], 125.0) self.assertEqual(first["params"]["reverse_distance_mm"], 125.0)
gap = next(item for item in result.diagnostics if item.get("feature_id") == "F3") cut = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")
self.assertEqual(gap["code"], "unsupported_engine_capability") self.assertEqual(cut["atomic_id"], "extrude_cut_two_sided")
self.assertEqual(gap["capability"], "extrude_cut_two_sided")
hole = next(item for item in result.cdsl["features"] if item["atomic_id"] == "hole_wizard") hole = next(item for item in result.cdsl["features"] if item["atomic_id"] == "hole_wizard")
self.assertEqual(hole["params"]["hole_type"], "c_bore") self.assertEqual(hole["params"]["hole_type"], "c_bore")
self.assertEqual(hole["params"]["counterbore"]["diameter_mm"], 17.25) self.assertEqual(hole["params"]["diameter_mm"], 15.0)
self.assertEqual(hole["params"]["counterbore"]["depth_mm"], 10.0) self.assertEqual(hole["params"]["end_condition"]["type"], "through_all")
self.assertNotIn("counterbore", hole["params"])
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
self.assertEqual(rebuild_candidate(result.cdsl, rebuilt)["status"], "rebuilt")
comparison = compare_steps(root / "step_abc/0000/00002243.step", rebuilt)
self.assertTrue(comparison["strict"]["passed"])
def test_rectilinear_fillet_arcs_00129362_preserve_recovered_sketch_geometry(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0012/00129362.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00129362"), {})
profile = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F0")["profile"]
arcs = [segment for segment in profile["contours"][0]["segments"] if segment["type"] == "arc"]
self.assertEqual(sorted(arc["center"] for arc in arcs), [[25.4, 12.7], [25.4, 63.5]])
self.assertTrue(all(abs(arc["radius_mm"] - 12.7) <= 1e-12 for arc in arcs))
triangle = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F5")["profile"]
self.assertEqual(
sorted({coordinate for contour in triangle["contours"] for segment in contour["segments"] for point in (segment.get("start"), segment.get("end")) if point for coordinate in point}),
[-12.7, -6.35, 0.0, 6.35, 12.7, 25.4, 34.925, 41.275],
)
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertEqual(outcome["status"], "rebuilt")
def test_cylindrical_swept_face_selector_00111611(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0011/00111611.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00111611"), {})
fillet = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")
self.assertEqual(fillet["atomic_id"], "fillet")
self.assertEqual(fillet["selectors"][0]["kind"], "face")
self.assertEqual(
fillet["selectors"][0]["geometry"],
{"axis_origin_mm": [0.0, 0.0, 0.0], "axis_direction": [0.0, -1.0, 0.0], "radius_mm": 45.66},
)
chamfer = next(item for item in result.cdsl["features"] if item["id"] == "f_F5")
self.assertEqual(
sorted(selector["geometry"]["axis_origin_mm"] for selector in chamfer["selectors"]),
sorted([[0.0, 25.4, 33.37], [33.38, 25.4, 0.0], [0.0, 25.4, -33.38], [-33.38, 25.4, 0.0]]),
)
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
f4 = next(item for item in result.cdsl["features"] if item["id"] == "f_F4")
self.assertEqual(
sorted(segment["center"] for contour in sketches[f4["sketch_id"]]["profile"]["contours"] for segment in contour["segments"]),
sorted([[0.0, 33.37], [33.38, 0.0], [-33.38, 0.0], [0.0, -33.38]]),
)
f7 = next(item for item in result.cdsl["features"] if item["id"] == "f_F7")
self.assertEqual(
[segment["radius_mm"] for contour in sketches[f7["sketch_id"]]["profile"]["contours"] for segment in contour["segments"]],
[4.48, 2.36],
)
self.assertNotIn("result_mode", next(item for item in result.cdsl["features"] if item["id"] == "f_F7")["params"])
self.assertEqual(next(item for item in result.cdsl["features"] if item["id"] == "f_F9")["params"]["result_mode"], "new_body")
from build123d import import_step
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertEqual(outcome["status"], "rebuilt")
runtime_box = outcome["result"]["bbox_mm"]
imported_box = import_step(str(rebuilt)).bounding_box()
actual = [imported_box.min.X, imported_box.min.Y, imported_box.min.Z, imported_box.max.X, imported_box.max.Y, imported_box.max.Z]
expected = runtime_box["min"] + runtime_box["max"]
for value, target in zip(actual, expected): self.assertAlmostEqual(value, target, places=5)
def test_inward_shells_00789939_lower_and_rebuild(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0078/00789939.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00789939"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F2"]["atomic_id"], "shell")
self.assertEqual(features["f_F5"]["atomic_id"], "shell")
self.assertEqual(features["f_F2"]["params"], {"thickness_mm": 2.5, "inward": True})
self.assertEqual(features["f_F5"]["params"], {"thickness_mm": 2.5, "inward": True})
self.assertNotIn("F2", [item.get("feature_id") for item in result.diagnostics])
self.assertNotIn("F5", [item.get("feature_id") for item in result.diagnostics])
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual(outcome["result"]["solid_count"], 2)
def test_sweep_00542223_preserves_its_open_bspline_path(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0054/00542223.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00542223"), {})
sweep = next(item for item in result.cdsl["features"] if item["id"] == "f_F5")
segment = sweep["params"]["path"]["segment"]
self.assertEqual(sweep["atomic_id"], "sweep_add")
self.assertEqual(sweep["sketch_id"], "sketch_F4")
self.assertEqual(segment["points"], [[-40.0, -50.0], [-20.0, -14.96], [0.0, 5.0]])
self.assertEqual(segment["start_tangent"], [27.94, 92.49])
self.assertEqual(segment["end_tangent"], [52.88, 49.52])
self.assertNotIn("F5", [item.get("feature_id") for item in result.diagnostics])
def test_face_chamfer_ignores_periodic_seams_00111611(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0011/00111611.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
from copy import deepcopy
from engine.cdsl_engine.capabilities import CapabilityAnalyzer, sketch_ids_required_by_contract
from engine.cdsl_engine.runtime import EXECUTORS, ExecutionSession, _execute_node, _selector_edges
from engine.cdsl_engine.sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
from cadfs_to_cdsl.selector_binding import bind_candidate_selectors
candidate = lower_model(parse_featurescript(feature.read_text(), "00111611"), {}).cdsl
bound, _ = bind_candidate_selectors(candidate)
resolved = resolve_required_sketches(
deepcopy(bound), sketch_ids_required_by_contract(bound), errors={},
)
analysis = CapabilityAnalyzer(
atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS,
).analyze(resolved)
session = ExecutionSession(
sketches={str(item["id"]): item for item in resolved["geometry"]["sketches"]},
nodes={node.feature_id: node for node in analysis.plan},
)
for node in analysis.plan:
if node.feature_id == "f_F5":
edges = _selector_edges(node, session, tangent_propagation=True)
break
_execute_node(node, session)
else:
self.fail("F5 chamfer was not planned")
self.assertEqual(len(edges), 8)
self.assertTrue(all(str(edge.geom_type).endswith("CIRCLE") for edge in edges))
def test_circular_remove_pattern_replays_cut_sources_00159804(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0015/00159804.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00159804"), {})
source = next(item for item in result.cdsl["features"] if item["id"] == "f_F8")
pattern = next(item for item in result.cdsl["features"] if item["id"] == "f_F9")
self.assertEqual(source["atomic_id"], "extrude_cut_blind")
self.assertNotIn("result_mode", source["params"])
self.assertEqual(pattern["params"]["operation_mode"], "remove")
if __name__ == "__main__": unittest.main() if __name__ == "__main__": unittest.main()
+530 -13
View File
@@ -1,16 +1,26 @@
from __future__ import annotations from __future__ import annotations
import json, tempfile, unittest import json, math, tempfile, unittest
from pathlib import Path from pathlib import Path
from cadfs_to_cdsl.featurescript_parser import parse_featurescript from cadfs_to_cdsl.featurescript_parser import parse_featurescript
from cadfs_to_cdsl.lowering import lower_model from cadfs_to_cdsl.lowering import _arc, _contours, _global, lower_model
from cadfs_to_cdsl.pipeline import convert_one from cadfs_to_cdsl.pipeline import compare_one, convert_one, rebuild_one
from cadfs_to_cdsl.dataset import Sample from cadfs_to_cdsl.dataset import Sample
from cadfs_to_cdsl.dataset import scan_dataset from cadfs_to_cdsl.dataset import scan_dataset
from cadfs_to_cdsl.tests.test_parser import SOURCE, TRANSFORM_SOURCE from cadfs_to_cdsl.tests.test_parser import SOURCE, TRANSFORM_SOURCE
class LoweringTests(unittest.TestCase): class LoweringTests(unittest.TestCase):
def test_strict_comparison_status_is_not_labeled_approximate(self):
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp); directory = output / "samples" / "strict-status"; directory.mkdir(parents=True)
sample = Sample("strict-status", {"step": str(directory / "gold.step")}, {}, {}, [])
(directory / "rebuild.step").touch(); (directory / "gold.step").touch()
(directory / "status.json").write_text(json.dumps({"sample_id": sample.sample_id, "rebuild_status": "rebuilt"}), encoding="utf-8")
(directory / "comparison.json").write_text(json.dumps({"decision": "strict_pass", "strict": {"passed": True}, "rp": {"passed": True}}), encoding="utf-8")
status = compare_one(sample, output, compare_mode="strict")
self.assertEqual(status["status"], "rebuilt_strict")
def test_missing_dataset_fails_explicitly(self): def test_missing_dataset_fails_explicitly(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(FileNotFoundError): scan_dataset(Path(tmp) / "missing") with self.assertRaises(FileNotFoundError): scan_dataset(Path(tmp) / "missing")
@@ -23,8 +33,20 @@ class LoweringTests(unittest.TestCase):
from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl
self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"])
def test_rectilinear_fillet_arc_recovers_its_exact_endpoint_radius(self):
arc = _arc([38.1, 63.5], [34.38, 72.48], [25.4, 76.2])
self.assertEqual(arc["center"], [25.4, 63.5])
self.assertAlmostEqual(arc["radius_mm"], 12.7)
self.assertFalse(arc["clockwise"])
def test_general_three_point_arc_remains_circumcircle_defined(self):
arc = _arc([0, 0], [2, 3], [5, 0])
self.assertAlmostEqual(arc["center"][0], 2.5)
self.assertAlmostEqual(arc["center"][1], 0.5)
self.assertAlmostEqual(arc["radius_mm"], math.hypot(2.5, 0.5))
def test_unsupported_operation_is_audited_not_invented(self): def test_unsupported_operation_is_audited_not_invented(self):
source = SOURCE.replace('extrude(context, id + "F1",', 'shell(context, id + "F1",') source = SOURCE.replace('extrude(context, id + "F1",', 'draft(context, id + "F1",')
result = lower_model(parse_featurescript(source, "00000173"), {}) result = lower_model(parse_featurescript(source, "00000173"), {})
self.assertEqual(result.status, "deferred_no_executable_feature") self.assertEqual(result.status, "deferred_no_executable_feature")
self.assertIsNone(result.cdsl) self.assertIsNone(result.cdsl)
@@ -86,21 +108,225 @@ class LoweringTests(unittest.TestCase):
self.assertEqual(feature["atomic_id"], expected_atomic) self.assertEqual(feature["atomic_id"], expected_atomic)
self.assertEqual(feature["params"]["end_condition"]["type"], "through_next") self.assertEqual(feature["params"]["end_condition"]["type"], "through_next")
def test_open_nonconstruction_geometry_is_not_silently_dropped(self): def test_implicit_extrude_after_a_body_uses_default_new_body_semantics(self):
source = SOURCE.replace(
'\n});\n',
'''
{ var Q0; Q0=qSketchRegion(id + "F0", true);
extrude(context, id + "F2", {"entities":qUnion([Q0]), "depth":20 * mm}); }
});
''',
)
result = lower_model(parse_featurescript(source, "implicit-add"), {})
first, second = result.cdsl["features"]
self.assertEqual(first["params"]["result_mode"], "new_body")
self.assertEqual(second["params"]["result_mode"], "new_body")
def test_up_to_body_extrude_captures_target_and_offset(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0069/00694309.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00694309"), {})
self.assertEqual(result.status, "converted_complete")
extrude = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")
end = extrude["params"]["end_condition"]
self.assertEqual(end["type"], "up_to_body")
self.assertEqual(end["offset_mm"], 3.0)
self.assertEqual(end["reference"]["kind"], "body")
self.assertEqual(end["reference"]["owner_feature_id"], "f_F1")
def test_up_to_surface_extrude_captures_the_cap_face(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0092/00925274.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00925274"), {})
extrude = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")
end = extrude["params"]["end_condition"]
self.assertEqual(end["type"], "up_to_surface")
self.assertEqual(end["reference"]["kind"], "face")
self.assertEqual(end["reference"]["owner_feature_id"], "f_F1")
self.assertEqual(end["reference"]["geometry"]["plane_offset_mm"], 0.0)
swept = next(item for item in result.cdsl["features"] if item["id"] == "f_F6")
self.assertEqual(swept["params"]["end_condition"]["reference"]["geometry"]["radius_mm"], 24.3)
mirror = next(item for item in result.cdsl["features"] if item["id"] == "f_F18")
self.assertTrue(mirror["params"]["mirror_current_body"])
def test_mirrored_copy_cap_face_uses_the_mirrored_loft_frame(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0061/00612529.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00612529"), {})
sketch = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F6")
plane = sketch["workplane"]
self.assertEqual(plane["origin_mm"], [0.0, 50.0, 0.0])
self.assertEqual(plane["normal"], [0.0, 1.0, 0.0])
self.assertEqual(plane["x_dir"], [-1.0, 0.0, 0.0])
spline = sketch["profile"]["contours"][0]["segments"][0]
first = spline["points"][0]
self.assertEqual([
plane["origin_mm"][index]
+ plane["x_dir"][index] * first[0]
+ (plane["normal"][(index + 1) % 3] * plane["x_dir"][(index + 2) % 3]
- plane["normal"][(index + 2) % 3] * plane["x_dir"][(index + 1) % 3]) * first[1]
for index in range(3)
], [-60.68, 50.0, 66.22])
def test_line_angle_opposite_direction_preserves_the_sketch_side(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0092/00925274.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00925274"), {})
sketch = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F8")
plane = sketch["workplane"]
y_dir = [
plane["normal"][1] * plane["x_dir"][2] - plane["normal"][2] * plane["x_dir"][1],
plane["normal"][2] * plane["x_dir"][0] - plane["normal"][0] * plane["x_dir"][2],
plane["normal"][0] * plane["x_dir"][1] - plane["normal"][1] * plane["x_dir"][0],
]
center = sketch["profile"]["center"]
self.assertAlmostEqual(plane["origin_mm"][2] + y_dir[2] * center[1], 25.0)
def test_line_angle_cylinder_reference_uses_the_surface_generatrix(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0092/00925274.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00925274"), {})
sketch = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == "sketch_F5")
plane = sketch["workplane"]
self.assertAlmostEqual(plane["normal"][0], -0.5)
self.assertAlmostEqual(plane["normal"][1], -math.sqrt(3) / 2)
self.assertAlmostEqual(plane["origin_mm"][0], -12.15)
self.assertAlmostEqual(plane["origin_mm"][1], -24.3 * math.sqrt(3) / 2)
def test_open_nonconstruction_geometry_is_preserved_as_reference(self):
source = SOURCE.replace('skSolve(sketch);', 'skLineSegment(sketch, "open", {"start":v(0, 0) * mm, "end":v(20, 0) * mm}); skSolve(sketch);', 1) source = SOURCE.replace('skSolve(sketch);', 'skLineSegment(sketch, "open", {"start":v(0, 0) * mm, "end":v(20, 0) * mm}); skSolve(sketch);', 1)
result = lower_model(parse_featurescript(source, "open-profile"), {}) result = lower_model(parse_featurescript(source, "open-profile"), {})
self.assertIsNone(result.cdsl) self.assertEqual(result.status, "converted_complete")
self.assertEqual(result.diagnostics[0]["code"], "sketch_deferred") profile = result.cdsl["geometry"]["sketches"][0]["profile"]
self.assertIn("open non-construction", result.diagnostics[0]["message"]) self.assertEqual(profile["type"], "analytic_contours")
self.assertEqual(profile["construction"][0]["type"], "line")
def test_surface_revolve_is_not_disguised_as_solid_revolve(self): def test_open_imprint_profiles_and_mixed_sketch_context_lower_for_00789939(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0078/00789939.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00789939"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
self.assertEqual(features["f_F8"]["sketch_id"], "sketch_F7__f_F8")
self.assertEqual(features["f_F10"]["sketch_id"], "sketch_F9__f_F10")
self.assertEqual(features["f_F12"]["sketch_id"], "sketch_F11")
self.assertFalse(sketches["sketch_F7__f_F8"]["profile"]["contours"][0]["closed"])
self.assertEqual(sketches["sketch_F7__f_F8"]["workplane"]["origin_mm"], [45.7, 0.0, 0.0])
self.assertEqual(sketches["sketch_F9__f_F10"]["workplane"]["origin_mm"], [-44.3, -0.0, -0.0])
self.assertEqual(sketches["sketch_F7__f_F8"]["workplane"]["normal"], [1.0, -0.0, -0.0])
self.assertEqual(sketches["sketch_F9__f_F10"]["workplane"]["normal"], [-1.0, -0.0, -0.0])
self.assertEqual(sketches["sketch_F9__f_F10"]["workplane"]["x_dir"], [-0.0, -1.0, -0.0])
def test_circular_pattern_replays_the_fused_swept_body_history(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0054/00542223.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00542223"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F6"]["params"]["source_feature_ids"], ["f_F1", "f_F5"])
def test_shared_sketch_edge_produces_each_bounded_region(self):
segments = [
{"type": "line", "start": [0, 0], "end": [1, 0]},
{"type": "line", "start": [1, 0], "end": [2, 0]},
{"type": "line", "start": [2, 0], "end": [2, 1]},
{"type": "line", "start": [2, 1], "end": [1, 1]},
{"type": "line", "start": [1, 1], "end": [0, 1]},
{"type": "line", "start": [0, 1], "end": [0, 0]},
{"type": "line", "start": [1, 0], "end": [1, 1]},
]
contours, construction = _contours(segments)
self.assertEqual(len(contours), 2)
self.assertFalse(construction)
self.assertEqual(sorted(len(item["segments"]) for item in contours), [4, 4])
def test_vertex_on_long_line_is_split_before_region_extraction(self):
segments = [
{"type": "line", "start": [0, 0], "end": [2, 0]},
{"type": "line", "start": [2, 0], "end": [2, 2]},
{"type": "line", "start": [2, 2], "end": [0, 2]},
{"type": "line", "start": [0, 2], "end": [0, 0]},
{"type": "line", "start": [2, 0.5], "end": [3, 0.5]},
{"type": "line", "start": [3, 0.5], "end": [3, 1.5]},
{"type": "line", "start": [3, 1.5], "end": [2, 1.5]},
]
contours, construction = _contours(segments)
self.assertEqual(len(contours), 2)
self.assertFalse(construction)
def test_surface_operation_on_a_closed_region_lowers_to_a_solid_revolve(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0095/00957738.txt" feature = root / "featurescript_rp/0095/00957738.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed") if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00957738"), {}) result = lower_model(parse_featurescript(feature.read_text(), "00957738"), {})
gap = next(item for item in result.diagnostics if item.get("feature_id") == "F2") self.assertEqual(result.status, "converted_complete")
self.assertEqual(gap["capability"], "revolve_surface") feature = next(item for item in result.cdsl["features"] if item["id"] == "f_F2")
self.assertIsNone(result.cdsl) self.assertEqual(feature["atomic_id"], "revolve_add")
self.assertNotIn("result_mode", feature["params"])
def test_explicit_surface_body_lowers_to_the_surface_revolve_contract(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0078/00784880.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00784880"), {})
self.assertEqual(result.status, "converted_complete")
feature = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")
self.assertEqual(feature["atomic_id"], "revolve_surface")
self.assertNotIn("result_mode", feature["params"])
def test_mixed_extrude_lowers_selected_circular_surface_wires(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0071/00710855.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00710855"), {})
self.assertEqual(result.status, "converted_complete")
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F1_surface"]["atomic_id"], "extrude_surface")
self.assertEqual(features["f_F1_surface"]["params"], {
"distance_mm": 63.5,
"reverse": False,
"reverse_distance_mm": 63.5,
})
self.assertEqual(features["f_F5_surface"]["atomic_id"], "extrude_surface")
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
radii = [
contour["segments"][0]["radius_mm"]
for contour in sketches["sketch_F0__f_F1_surface"]["profile"]["contours"]
]
self.assertEqual(radii, [19.0, 16.0])
profile = sketches[features["f_F5"]["sketch_id"]]["profile"]
self.assertEqual(profile["type"], "analytic_contours")
self.assertEqual(
[(item["role"], item["segments"][0]["radius_mm"]) for item in profile["contours"]],
[("outer", 25.5), ("inner", 19.0)],
)
def test_mixed_extrude_rebuilds_surface_limited_chamfer_history(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0071/00710855.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00710855"), {})
with tempfile.TemporaryDirectory() as tmp:
from cadfs_to_cdsl.rebuild import rebuild_candidate
rebuilt = rebuild_candidate(result.cdsl, Path(tmp) / "rebuilt.step")
self.assertEqual(rebuilt["status"], "rebuilt")
chamfer = next(item for item in rebuilt["result"]["feature_results"] if item["feature_id"] == "f_F10")
self.assertIn("chamfer_surface_limited", [item["code"] for item in chamfer["diagnostics"]])
self.assertAlmostEqual(rebuilt["result"]["volume_mm3"], 72577.33528261917, places=6)
def test_fit_spline_loft_lowers_to_executable_loft_add(self): def test_fit_spline_loft_lowers_to_executable_loft_add(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -128,6 +354,171 @@ class LoweringTests(unittest.TestCase):
self.assertEqual(rebuilt["status"], "rebuilt") self.assertEqual(rebuilt["status"], "rebuilt")
self.assertGreater(rebuilt["result"]["volume_mm3"], 0) self.assertGreater(rebuilt["result"]["volume_mm3"], 0)
def test_closed_fit_splines_lower_to_periodic_contours_and_join_split_imprints(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0061/00612529.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00612529"), {})
self.assertEqual(result.status, "converted_complete")
self.assertNotIn("F8", {item.get("feature_id") for item in result.diagnostics})
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
for sketch_id in ("sketch_F0", "sketch_F2", "sketch_F5", "sketch_F6"):
segment = sketches[sketch_id]["profile"]["contours"][0]["segments"][0]
self.assertTrue(segment["periodic"])
self.assertEqual(segment["parameterization"], "centripetal")
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F8"]["sketch_id"], "sketch_F6")
def test_open_fit_spline_reversal_preserves_parameter_and_tangent_semantics(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0028/00287955.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00287955"), {})
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
spline = sketches["sketch_F13__f_F14"]["profile"]["contours"][0]["segments"][1]
source_points = [[33.77, 39.06], [38.2, 35.02], [36.58, 30.72], [33.77, 28.66]]
source_parameters = [0.0]
for start, end in zip(source_points, source_points[1:]):
source_parameters.append(source_parameters[-1] + math.sqrt(math.dist(start, end)))
source_parameters = [value / source_parameters[-1] for value in source_parameters]
self.assertEqual(spline["points"], list(reversed(source_points)))
self.assertEqual(spline["start_tangent"], [11.58, -1.2])
self.assertEqual(spline["end_tangent"], [-14.95, 1.46])
self.assertEqual(
spline["parameters"],
[source_parameters[-1] - value for value in reversed(source_parameters)],
)
def test_draft_and_boolean_bodies_preserve_explicit_source_body_selection(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0083/00835610.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00835610"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F1"]["params"]["draft"], {"angle_deg": 49.0, "pull_direction": True})
self.assertEqual(features["f_F3"]["sketch_id"], "sketch_F0__f_F3")
self.assertEqual(features["f_F3"]["params"]["draft"], {"angle_deg": 49.0, "pull_direction": True})
self.assertEqual(features["f_F4"]["atomic_id"], "boolean_bodies")
self.assertEqual(features["f_F4"]["params"], {
"operation": "subtract",
"target_feature_ids": ["f_F3"],
"tool_feature_ids": ["f_F2"],
"keep_tools": False,
})
self.assertEqual(features["f_F8"]["atomic_id"], "revolve_add")
self.assertNotIn("result_mode", features["f_F8"]["params"])
def test_cap_face_profile_preserves_the_selected_circular_output_region(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0083/00835610.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00835610"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
cap_profile = sketches[features["f_F5"]["sketch_id"]]["profile"]
self.assertEqual(features["f_F5"]["sketch_id"], "sketch_F3__f_F5")
self.assertEqual(cap_profile["type"], "analytic_contours")
self.assertEqual([
contour["segments"][0]["radius_mm"]
for contour in cap_profile["contours"]
], [34.0, 32.0])
def test_transformed_cap_face_uses_its_attachment_plane_origin(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0083/00835610.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00835610"), {})
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
plane = sketches["sketch_F14"]["workplane"]
self.assertAlmostEqual(plane["origin_mm"][0], 0.0)
self.assertAlmostEqual(plane["origin_mm"][1], 0.0)
self.assertAlmostEqual(plane["origin_mm"][2], -24.08)
self.assertAlmostEqual(plane["normal"][0], 0.0)
self.assertAlmostEqual(plane["normal"][1], 0.0)
self.assertAlmostEqual(plane["normal"][2], 1.0)
def test_chamfer_tangent_propagation_lowers_to_engine_param(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0000/00002243.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00002243"), {})
chamfer = next(item for item in result.cdsl["features"] if item["id"] == "f_F4")
self.assertTrue(chamfer["params"]["tangent_propagation"])
def test_imprint_profile_query_selects_one_shared_edge_region(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0003/00035682.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00035682"), {})
revolve = next(item for item in result.cdsl["features"] if item["id"] == "f_F1")
self.assertEqual(revolve["sketch_id"], "sketch_F0__f_F1")
selected = next(item for item in result.cdsl["geometry"]["sketches"] if item["id"] == revolve["sketch_id"])
self.assertEqual(len(selected["profile"]["contours"]), 1)
x_coordinates = {point[0] for segment in selected["profile"]["contours"][0]["segments"] for point in (segment["start"], segment["end"])}
self.assertEqual(x_coordinates, {11.68, 12.05})
def test_line_point_plane_uses_the_reference_line_as_its_normal(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0003/00035682.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00035682"), {})
self.assertEqual(result.status, "converted_complete")
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F5"]["atomic_id"], "reference_plane")
self.assertEqual(features["f_F8"]["atomic_id"], "reference_plane")
self.assertEqual(features["f_F7"]["atomic_id"], "extrude_cut_blind")
self.assertEqual(features["f_F10"]["atomic_id"], "extrude_cut_blind")
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
length = math.hypot(10.44, 6.03)
for actual, expected in zip(sketches["sketch_F6"]["workplane"]["normal"], [-10.44 / length, 6.03 / length, 0.0]):
self.assertAlmostEqual(actual, expected)
for actual, expected in zip(sketches["sketch_F6"]["workplane"]["x_dir"], [-6.03 / length, -10.44 / length, 0.0]):
self.assertAlmostEqual(actual, expected)
for actual, expected in zip(sketches["sketch_F9"]["workplane"]["normal"], [10.44 / length, 6.03 / length, 0.0]):
self.assertAlmostEqual(actual, expected)
for actual, expected in zip(sketches["sketch_F9"]["workplane"]["x_dir"], [-6.03 / length, 10.44 / length, 0.0]):
self.assertAlmostEqual(actual, expected)
def test_revolved_swept_face_lowers_to_an_annular_profile(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0005/00054089.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00054089"), {})
self.assertEqual(result.status, "converted_complete")
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F5"]["atomic_id"], "extrude_add_blind")
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
profile = sketches["sketch_F4"]["profile"]
self.assertEqual(profile["type"], "analytic_contours")
radii = {contour["role"]: contour["segments"][0]["radius_mm"] for contour in profile["contours"]}
self.assertEqual(radii, {"outer": 18.0, "inner": 14.0})
def test_intersect_partition_profile_lowers_to_its_bounded_circular_region(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0083/00835610.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00835610"), {})
self.assertEqual(result.status, "converted_partial")
self.assertEqual({item["feature_id"] for item in result.diagnostics}, {"F7", "F12"})
features = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
first = sketches[features["f_F15"]["sketch_id"]]["profile"]
second = sketches[features["f_F16"]["sketch_id"]]["profile"]
self.assertEqual(first["type"], "analytic_contours")
self.assertEqual(
{contour["role"]: contour["segments"][0]["radius_mm"] for contour in first["contours"]},
{"outer": 2.75, "inner": 2.25},
)
self.assertEqual(second, {"type": "circle", "center": [-50.8, 0.0], "radius_mm": 2.25})
def test_direct_translation_transform_updates_the_source_revolve(self): def test_direct_translation_transform_updates_the_source_revolve(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0011/00111611.txt" feature = root / "featurescript_rp/0011/00111611.txt"
@@ -136,10 +527,63 @@ class LoweringTests(unittest.TestCase):
self.assertNotIn("F10", {item.get("feature_id") for item in result.diagnostics}) self.assertNotIn("F10", {item.get("feature_id") for item in result.diagnostics})
features = {item["id"]: item for item in result.cdsl["features"]} features = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
self.assertEqual(features["f_F9"]["params"]["result_mode"], "new_body")
self.assertEqual(sketches[features["f_F9"]["sketch_id"]]["workplane"]["origin_mm"], [77.16, -11.67, -63.0]) self.assertEqual(sketches[features["f_F9"]["sketch_id"]]["workplane"]["origin_mm"], [77.16, -11.67, -63.0])
for actual, expected in zip(features["f_F9"]["params"]["axis"]["origin_mm"], [0.13, -11.67, 56.11]): for actual, expected in zip(features["f_F9"]["params"]["axis"]["origin_mm"], [0.13, -11.67, 56.11]):
self.assertAlmostEqual(actual, expected) self.assertAlmostEqual(actual, expected)
def test_cap_face_workplane_uses_the_physical_cap_normal(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0011/00111611.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00111611"), {})
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
self.assertEqual(sketches["sketch_F2"]["workplane"]["origin_mm"], [0.0, 25.4, 0.0])
self.assertEqual(sketches["sketch_F2"]["workplane"]["normal"], [0.0, 1.0, 0.0])
self.assertEqual(sketches["sketch_F6"]["workplane"]["origin_mm"], [0.0, 0.0, 0.0])
self.assertEqual(sketches["sketch_F6"]["workplane"]["normal"], [0.0, -1.0, 0.0])
def test_nested_circle_imprint_selects_its_bounded_annulus(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0011/00111611.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00111611"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
profile = sketches[features["f_F7"]["sketch_id"]]["profile"]
self.assertEqual(profile["type"], "analytic_contours")
self.assertEqual(
[(item["role"], item["segments"][0]["radius_mm"]) for item in profile["contours"]],
[("outer", 4.48), ("inner", 2.36)],
)
def test_ellipse_profiles_unblock_the_00287955_feature_chain(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0028/00287955.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00287955"), {})
features = {item["id"] for item in result.cdsl["features"]}
feature_by_id = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
self.assertTrue({"f_F3", "f_F5", "f_F6", "f_F11"}.issubset(features))
self.assertEqual(feature_by_id["f_F9"]["atomic_id"], "extrude_add_blind")
self.assertNotIn("selectors", feature_by_id["f_F9"])
mid_plane = feature_by_id["f_F12"]["params"]["plane"]
self.assertAlmostEqual(mid_plane["origin_mm"][0], -18.67, places=5)
self.assertAlmostEqual(mid_plane["normal"][0], 1.0, places=5)
self.assertAlmostEqual(mid_plane["normal"][1], 0.0, places=3)
axis = feature_by_id["f_F14"]["params"]["axis"]
self.assertAlmostEqual(axis["origin_mm"][0], -18.67, delta=0.01)
self.assertAlmostEqual(axis["origin_mm"][1], 33.77, delta=0.01)
for sketch_id in ("sketch_F2", "sketch_F4", "sketch_F7", "sketch_F8"):
segment = sketches[sketch_id]["profile"]["contours"][0]["segments"][0]
self.assertEqual(segment["type"], "ellipse")
self.assertNotIn("sketch_deferred", {item["code"] for item in result.diagnostics})
def test_circular_pattern_lowers_to_existing_engine_contract(self): def test_circular_pattern_lowers_to_existing_engine_contract(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0042/00423838.txt" feature = root / "featurescript_rp/0042/00423838.txt"
@@ -155,9 +599,20 @@ class LoweringTests(unittest.TestCase):
self.assertEqual(pattern["params"]["axis"]["direction"], [0.0, 0.0, -1.0]) self.assertEqual(pattern["params"]["axis"]["direction"], [0.0, 0.0, -1.0])
self.assertNotIn("F2", {item.get("feature_id") for item in result.diagnostics}) self.assertNotIn("F2", {item.get("feature_id") for item in result.diagnostics})
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]} sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
self.assertNotEqual(sketches["sketch_F0"]["workplane"]["normal"], [0.0, 0.0, 1.0]) self.assertNotEqual(sketches[features["f_F1"]["sketch_id"]]["workplane"]["normal"], [0.0, 0.0, 1.0])
self.assertNotIn("circularPattern", {item.get("operation") for item in result.diagnostics}) self.assertNotIn("circularPattern", {item.get("operation") for item in result.diagnostics})
def test_opposed_circle_regions_lower_to_their_union_disk(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0042/00423838.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00423838"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
profile = sketches[features["f_F6"]["sketch_id"]]["profile"]
self.assertEqual(profile["type"], "circle")
self.assertEqual(profile["radius_mm"], 30.0)
def test_reference_plane_variants_lower_to_explicit_frames(self): def test_reference_plane_variants_lower_to_explicit_frames(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
cases = { cases = {
@@ -179,6 +634,57 @@ class LoweringTests(unittest.TestCase):
diagnostics = [item for item in result.diagnostics if item.get("operation") == "cPlane"] diagnostics = [item for item in result.diagnostics if item.get("operation") == "cPlane"]
self.assertFalse(diagnostics) self.assertFalse(diagnostics)
def test_shell_preserves_pattern_copy_cap_faces_and_offset_edge_fillet(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0054/00542223.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00542223"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
shell = features["f_F7"]
fillet = features["f_F8"]
self.assertEqual(sketches["sketch_F4"]["workplane"]["origin_mm"], [0.0, 0.0, -50.0])
self.assertEqual(features["f_F5"]["params"]["path"]["segment"]["points"][0], [-40.0, -50.0])
self.assertEqual(features["f_F5"]["params"]["path"]["segment"]["start_tangent"], [27.94, 92.49])
self.assertEqual(shell["selectors"][1]["geometry"], {"normal": [0.0, 0.0, 1.0], "plane_offset_mm": -50.0})
self.assertEqual(shell["atomic_id"], "shell")
self.assertEqual(len(shell["selectors"]), 4)
self.assertEqual(
[selector["owner_feature_id"] for selector in shell["selectors"][:3]],
["f_F6.c1.f_F5", "f_F5", "f_F6.c2.f_F5"],
)
self.assertEqual(fillet["atomic_id"], "fillet")
self.assertEqual(fillet["selectors"][0]["geometry"]["source_circle_radius_mm"], 17.5)
self.assertNotIn("F7", {item.get("feature_id") for item in result.diagnostics})
self.assertNotIn("F8", {item.get("feature_id") for item in result.diagnostics})
def test_curve_point_plane_uses_its_projected_attachment_origin(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0019/00192744.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00192744"), {})
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
plane = sketches["sketch_F4"]["workplane"]
self.assertEqual(plane["origin_mm"], [0.0, -2000.0, -4000.0])
self.assertAlmostEqual(_global(plane, [0.0, 8944.27])[1], -10000.0, places=2)
self.assertAlmostEqual(_global(plane, [0.0, 8944.27])[2], 0.0, places=2)
def test_delete_pattern_copies_lower_to_explicit_exclusions(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0019/00192744.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00192744"), {})
features = {item["id"]: item for item in result.cdsl["features"]}
self.assertEqual(features["f_F6"]["params"]["excluded_instance_indices"], [2])
self.assertEqual(features["f_F11"]["params"]["excluded_instance_indices"], [2])
self.assertEqual(result.status, "converted_partial")
self.assertEqual(
[(item["feature_id"], item["capability"]) for item in result.diagnostics if item.get("code") == "unsupported_engine_capability"],
[("F15", "transform")],
)
def test_feature_face_profile_is_not_reused_as_original_sketch(self): def test_feature_face_profile_is_not_reused_as_original_sketch(self):
source = SOURCE.replace('qSketchRegion(id + "F0", true)', 'makeQuery(id+"F1.opExtrude","CAP_FACE",FACE,{"isStart":false})') source = SOURCE.replace('qSketchRegion(id + "F0", true)', 'makeQuery(id+"F1.opExtrude","CAP_FACE",FACE,{"isStart":false})')
result = lower_model(parse_featurescript(source, "face-profile"), {}) result = lower_model(parse_featurescript(source, "face-profile"), {})
@@ -197,5 +703,16 @@ class LoweringTests(unittest.TestCase):
self.assertTrue((directory / name).exists(), name) self.assertTrue((directory / name).exists(), name)
self.assertEqual(json.loads((directory / "candidate.cdsl.json").read_text())["part_id"], "00000173") self.assertEqual(json.loads((directory / "candidate.cdsl.json").read_text())["part_id"], "00000173")
def test_force_rebuild_refreshes_the_candidate_from_source(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); source = root / "00000173.txt"; source.write_text(SOURCE)
sample = Sample("00000173", {"featurescript": str(source)}, {"featurescript": "x"})
directory = root / "out/samples/00000173"; directory.mkdir(parents=True)
(directory / "candidate.cdsl.json").write_text(json.dumps({"part_id": "stale"}), encoding="utf-8")
(directory / "status.json").write_text(json.dumps({"sample_id": sample.sample_id}), encoding="utf-8")
status = rebuild_one(sample, root / "out", force=True)
self.assertEqual(status["rebuild_status"], "rebuilt")
self.assertEqual(json.loads((directory / "candidate.cdsl.json").read_text())["part_id"], "00000173")
if __name__ == "__main__": unittest.main() if __name__ == "__main__": unittest.main()
+13
View File
@@ -4,6 +4,7 @@ import unittest
from cadfs_to_cdsl.featurescript_lexer import lex from cadfs_to_cdsl.featurescript_lexer import lex
from cadfs_to_cdsl.featurescript_parser import parse_featurescript from cadfs_to_cdsl.featurescript_parser import parse_featurescript
from cadfs_to_cdsl.ir import Call from cadfs_to_cdsl.ir import Call
from cadfs_to_cdsl.lowering import _queries, _source_refs
from cadfs_to_cdsl.query_parser import parse_query from cadfs_to_cdsl.query_parser import parse_query
from cadfs_to_cdsl.units import length_mm from cadfs_to_cdsl.units import length_mm
@@ -52,6 +53,18 @@ class ParserTests(unittest.TestCase):
self.assertEqual((value.owner_feature, value.source_sketch, value.source_entity), ("F1", "F0", "E0")) self.assertEqual((value.owner_feature, value.source_sketch, value.source_entity), ("F1", "F0", "E0"))
self.assertFalse(value.is_start) self.assertFalse(value.is_start)
def test_block_scoped_query_aliases_do_not_use_the_last_assignment(self):
source = r'''
{ var Q0;
{ var subQ0=sQuery(id + "F2.wireOp", EDGE, "E2"); Q0=makeQuery(id + "F4.opExtrude", "SWEPT_FACE", FACE, {"derivedFrom":subQ0}); }
var Q1;
{ var subQ0=sQuery(id + "F2.wireOp", EDGE, "E4"); Q1=makeQuery(id + "F4.opExtrude", "SWEPT_FACE", FACE, {"derivedFrom":subQ0}); }
chamfer(context, id + "F5", {"entities":qUnion([Q0, Q1]), "width":5 * mm}); }
'''
model = parse_featurescript(source, "scoped-query")
queries = _queries(model.features[0].params["entities"])
self.assertEqual([_source_refs(query)[0] for query in queries], [("F2", "E2"), ("F2", "E4")])
def test_safe_units(self): def test_safe_units(self):
self.assertEqual(length_mm("2 * inch"), 50.8) self.assertEqual(length_mm("2 * inch"), 50.8)
self.assertEqual(length_mm("25.4 / 2 * mm"), 12.7) self.assertEqual(length_mm("25.4 / 2 * mm"), 12.7)
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
import json, tempfile, unittest
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from cadfs_to_cdsl.reports import write_json
class ReportTests(unittest.TestCase):
def test_parallel_json_writes_use_unique_temporary_paths(self):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "summary.json"
with ThreadPoolExecutor(max_workers=8) as executor:
list(executor.map(lambda index: write_json(path, {"index": index}), range(64)))
payload = json.loads(path.read_text(encoding="utf-8"))
self.assertIn(payload["index"], range(64))
self.assertEqual(list(path.parent.glob(".summary.json.*.tmp")), [])
if __name__ == "__main__": unittest.main()
+55 -2
View File
@@ -1,8 +1,11 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import unittest import unittest
from cadfs_to_cdsl.selector_binding import _score from cadfs_to_cdsl.featurescript_parser import parse_featurescript
from cadfs_to_cdsl.lowering import lower_model
from cadfs_to_cdsl.selector_binding import _score, bind_candidate_selectors
class SelectorBindingTests(unittest.TestCase): class SelectorBindingTests(unittest.TestCase):
@@ -13,12 +16,62 @@ class SelectorBindingTests(unittest.TestCase):
) )
self.assertEqual(score, 1.0) self.assertEqual(score, 1.0)
def test_axis_direction_remains_orientation_sensitive(self) -> None: def test_rotational_face_axis_direction_is_orientation_independent(self) -> None:
score = _score( score = _score(
{"axis_direction": [0.0, 0.0, 1.0]}, {"axis_direction": [0.0, 0.0, 1.0]},
{"axis_direction": [0.0, 0.0, -1.0]}, {"axis_direction": [0.0, 0.0, -1.0]},
) )
self.assertEqual(score, 1.0)
def test_axis_origin_distinguishes_parallel_cylinders(self) -> None:
score = _score(
{"axis_origin_mm": [0.0, 25.4, 33.37]},
{"axis_origin_mm": [33.38, 25.4, 0.0]},
)
self.assertEqual(score, 0.0) self.assertEqual(score, 0.0)
def test_empty_snapshot_score_is_not_treated_as_a_match(self) -> None: def test_empty_snapshot_score_is_not_treated_as_a_match(self) -> None:
self.assertEqual(_score({}, {"normal": [0.0, 0.0, 1.0]}), 0.0) self.assertEqual(_score({}, {"normal": [0.0, 0.0, 1.0]}), 0.0)
def test_swept_face_area_lower_bound_rejects_coplanar_fragment(self) -> None:
expected = {"normal": [0.0, 1.0, 0.0], "plane_offset_mm": 54.69, "minimum_area_mm2": 285.0}
self.assertIsNone(_score(expected, {"normal": [0.0, 1.0, 0.0], "plane_offset_mm": 54.69, "area_mm2": 0.64}))
self.assertEqual(_score(expected, {"normal": [0.0, 1.0, 0.0], "plane_offset_mm": 54.69, "area_mm2": 463.7}), 1.0)
def test_reversed_cap_normal_reverses_its_plane_offset(self) -> None:
score = _score(
{"normal": [0.0, 0.0, 1.0], "plane_offset_mm": 10.0},
{
"normal": [0.0, 0.0, 1.0],
"plane_normal": [0.0, 0.0, -1.0],
"plane_offset_mm": -10.0,
},
)
self.assertEqual(score, 1.0)
def test_intersection_vertex_binds_pattern_and_current_body_prefixes(self) -> None:
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0042/00423838.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00423838"), {})
bound, evidence = bind_candidate_selectors(candidate.cdsl)
f7 = next(item for item in bound["features"] if item["id"] == "f_F7")
reference = f7["params"]["end_condition"]["reference"]
components = reference["intersection_of"]
self.assertEqual(len(components), 3)
self.assertEqual([item["binding_feature_id"] for item in components], ["f_F4", "f_F4", "f_F6"])
# 绑定发生在 F4 完整 pattern 前缀;owner 保留 instance 4 的语义来源,
# snapshot 则指向该前缀的 active Compound B-rep。
self.assertEqual([item["owner_feature_id"] for item in components[:2]], ["f_F4.c4.f_F1", "f_F4.c4.f_F1"])
self.assertTrue(all(item["snapshot_id"].startswith("body:f_F4:") for item in components[:2]))
self.assertEqual(components[2]["match_mode"], "all")
# 同一圆柱面可能被前缀 boolean 切成不同数量的 B-rep face;关键是
# match_mode=all 保留每个可匹配片段,而不是把它收缩为任意一个面。
matched = components[2]["matched_selectors"]
self.assertTrue(matched)
self.assertTrue(all(item["owner_feature_id"] == "f_F6" for item in matched))
self.assertTrue(all(item["snapshot_id"].startswith("body:f_F6:") for item in matched))
f7_evidence = next(item for item in evidence if item["feature_id"] == "f_F7")
self.assertEqual(len(f7_evidence["resolved"]), len(matched) + 2)
+71 -19
View File
@@ -16,21 +16,20 @@ def _point(value: Any) -> tuple[float, float, float]:
def _surface_points(shape: Any, tolerance_mm: float) -> list[tuple[float, float, float]]: def _surface_points(shape: Any, tolerance_mm: float) -> list[tuple[float, float, float]]:
"""Deterministically sample vertices and triangle centroids on every face.""" """Deterministically sample tessellation vertices on every B-rep face."""
points: dict[tuple[int, int, int], tuple[float, float, float]] = {} points: dict[tuple[int, int, int], tuple[float, float, float]] = {}
def add(point: tuple[float, float, float]) -> None: def add(point: tuple[float, float, float]) -> None:
points.setdefault(tuple(round(value / 1e-8) for value in point), point) points.setdefault(tuple(round(value / 1e-8) for value in point), point)
for face in shape.faces(): for face in shape.faces():
try: try:
vertices, triangles = face.tessellate(tolerance_mm) vertices, _triangles = face.tessellate(tolerance_mm)
rendered = [_point(vertex) for vertex in vertices] # Tessellation vertices are evaluated on the source B-rep. A
for vertex in rendered: # triangle centroid is generally inside a curved face's chord,
add(vertex) # not on its surface, so including it can reject geometrically
for triangle in triangles: # identical STEP shapes whose face partitions differ.
indices = list(triangle) for vertex in vertices:
if len(indices) >= 3: rendered = _point(vertex)
first, second, third = (rendered[int(index)] for index in indices[:3]) add(rendered)
add(tuple((first[axis] + second[axis] + third[axis]) / 3.0 for axis in range(3)))
except Exception: except Exception:
# A malformed individual face should not make the comparison pass. # A malformed individual face should not make the comparison pass.
for vertex in face.vertices(): for vertex in face.vertices():
@@ -65,22 +64,76 @@ def _percentile(values: list[float], percentile: float) -> float:
return ordered[index] return ordered[index]
def _bbox(shape: Any) -> list[float]: def _bbox(points: list[tuple[float, float, float]]) -> list[float]:
box = shape.bounding_box() """Return B-rep sampled bounds instead of OCC's cached shape bounds."""
return [float(box.min.X), float(box.min.Y), float(box.min.Z), float(box.max.X), float(box.max.Y), float(box.max.Z)] if not points:
raise ValueError("cannot calculate a bounding box without surface points")
return [
min(point[0] for point in points), min(point[1] for point in points), min(point[2] for point in points),
max(point[0] for point in points), max(point[1] for point in points), max(point[2] for point in points),
]
def strict_compare(gold_step: Path, rebuilt_step: Path, *, surface_tolerance_mm: float = 0.05) -> dict[str, Any]: def strict_compare(
gold_step: Path,
rebuilt_step: Path,
*,
surface_tolerance_mm: float = 0.05,
fast_reject_relative_error: float | None = None,
) -> dict[str, Any]:
"""Compare source and rebuilt B-reps without the engine's permissive rules.""" """Compare source and rebuilt B-reps without the engine's permissive rules."""
from build123d import import_step from build123d import import_step
gold, rebuilt = import_step(str(gold_step)), import_step(str(rebuilt_step)) gold, rebuilt = import_step(str(gold_step)), import_step(str(rebuilt_step))
gold_volume, rebuilt_volume = float(gold.volume), float(rebuilt.volume)
gold_area, rebuilt_area = float(gold.area), float(rebuilt.area)
volume_error = abs(gold_volume - rebuilt_volume) / max(abs(gold_volume), 1e-12)
area_error = abs(gold_area - rebuilt_area) / max(abs(gold_area), 1e-12)
gold_solids, rebuilt_solids = len(gold.solids()), len(rebuilt.solids())
metrics = {
"gold_bbox_mm": None,
"rebuilt_bbox_mm": None,
"bbox_max_delta_mm": None,
"gold_volume_mm3": gold_volume,
"rebuilt_volume_mm3": rebuilt_volume,
"volume_relative_error": volume_error,
"gold_surface_area_mm2": gold_area,
"rebuilt_surface_area_mm2": rebuilt_area,
"surface_area_relative_error": area_error,
"gold_solid_count": gold_solids,
"rebuilt_solid_count": rebuilt_solids,
}
exact_mismatch = fast_reject_relative_error is not None and (
gold_solids != rebuilt_solids
or volume_error > fast_reject_relative_error
or area_error > fast_reject_relative_error
)
if exact_mismatch:
# 体积、面积和实体数都是 B-rep 精确量。它们已超过调用方的最宽松
# 验收界限时,无需执行可能耗时数分钟的逐点曲面距离计算;该路径只会
# 产生确定的拒绝,绝不能把未采样曲面判为通过。
reasons = []
if volume_error > RELATIVE_TOLERANCE: reasons.append("volume_relative_error_exceeds_1e-5")
if area_error > RELATIVE_TOLERANCE: reasons.append("surface_area_relative_error_exceeds_1e-5")
if gold_solids != rebuilt_solids: reasons.append("solid_count_mismatch")
surface = {
"status": "skipped_after_exact_mismatch",
"gold_to_rebuilt": {"sample_count": 0, "max_mm": None, "p95_mm": None, "p99_mm": None, "over_tolerance_count": None},
"rebuilt_to_gold": {"sample_count": 0, "max_mm": None, "p95_mm": None, "p99_mm": None, "over_tolerance_count": None},
}
return {
"schema": "onshape_to_cdsl.strict_step_compare.v1", "tolerance_mm": TOLERANCE_MM, "surface_tessellation_tolerance_mm": surface_tolerance_mm,
"gold_step": str(gold_step), "rebuilt_step": str(rebuilt_step), "passed": False, "failure_reasons": reasons,
"surface": surface, "metrics": metrics,
"diagnostic_topology": {"gold_faces": len(gold.faces()), "rebuilt_faces": len(rebuilt.faces()), "gold_edges": len(gold.edges()), "rebuilt_edges": len(rebuilt.edges()), "gold_surface_types": sorted(str(face.geom_type) for face in gold.faces()), "rebuilt_surface_types": sorted(str(face.geom_type) for face in rebuilt.faces())},
}
gold_samples = _surface_points(gold, surface_tolerance_mm) gold_samples = _surface_points(gold, surface_tolerance_mm)
rebuilt_samples = _surface_points(rebuilt, surface_tolerance_mm) rebuilt_samples = _surface_points(rebuilt, surface_tolerance_mm)
forward, reverse = _distances(gold_samples, rebuilt), _distances(rebuilt_samples, gold) forward, reverse = _distances(gold_samples, rebuilt), _distances(rebuilt_samples, gold)
gold_box, rebuilt_box = _bbox(gold), _bbox(rebuilt) # OCC 的 Shape.bounding_box() 对部分 STEP B-spline 曲面会漏掉裁剪面实际极值;
# 严格比较与表面距离共用同一批 B-rep 曲面采样点,避免比较基础设施本身产生伪差异。
gold_box, rebuilt_box = _bbox(gold_samples), _bbox(rebuilt_samples)
bbox_delta = max(abs(a - b) for a, b in zip(gold_box, rebuilt_box)) bbox_delta = max(abs(a - b) for a, b in zip(gold_box, rebuilt_box))
volume_error = abs(float(gold.volume) - float(rebuilt.volume)) / max(abs(float(gold.volume)), 1e-12) metrics.update({"gold_bbox_mm": gold_box, "rebuilt_bbox_mm": rebuilt_box, "bbox_max_delta_mm": bbox_delta})
area_error = abs(float(gold.area) - float(rebuilt.area)) / max(abs(float(gold.area)), 1e-12)
forward_max, reverse_max = max(forward), max(reverse) forward_max, reverse_max = max(forward), max(reverse)
forward_p99, reverse_p99 = _percentile(forward, 0.99), _percentile(reverse, 0.99) forward_p99, reverse_p99 = _percentile(forward, 0.99), _percentile(reverse, 0.99)
reasons = [] reasons = []
@@ -94,13 +147,12 @@ def strict_compare(gold_step: Path, rebuilt_step: Path, *, surface_tolerance_mm:
reasons.append("volume_relative_error_exceeds_1e-5") reasons.append("volume_relative_error_exceeds_1e-5")
if area_error > RELATIVE_TOLERANCE: if area_error > RELATIVE_TOLERANCE:
reasons.append("surface_area_relative_error_exceeds_1e-5") reasons.append("surface_area_relative_error_exceeds_1e-5")
gold_solids, rebuilt_solids = len(gold.solids()), len(rebuilt.solids())
if gold_solids != rebuilt_solids: if gold_solids != rebuilt_solids:
reasons.append("solid_count_mismatch") reasons.append("solid_count_mismatch")
return { return {
"schema": "onshape_to_cdsl.strict_step_compare.v1", "tolerance_mm": TOLERANCE_MM, "surface_tessellation_tolerance_mm": surface_tolerance_mm, "schema": "onshape_to_cdsl.strict_step_compare.v1", "tolerance_mm": TOLERANCE_MM, "surface_tessellation_tolerance_mm": surface_tolerance_mm,
"gold_step": str(gold_step), "rebuilt_step": str(rebuilt_step), "passed": not reasons, "failure_reasons": reasons, "gold_step": str(gold_step), "rebuilt_step": str(rebuilt_step), "passed": not reasons, "failure_reasons": reasons,
"surface": {"gold_to_rebuilt": {"sample_count": len(forward), "max_mm": forward_max, "p95_mm": _percentile(forward, .95), "p99_mm": forward_p99, "over_tolerance_count": sum(value > TOLERANCE_MM for value in forward)}, "rebuilt_to_gold": {"sample_count": len(reverse), "max_mm": reverse_max, "p95_mm": _percentile(reverse, .95), "p99_mm": reverse_p99, "over_tolerance_count": sum(value > TOLERANCE_MM for value in reverse)}}, "surface": {"gold_to_rebuilt": {"sample_count": len(forward), "max_mm": forward_max, "p95_mm": _percentile(forward, .95), "p99_mm": forward_p99, "over_tolerance_count": sum(value > TOLERANCE_MM for value in forward)}, "rebuilt_to_gold": {"sample_count": len(reverse), "max_mm": reverse_max, "p95_mm": _percentile(reverse, .95), "p99_mm": reverse_p99, "over_tolerance_count": sum(value > TOLERANCE_MM for value in reverse)}},
"metrics": {"gold_bbox_mm": gold_box, "rebuilt_bbox_mm": rebuilt_box, "bbox_max_delta_mm": bbox_delta, "gold_volume_mm3": float(gold.volume), "rebuilt_volume_mm3": float(rebuilt.volume), "volume_relative_error": volume_error, "gold_surface_area_mm2": float(gold.area), "rebuilt_surface_area_mm2": float(rebuilt.area), "surface_area_relative_error": area_error, "gold_solid_count": gold_solids, "rebuilt_solid_count": rebuilt_solids}, "metrics": metrics,
"diagnostic_topology": {"gold_faces": len(gold.faces()), "rebuilt_faces": len(rebuilt.faces()), "gold_edges": len(gold.edges()), "rebuilt_edges": len(rebuilt.edges()), "gold_surface_types": sorted(str(face.geom_type) for face in gold.faces()), "rebuilt_surface_types": sorted(str(face.geom_type) for face in rebuilt.faces())}, "diagnostic_topology": {"gold_faces": len(gold.faces()), "rebuilt_faces": len(rebuilt.faces()), "gold_edges": len(gold.edges()), "rebuilt_edges": len(rebuilt.edges()), "gold_surface_types": sorted(str(face.geom_type) for face in gold.faces()), "rebuilt_surface_types": sorted(str(face.geom_type) for face in rebuilt.faces())},
} }
+26 -1
View File
@@ -6,7 +6,7 @@ import shutil
import tempfile import tempfile
import unittest import unittest
from onshape_to_cdsl.compare import strict_compare from onshape_to_cdsl.compare import _bbox, _distances, _surface_points, strict_compare
from onshape_to_cdsl.convert import convert_one from onshape_to_cdsl.convert import convert_one
from onshape_to_cdsl.issues import write_issue_register from onshape_to_cdsl.issues import write_issue_register
from onshape_to_cdsl.merge_scans import merge_scans from onshape_to_cdsl.merge_scans import merge_scans
@@ -100,6 +100,31 @@ class PipelineTests(unittest.TestCase):
self.assertFalse(rejection["passed"]) self.assertFalse(rejection["passed"])
self.assertIn("bbox_exceeds_0.01mm", rejection["failure_reasons"]) self.assertIn("bbox_exceeds_0.01mm", rejection["failure_reasons"])
def test_strict_comparator_can_fast_reject_an_exact_mismatch(self) -> None:
from build123d import Box, export_step
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source, changed = root / "source.step", root / "changed.step"
export_step(Box(10, 10, 10), source)
export_step(Box(20, 10, 10), changed)
rejection = strict_compare(source, changed, fast_reject_relative_error=.005)
self.assertFalse(rejection["passed"])
self.assertEqual(rejection["surface"]["status"], "skipped_after_exact_mismatch")
self.assertIsNone(rejection["metrics"]["bbox_max_delta_mm"])
def test_strict_comparator_samples_points_on_the_source_brep(self) -> None:
from build123d import Cylinder
cylinder = Cylinder(21.3, 50)
samples = _surface_points(cylinder, 0.05)
self.assertGreater(len(samples), 0)
self.assertLess(max(_distances(samples, cylinder)), 1e-6)
def test_strict_comparator_bounds_use_the_sampled_brep_surface(self) -> None:
points = [(-1.0, -2.0, -3.0), (4.0, 5.0, 6.0), (0.0, 1.0, 2.0)]
self.assertEqual(_bbox(points), [-1.0, -2.0, -3.0, 4.0, 5.0, 6.0])
def test_00000352_offline_end_to_end_when_fixture_is_available(self) -> None: def test_00000352_offline_end_to_end_when_fixture_is_available(self) -> None:
fixture = Path.cwd() / "json_to_cdsl/input/onshape_complete/00000352" fixture = Path.cwd() / "json_to_cdsl/input/onshape_complete/00000352"
if not (fixture / "model.step").exists(): if not (fixture / "model.step").exists():