feat(cadfs): 扩展重建引擎能力并固化代表性模型回归

- 扩展 CDSL engine 的 shell、sweep、loft、reference plane、pattern 等运行时能力,
  支持新的实体结果模式、双向拉伸、曲线扫掠、镜像/圆周阵列及相关 selector 解析。
- 完善 Build123d 适配层的拓扑快照、Compound/ShapeList 兼容处理和旋转曲面识别,
  兼容 Python 3.12 / 当前 Build123d 缺少 axis_of_rotation 的合法曲面场景。
- 扩展 CDSL schema、profile schema、capability analysis、semantic validation 和
  sketch solver,使新增建模操作能够被校验、执行并保留可诊断的部分结果。
- 完善 CADFS FeatureScript lowering:
  支持 shell、sweep、surface/实体 loft、圆周阵列副本、镜像副本、删除阵列实例、
  新 body 操作、更多拉伸终止条件和 reference plane 变体。
- 补齐椭圆、B-spline、环形区域、imprint、SWEPT_FACE、CAP_FACE、OFFSET_FACE 等
  草图和拓扑引用的转换逻辑,改善后续特征的工作平面、轴线和 profile 定位精度。
- 改进 selector binding:支持 pattern 前缀复合 B-rep 快照、交集顶点引用、
  多面 match_mode=all、圆柱轴线/半径和面积下限等稳定匹配条件。
- 修复 MID_PLANE 法向统一后交线方向未同步的问题,恢复 00287955 基准面的正确位置;
  修复 00542223 sweep 路径反转后的切线契约和 00423838 的拓扑面数不稳定测试假设。
- 修正 CADFS 比较模块 import 路径,补充重建报告、批量重建脚本、目标文档和 README。
- 新增并扩展 engine、lowering、parser、selector binding、reports、integration 和
  Onshape pipeline 回归测试,覆盖代表性 CADFS 特征链及运行时兼容性。
This commit is contained in:
2026-09-08 11:47:10 +08:00
parent e7dc521ece
commit 738934416e
28 changed files with 5532 additions and 339 deletions
+587 -67
View File
@@ -5,7 +5,21 @@ from __future__ import annotations
import math
from typing import Any, Iterable
from build123d import Axis, Compound, Edge, Face, Location, Plane, ShapeList, Solid, Vector, Wire, export_step
from build123d import Axis, Compound, Edge, Face, Location, Plane, ShapeList, Shell, Solid, Vector, Wire, export_step
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCP.BRep import BRep_Tool
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
from OCP.BRepOffset import BRepOffset_Skin
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol
from OCP.Geom import Geom_SurfaceOfRevolution
from OCP.GeomAbs import GeomAbs_Arc
from OCP.LocOpe import LocOpe_DPrism
from OCP.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle
from OCP.TopAbs import TopAbs_SHELL
from OCP.TopTools import TopTools_ListOfShape
from OCP.TopoDS import TopoDS
from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Vec
from .parametric_thread import build_thread_solid
from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, ThreadSpec, TopologyRecord, Vector3, canonical_plane_signature
@@ -65,14 +79,34 @@ class Build123dGeometryAdapter:
@staticmethod
def _wire(edges: list[dict[str, Any]]) -> Wire:
# 将边字典列表(直线/圆弧/插值 B 样条)组装成 build123d 的 Wire 线框。
# 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。
built: list[Edge] = []
for edge in edges:
if edge.get("type") == "bspline":
points = [_vector(point) for point in edge.get("points_mm") or []]
if len(points) < 3:
raise ValueError("bspline contour edge needs at least 3 points")
built.append(Edge.make_spline(points, periodic=False))
parameters = edge.get("parameters")
start_tangent = edge.get("start_tangent_mm")
end_tangent = edge.get("end_tangent_mm")
if (start_tangent is None) != (end_tangent is None):
raise ValueError("bspline contour edge requires both endpoint tangents")
built.append(Edge.make_spline(
points,
tangents=[_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None,
periodic=bool(edge.get("periodic")),
parameters=[float(value) for value in parameters] if parameters is not None else None,
scale=False,
))
continue
if edge.get("type") == "ellipse":
center = _vector(edge["center_mm"])
major_axis = _vector(edge["major_axis_mm"])
normal = _vector(edge.get("normal") or [0, 0, 1])
if major_axis.length <= 1e-9 or normal.length <= 1e-9:
raise ValueError("ellipse contour edge has a degenerate frame")
plane = Plane(origin=center, x_dir=major_axis, z_dir=normal)
built.append(Edge.make_ellipse(float(edge["major_radius_mm"]), float(edge["minor_radius_mm"]), plane=plane))
continue
start = _vector(edge["start_mm"])
end = _vector(edge["end_mm"])
@@ -133,6 +167,30 @@ class Build123dGeometryAdapter:
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Face]:
# 从草图数据解析出可拉伸/旋转的轮廓面,按三种数据来源依次回退。
# 1. 单圆 contour 不应先被 sketch_solver 展开成四段圆弧。圆弧分段会
# 改变拉伸后的圆柱面拓扑:同一圆柱侧面被拆成四块,后续来自
# FeatureScript 的 SWEPT_FACE 无法再以圆心/半径唯一定位。对于
# 只含闭合整圆的轮廓,保留每个圆一条原生 circle edge;同心圆仍由
# _faces_from_circles 的包含关系生成带孔面。
profile = sketch.get("profile") or {}
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
if isinstance(contours, list) and contours and all(
isinstance(contour, dict)
and bool(contour.get("closed"))
and len(contour.get("segments") or []) == 1
and (contour.get("segments") or [{}])[0].get("type") == "circle"
for contour in contours
):
circles = [
{
"type": "circle",
"center": segment.get("center"),
"radius_mm": segment.get("radius_mm"),
}
for contour in contours
for segment in contour.get("segments") or []
]
return self._faces_from_circles(circles, PlaneSpec.from_mapping(sketch.get("workplane") or {}))
# 1. 优先使用预计算的轮廓区域 contour_regions_mm(外轮廓 + 孔洞列表)。
regions = sketch.get("contour_regions_mm") or []
if regions:
@@ -154,6 +212,20 @@ class Build123dGeometryAdapter:
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
return self._faces_from_circles(sketch.get("entities") or [], plane)
@staticmethod
def face_with_holes(outer: Face, holes: Iterable[Face]) -> Face:
"""Build one planar profile from a sketch outer wire and cap-face holes."""
if outer.inner_wires():
raise ValueError("profile outer face must not already contain holes")
wires = []
for hole in holes:
if hole.inner_wires():
raise ValueError("profile hole face must have exactly one outer wire")
wires.append(hole.outer_wire())
if not wires:
raise ValueError("profile hole feature requires at least one cap face")
return Face(outer.outer_wire()).make_holes(wires)
def loft(self, sketches: list[dict[str, Any]]) -> Solid:
"""由多条简单闭合草图轮廓生成实体放样。"""
wires: list[Wire] = []
@@ -171,6 +243,20 @@ class Build123dGeometryAdapter:
raise ValueError("loft requires at least two profile sketches")
return Solid.make_loft(wires)
def loft_with_cap_face(self, cap_face: Face, sketches: list[dict[str, Any]]) -> Solid:
"""Loft from one cap face's outer wire to one closed sketch profile."""
if not sketches:
raise ValueError("cap-face loft requires at least one profile sketch")
wires = [cap_face.outer_wire()]
for index, sketch in enumerate(sketches):
faces = self.faces_for_sketch(sketch)
if len(faces) != 1:
raise ValueError(f"loft profile {index} must resolve to exactly one closed region")
if faces[0].inner_wires():
raise ValueError(f"loft profile {index} must not contain inner loops")
wires.append(faces[0].outer_wire())
return Solid.make_loft(wires)
@staticmethod
def _coerce_single_or_compound(result: Any, *, empty_error: str | None = None) -> Any:
"""规整一次布尔结果:None/空视为失败(可选报错),多成员合并为 Compound。"""
@@ -187,17 +273,44 @@ class Build123dGeometryAdapter:
return None
if len(members) == 1:
return members[0]
# build123d 0.11 的有效运行时合并 API 是 Compound.make_composite(多体域
# 合并为 Compound/Part);Compound.make_compound 在类型桩与运行时均不存在,
# 此前的调用在多成员 ShapeList 下会 AttributeError。类型桩未声明
# make_composite,保留宽泛类型桩噪音抑制。
return Compound.make_composite(members)
# ``Compound`` constructor accepts an iterable on both supported
# Build123d runtimes. ``make_composite`` is not available in every
# deployed version, so using it here breaks valid multi-solid cuts.
return Compound(members)
@staticmethod
def extrude(face: Face, direction: Vector3) -> Solid:
# 沿给定方向向量拉伸一个面,生成实体。
return Solid.extrude(face, _vector(direction))
@staticmethod
def extrude_taper(face: Face, direction: Vector3, taper_deg: float) -> Solid:
# 沿给定方向以锥角拉伸一个面。build123d 正角收缩外轮廓,负角扩张;
# CADFS 的 draftPullDirection 已由 lowering 映射到该符号。
# build123d 对负锥角回退为 offset wire loft;椭圆等解析曲线在该
# 路径会产生仅能留在内存、STEP round-trip 后退化为 Shell 的 B-rep。
# LocOpe_DPrism 同时支持正负拔模角,且保留一张解析侧面,因此在
# 无内环、拉伸方向与 face normal 同向时始终优先使用它。
vector = _vector(direction)
normal = face.normal_at()
if (
vector.length > 1e-9
and normal.length > 1e-9
and vector.normalized().dot(normal.normalized()) >= 1.0 - 1e-9
and not face.inner_wires()
):
angle_rad = math.radians(taper_deg)
prism = LocOpe_DPrism(
face.wrapped,
vector.length / math.cos(angle_rad),
angle_rad,
)
if prism.IsDone():
result = Solid(TopoDS.Solid_s(prism.Shape()))
if result.is_valid:
return result
return Solid.extrude_taper(face, _vector(direction), taper_deg)
@staticmethod
def extrude_trimmed(face: Face, target: Any, direction: Vector3) -> Any:
"""Extrude the profile to the target face, trimming unreached regions.
@@ -215,6 +328,7 @@ class Build123dGeometryAdapter:
# through_next 的 target 是当前主体,必须先从其面中选出实际命中的
# 下一终止面,不能把整个 body 当作待拉伸的 Face。
unit = _vector(direction).normalized()
target_body = target if not isinstance(target, Face) else None
points = Build123dGeometryAdapter.profile_sample_points(face)
targets = [target] if isinstance(target, Face) else list(target.faces())
candidates = []
@@ -229,15 +343,28 @@ class Build123dGeometryAdapter:
# 时取最近的正向交点,确保相邻面交界处的选择稳定。
_, _, target, distances = max(candidates, key=lambda item: (item[0], -item[1]))
margin = max(distances) + 2.0
# 2. 穿透拉伸 profile,同时把目标面向回推生成体层,二者求交即裁剪体。
if target_body is not None and len(distances) == len(points):
# through_next 的 target 是当前实体,不是孤立的终止面。先让工具体
# 轻微穿过首个命中面,再切掉既有实体,留下从 profile 到该面的一侧。
# 将圆柱面拉成 slab 会保留实体内部的短段,丢失外部新增材料。
pierced = Solid.extrude(face, unit * margin)
return Build123dGeometryAdapter._coerce_single_or_compound(
pierced.cut(target_body), empty_error="extent target leaves no leading material",
)
# 2. 穿透拉伸 profile,再从目标面两侧各构造一个体层。曲面 Face 的
# OCC 朝向不保证与拉伸方向一致(特别是 cut 后的内圆柱面),不能
# 固定假定 -unit 一定朝向 profile;选择与 profile 相接的交集侧。
# build123d 的布尔交方法名是 intersect(不是 OCC 的 common),
# 且多实体结果返回 ShapeList,需要规整为单个 Solid / Compound。
pierced = Solid.extrude(face, unit * margin)
slab = Solid.extrude(target, -unit * margin)
trimmed = pierced.intersect(slab)
return Build123dGeometryAdapter._coerce_single_or_compound(
trimmed, empty_error="extent target produced an empty trimmed solid",
)
candidates = []
for slab_direction in (-unit * margin, unit * margin):
trimmed = Build123dGeometryAdapter._coerce_single_or_compound(pierced.intersect(Solid.extrude(target, slab_direction)))
if trimmed is not None:
candidates.append((trimmed.distance_to(face), trimmed))
if not candidates:
raise ValueError("extent target produced an empty trimmed solid")
return min(candidates, key=lambda item: item[0])[1]
@staticmethod
def body_center(body: Any) -> Vector3:
@@ -263,6 +390,20 @@ class Build123dGeometryAdapter:
# 提取顶点的三维坐标元组。
return (float(vertex.X), float(vertex.Y), float(vertex.Z))
@staticmethod
def intersection_vertex(body: Any, face_sets: list[list[Any]]) -> Any:
"""Resolve one current-body vertex shared by every selected face set."""
if not face_sets or any(not faces for faces in face_sets):
raise ValueError("intersection selector has an empty face set")
matched = []
for vertex in body.vertices():
if all(any(face.distance_to(vertex) <= 1e-6 for face in faces) for faces in face_sets):
point = (round(float(vertex.X), 6), round(float(vertex.Y), 6), round(float(vertex.Z), 6))
if not any(point == existing[0] for existing in matched): matched.append((point, vertex))
if len(matched) != 1:
raise ValueError(f"intersection selector resolved {len(matched)} current-body vertices")
return matched[0][1]
@staticmethod
def profile_sample_points(face: Face) -> list[Vector]:
"""Sample a profile face before a selector-dependent termination.
@@ -285,6 +426,51 @@ class Build123dGeometryAdapter:
unique.append(point)
return unique
@staticmethod
def profile_touches_target(target: Any, faces: Iterable[Face]) -> bool:
"""Return whether a profile starts on the selected extent target."""
# `up_to_surface` 允许 profile 从 selected face 出发。这时 selected
# face 不是拉伸终止,而是起始边界;交给 next_body_face_after 在当前
# 主体中寻找真实的下一面。distance_to 是 OCC 的最短形体距离,适用于
# 平面、圆柱和其他可用作 selector 的 B-rep face。
for face in faces:
try:
if face.distance_to(target) <= 1e-6:
return True
except Exception as error:
raise ValueError("extent target does not support profile distance") from error
return False
@staticmethod
def next_body_face_after(body: Any, faces: Iterable[Face], direction: Vector3, *, excluded_face: Face) -> Face:
"""Find the next current-body face reached after an extent start face."""
# 对每个候选面统计其能截获的 profile 射线。完整覆盖优先,随后选取
# 最近正向交点,避免把同一外圆柱的远侧交点误当成穿过实体后的终止面。
unit = _vector(direction).normalized()
samples = [point for face in faces for point in Build123dGeometryAdapter.profile_sample_points(face)]
if not samples:
raise ValueError("extent feature has no profile samples")
candidates = []
for candidate in body.faces():
# Selector resolution can retain the preceding B-rep snapshot,
# whose wrapper is no longer `is_same` after a later boolean.
# Geometrically coincident current faces are still the selected
# start boundary and must not win by their near-zero ray hits.
if candidate.is_same(excluded_face) or candidate.distance_to(excluded_face) <= 1e-6:
continue
distances = [
value for point in samples
if (value := Build123dGeometryAdapter._forward_intersection_distance(candidate, point, unit)) is not None
]
if distances:
candidates.append((len(distances), min(distances), candidate))
if not candidates:
raise ValueError("extent target has no following body face")
coverage, _distance, target = max(candidates, key=lambda item: (item[0], -item[1]))
if coverage != len(samples):
raise ValueError("extent target does not reach every profile ray after the start face")
return target
@staticmethod
def _forward_intersection_distance(target: Any, point: Vector, direction: Vector) -> float | None:
# 从 point 沿 direction 发一条射线,求与目标的第一个正向交点距离。
@@ -323,16 +509,120 @@ class Build123dGeometryAdapter:
# 绕给定轴将面旋转指定角度,生成回转实体。
return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis))
@staticmethod
def revolve_surface(wire: Wire, angle_deg: float, axis: AxisSpec) -> Shell:
# 表面回转必须以 profile wire 而非 Face 输入。Face 回转会由 OCC 封闭为
# Solid,错误改变 CADFS NewSurfaceOperation 的实体结果和体积。
operation = BRepPrimAPI_MakeRevol(
wire.wrapped,
gp_Ax1(gp_Pnt(*axis.origin_mm), gp_Dir(*axis.direction)),
math.radians(angle_deg),
True,
)
operation.Build()
if not operation.IsDone():
raise ValueError("surface revolve did not complete")
shape = operation.Shape()
if shape.IsNull() or shape.ShapeType() != TopAbs_SHELL:
raise ValueError("surface revolve did not produce one shell")
return Shell(shape)
@staticmethod
def surface_wires_for_sketch(sketch: dict[str, Any]) -> list[Wire]:
# 曲面拉伸只消费 CADFS 显式选择的闭合曲线,不把同心圆转换成带孔 Face。
# 后者适用于实体拉伸,但会丢失每条 source edge 对应的一张独立曲面。
profile = sketch.get("profile") or {}
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
if profile.get("type") == "circle":
return [Build123dGeometryAdapter()._circle_wire(profile.get("center") or [0, 0], float(profile["radius_mm"]), plane)]
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
if not isinstance(contours, list) or not contours:
raise ValueError("surface extrude requires closed contour wires")
wires = []
for contour in contours:
segments = contour.get("segments") or []
if not contour.get("closed") or len(segments) != 1 or segments[0].get("type") != "circle":
raise ValueError("surface extrude currently supports selected circular wires only")
segment = segments[0]
wires.append(Build123dGeometryAdapter()._circle_wire(
segment.get("center") or [0, 0], float(segment["radius_mm"]), plane,
))
return wires
@staticmethod
def extrude_surface(wires: Iterable[Wire], direction: Vector3) -> Any:
# 将每条闭合 wire 沿给定方向扫成独立 shell。曲面不参与实体布尔,后续
# selector 通过 register_surface 的独立 topology snapshot 追溯其来源。
vector = _vector(direction)
if vector.length <= 1e-9:
raise ValueError("surface extrude direction must be non-zero")
surfaces = []
for wire in wires:
operation = BRepPrimAPI_MakePrism(wire.wrapped, gp_Vec(vector.X, vector.Y, vector.Z), True, True)
operation.Build()
if not operation.IsDone():
raise ValueError("surface extrude did not complete")
shape = operation.Shape()
if shape.IsNull() or shape.ShapeType() != TopAbs_SHELL:
raise ValueError("surface extrude did not produce one shell")
surfaces.append(Shell(shape))
if not surfaces:
raise ValueError("surface extrude requires at least one wire")
return surfaces[0] if len(surfaces) == 1 else Compound(surfaces)
@staticmethod
def combine_surfaces(*surfaces: Any) -> Any:
# 不能复用 combine:它按实体 body 生命周期调用 body_solids,会丢弃
# Shell。曲面组合只用于导出和拓扑登记,不执行实体 boolean。
members = []
for surface in surfaces:
if isinstance(surface, Compound):
members.extend(surface.faces())
else:
members.append(surface)
if not members:
raise ValueError("surface combination requires at least one shell")
return members[0] if len(members) == 1 else Compound(members)
@staticmethod
def fuse(body: Any | None, solid: Any) -> Any:
# 布尔并:没有既有主体时,直接以该实体作为新主体。
# 实参类型放宽为 Any:build123d 的布尔结果可能是 Solid 或 Compound。
return solid if body is None else body.fuse(solid)
if body is None:
return solid
# build123d.Shape.fuse 未启用 OBB 加速器;镜像后的重叠实体在该路径
# 会偶发返回反向、无效的 B-rep。直接采用 OCC 的稳定布尔配置,保留
# 一般 add/replay 的同一 union 语义。
arguments = TopTools_ListOfShape(); arguments.Append(body.wrapped)
tools = TopTools_ListOfShape(); tools.Append(solid.wrapped)
operation = BRepAlgoAPI_Fuse()
operation.SetRunParallel(True); operation.SetUseOBB(True)
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
if not operation.IsDone():
raise ValueError("OCC union operation did not complete")
result = Solid(operation.Shape())
if result.is_valid:
return result
# 保留 build123d 的既有调用作为内核版本差异下的兼容回退;无效结果
# 不能悄然进入后续 feature history。
fallback = Build123dGeometryAdapter._coerce_single_or_compound(body.fuse(solid))
if fallback is not None and fallback.is_valid:
return fallback
raise ValueError("OCC union operation produced an invalid shape")
@staticmethod
def combine(body: Any | None, solid: Any) -> Any:
# 保留独立 result body:不得调用 fuse,否则相交实体会被内核合并。
members = ([] if body is None else Build123dGeometryAdapter.body_solids(body))
members.extend(Build123dGeometryAdapter.body_solids(solid))
return members[0] if len(members) == 1 else Compound(members)
@staticmethod
def cut(body: Any, tool: Any) -> Any:
# 从主体上减去工具实体。
return body.cut(tool)
return Build123dGeometryAdapter._coerce_single_or_compound(
body.cut(tool), empty_error="OCC cut operation produced no shape",
)
@staticmethod
def sphere(radius_mm: float, center_mm: Vector3) -> Solid:
@@ -470,7 +760,179 @@ class Build123dGeometryAdapter:
@staticmethod
def chamfer(body: Any, distance_mm: float, distance_2_mm: float | None, edges: Iterable[Edge], face: Face | None = None) -> Any:
# 对指定边做倒角;distance_2_mm 提供时形成非对称倒角。
return body.chamfer(distance_mm, distance_2_mm, list(edges), face=face)
# OCC 的单距离 Add 重载会按内核的等距倒角语义处理两侧相邻面。build123d
# 的通用实现会先任选一张邻接面再调用双距离重载,复杂实体上该选择会改变
# 倒角结果,因此仅等距倒角优先走原生重载。
selected = list(edges)
if distance_2_mm is None and face is None and len(Build123dGeometryAdapter.body_solids(body)) == 1:
builder = BRepFilletAPI_MakeChamfer(body.wrapped)
for edge in selected:
builder.Add(distance_mm, edge.wrapped)
builder.Build()
if builder.IsDone():
result = Solid(builder.Shape())
if result.is_valid:
return result
return body.chamfer(distance_mm, distance_2_mm, selected, face=face)
@staticmethod
def _surface_limited_chamfer_tool(body: Any, edge: Edge, distance_mm: float, surfaces: Iterable[Any]) -> Solid:
"""Build the removable material for one surface-supported circular chamfer.
A regular equal-offset chamfer is first attempted by ``chamfer``. This
helper only handles the narrow CADFS case where that operation reaches
a concentric surface split before the requested second offset. The
explicit shell must contain the limiting circle at the selected plane;
without that evidence this method deliberately rejects the fallback.
"""
if str(edge.geom_type).split(".")[-1].lower() != "circle":
raise ValueError("surface-limited chamfer requires circular edges")
try:
radius = float(edge.radius)
center = edge.arc_center
except (TypeError, ValueError) as error:
raise ValueError("surface-limited chamfer edge has no circle radius") from error
if radius <= 0:
raise ValueError("surface-limited chamfer edge radius must be positive")
adjacent = [
face for face in body.faces()
if any(face_edge.is_same(edge) for face_edge in face.edges())
]
planes = [face for face in adjacent if str(face.geom_type).split(".")[-1].lower() == "plane"]
cylinders = [face for face in adjacent if str(face.geom_type).split(".")[-1].lower() == "cylinder"]
if len(planes) != 1 or len(cylinders) != 1:
raise ValueError("surface-limited chamfer requires one planar and one cylindrical adjacent face")
plane_face, cylinder_face = planes[0], cylinders[0]
axis = cylinder_face.axis_of_rotation
if axis is None:
raise ValueError("surface-limited chamfer cylinder has no axis")
axis_direction = axis.direction.normalized()
if abs(plane_face.normal_at().normalized().dot(axis_direction)) < 1.0 - 1e-6:
raise ValueError("surface-limited chamfer faces are not perpendicular")
boundaries = [
candidate for candidate in plane_face.edges()
if str(candidate.geom_type).split(".")[-1].lower() == "circle"
and not candidate.is_same(edge)
and (candidate.arc_center - center).length <= 1e-6
]
if len(boundaries) != 1:
raise ValueError("surface-limited chamfer plane has no unique concentric support")
support_edge = boundaries[0]
support_radius = float(support_edge.radius)
radial_span = radius - support_radius
if radial_span <= 1e-6 or distance_mm <= radial_span + 1e-6:
raise ValueError("surface-limited chamfer does not need a constrained outer transition")
cylinder_ends = [
candidate for candidate in cylinder_face.edges()
if str(candidate.geom_type).split(".")[-1].lower() == "circle"
and not candidate.is_same(edge)
and abs(float(candidate.radius) - radius) <= 1e-6
and (candidate.arc_center - center).length > 1e-6
]
if len(cylinder_ends) != 1:
raise ValueError("surface-limited chamfer cylinder has no unique opposite cap")
axial_span = cylinder_ends[0].arc_center - center
if axial_span.length <= distance_mm + 1e-6:
raise ValueError("surface-limited chamfer exceeds the selected cylindrical face")
direction = axial_span.normalized()
supported = False
for surface in surfaces:
for face in surface.faces():
if str(face.geom_type).split(".")[-1].lower() != "cylinder":
continue
surface_axis = face.axis_of_rotation
if surface_axis is None or abs(surface_axis.direction.normalized().dot(axis_direction)) < 1.0 - 1e-6:
continue
if abs(float(face.radius) - support_radius) > 1e-6:
continue
if any(
str(boundary.geom_type).split(".")[-1].lower() == "circle"
and abs(float(boundary.radius) - support_radius) <= 1e-6
and (boundary.arc_center - center).length <= 1e-6
for boundary in face.edges()
):
supported = True
break
if supported:
break
if not supported:
raise ValueError("surface-limited chamfer has no explicit surface support")
frame_x = edge.tangent_at(0.0).normalized()
first_length = distance_mm - radial_span
outer_radius = radius + max(distance_mm, 1.0)
start_plane = Plane(origin=center, x_dir=frame_x, z_dir=direction)
cone_plane = Plane(origin=center + direction * first_length, x_dir=frame_x, z_dir=direction)
outer = Solid.make_cylinder(outer_radius, distance_mm, start_plane)
core = Solid.make_cylinder(support_radius, first_length, start_plane).fuse(
Solid.make_cone(support_radius, radius, radial_span, cone_plane),
)
return outer.cut(core)
@staticmethod
def surface_limited_chamfer(body: Any, distance_mm: float, edges: Iterable[Edge], surfaces: Iterable[Any]) -> Any:
# 显式 surface shell 只在内核正常倒角失败后作为截断证据使用。每条边
# 都先从同一原 body 推导工具体,随后依序切除,避免已变形拓扑反向影响
# 另一条 source selector。
if len(Build123dGeometryAdapter.body_solids(body)) != 1:
raise ValueError("surface-limited chamfer requires one solid body")
selected = list(edges)
if not selected:
raise ValueError("surface-limited chamfer requires at least one edge")
surface_members = list(surfaces)
tools = [
Build123dGeometryAdapter._surface_limited_chamfer_tool(body, edge, distance_mm, surface_members)
for edge in selected
]
result = body
for tool in tools:
result = result.cut(tool)
if not isinstance(result, Solid) or not result.is_valid:
raise ValueError("surface-limited chamfer produced an invalid shape")
if result.volume >= body.volume - 1e-6:
raise ValueError("surface-limited chamfer removed no material")
return result
@staticmethod
def shell(body: Any, faces: Iterable[Face], thickness_mm: float, *, inward: bool = True) -> Any:
# 对单个实体移除指定面并偏置其余面,生成薄壁实体。多 body 的目标
# 选择与结果合并由 runtime 处理;OCC 的 MakeThickSolidByJoin 只接受
# 一个 Solid,不能把 Compound 直接交给内核并猜测其 body 生命周期。
selected = list(faces)
if not selected:
raise ValueError("shell requires at least one face to remove")
thickness = float(thickness_mm)
if thickness <= 0:
raise ValueError("shell thickness_mm must be > 0")
solids = Build123dGeometryAdapter.body_solids(body)
if len(solids) != 1:
raise ValueError("shell adapter requires exactly one target solid")
closing_faces = TopTools_ListOfShape()
for face in selected:
closing_faces.Append(face.wrapped)
builder = BRepOffsetAPI_MakeThickSolid()
builder.MakeThickSolidByJoin(
solids[0].wrapped,
closing_faces,
-thickness if inward else thickness,
1e-6,
BRepOffset_Skin,
False,
False,
GeomAbs_Arc,
False,
)
builder.Build()
if not builder.IsDone():
raise ValueError("OCC shell operation did not complete")
result = Solid(builder.Shape())
if not result.is_valid:
raise ValueError("OCC shell operation produced an invalid shape")
return result
@staticmethod
def sweep(
@@ -493,15 +955,36 @@ class Build123dGeometryAdapter:
}
if transition is not None:
sweep_options["transition"] = transition
return Solid.sweep(section, spine, **sweep_options)
result = Solid.sweep(section, spine, **sweep_options)
if make_solid and (not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9):
# OCC 在截面与路径不构成有效实体 sweep 时可能返回零体积形状,
# 而不报告 Build() 失败。该结果不能作为 CADFS 的 solid body 继续传播。
raise ValueError("OCC sweep operation did not produce a solid")
return result
@staticmethod
def sweep_path(points: Iterable[Vector3]) -> Wire:
# 把三维点列连成折线 Wire,作为扫掠路径的通用构造入口。
def sweep_path(
points: Iterable[Vector3],
*,
start_tangent: Vector3 | None = None,
end_tangent: Vector3 | None = None,
parameters: list[float] | None = None,
) -> Edge | Wire:
# 两点路径保持直线;三个及以上插值点构造单段 B-spline。端切线是
# FeatureScript skFitSpline 的约束,缺失时不能伪造,交给内核自动求解。
vertices = [_vector(point) for point in points]
if len(vertices) < 2:
raise ValueError("sweep path needs at least two points")
return Wire([Edge.make_line(vertices[index], vertices[index + 1]) for index in range(len(vertices) - 1)])
if len(vertices) == 2:
if start_tangent is not None or end_tangent is not None or parameters is not None:
raise ValueError("line sweep path does not accept B-spline tangents")
return Edge.make_line(vertices[0], vertices[1])
if (start_tangent is None) != (end_tangent is None):
raise ValueError("sweep B-spline path requires both endpoint tangents")
tangents = [_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None
if parameters is not None and len(parameters) != len(vertices):
raise ValueError("sweep B-spline path parameters must match point count")
return Edge.make_spline(vertices, tangents=tangents, parameters=parameters, scale=False)
@staticmethod
def helix_path(
@@ -581,8 +1064,30 @@ class Build123dGeometryAdapter:
@staticmethod
def export(body: Any, path: str) -> None:
# 将主体导出为 STEP 文件。
export_step(body, path)
# OCCT 对大于 90 度的 SURFACE_OF_REVOLUTION 在 STEP round-trip 时会
# 丢失部分参数域,导入后该侧面退化为一条母线。只对包含这类曲面的
# 独立实体按 45 度分段,保留原始解析曲面与实体几何,避免影响其余
# B-rep 的拓扑和导出体积。
segments = []
for solid in Build123dGeometryAdapter.body_solids(body):
if not isinstance(solid, Solid):
# Shell 或只含曲面的 Compound 没有实体体积分割语义。直接交给
# STEP exporter,才能保留 pure-surface feature history 的面。
segments.append(solid); continue
has_revolution = any(
BRep_Tool.Surface_s(face.wrapped).IsKind(Geom_SurfaceOfRevolution.get_type_descriptor_s())
for face in solid.faces()
)
if not has_revolution:
segments.append(solid); continue
divider = ShapeUpgrade_ShapeDivideAngle(math.radians(45.0), solid.wrapped)
divider.SetPrecision(1e-7); divider.SetMaxTolerance(1e-5)
if not divider.Perform(): raise ValueError("STEP revolution surface segmentation failed")
segmented = Solid(divider.Result())
if not segmented.is_valid:
raise ValueError("STEP revolution surface segmentation produced an invalid solid")
segments.append(segmented)
export_step(segments[0] if len(segments) == 1 else Compound(segments), path)
@staticmethod
def body_solids(body: Any) -> list[Any]:
@@ -608,6 +1113,16 @@ class Build123dGeometryAdapter:
"solid_count": len(solids),
}
@staticmethod
def surface_geometry(surface: Any) -> dict[str, Any]:
# 曲面结果不参与实体 body 聚合;只保存后续 selector 所需的独立拓扑摘要。
bbox = surface.bounding_box()
return {
"bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
"area_mm2": float(surface.area),
"face_count": len(surface.faces()),
}
@staticmethod
def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]:
# 从主体导出全部面/边/顶点拓扑记录,供后续特征选择与引用。
@@ -694,51 +1209,56 @@ class Build123dGeometryAdapter:
elif geometry["surface_type"] in {"cylinder", "cone"}:
axis = face.axis_of_rotation
if axis is None:
raise ValueError("rotational face is missing an axis")
direction = axis.direction
origin = axis.position
geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z]
geometry["axis_direction"] = [direction.X, direction.Y, direction.Z]
raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None
if geometry["surface_type"] == "cone":
boundary_radii: list[float] = []
for edge in face.edges():
if str(edge.geom_type).split(".")[-1].lower() != "circle":
continue
try:
boundary_radii.append(float(edge.radius))
except ValueError:
continue
geometry["boundary_radii_mm"] = sorted(boundary_radii)
geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None
# ``through`` alone describes a cylinder spanning two opposed
# planar faces. That applies to both a through bore and the
# outside wall of a cylindrical extrusion. Classify the B-rep
# face by its oriented normal so downstream acceptance claims
# can prove holes without mistaking an exterior wall for one.
unit_axis = (direction.X, direction.Y, direction.Z)
radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z)
axial_projection = sum(radial[component] * unit_axis[component] for component in range(3))
radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3))
radial_length = sum(component * component for component in radial) ** 0.5
if geometry["surface_type"] == "cylinder":
# OCC can report a cylinder surface with ``radius=None``
# after a non-planar-side Boolean cut. The face centre is
# still on that cylinder, so its perpendicular distance to
# the rotation axis is an equivalent measured radius. Do
# not fail an otherwise valid build merely because that
# optional OCC convenience property is absent.
if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)):
geometry["radius_mm"] = float(raw_cylinder_radius)
elif radial_length > 1e-9:
geometry["radius_mm"] = radial_length
if radial_length > 1e-9:
normal_components = (normal.X, normal.Y, normal.Z)
alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length
geometry["radial_normal_alignment"] = alignment
geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown"
# Build123d can omit this optional OCC property for valid
# swept rotational faces. Keep their generic B-rep record
# so a selector-free workflow remains executable; do not
# invent axis/radius evidence for an axis-based selector.
pass
else:
geometry["cylinder_role"] = "unknown"
direction = axis.direction
origin = axis.position
geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z]
geometry["axis_direction"] = [direction.X, direction.Y, direction.Z]
raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None
if geometry["surface_type"] == "cone":
boundary_radii: list[float] = []
for edge in face.edges():
if str(edge.geom_type).split(".")[-1].lower() != "circle":
continue
try:
boundary_radii.append(float(edge.radius))
except ValueError:
continue
geometry["boundary_radii_mm"] = sorted(boundary_radii)
geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None
# ``through`` alone describes a cylinder spanning two opposed
# planar faces. That applies to both a through bore and the
# outside wall of a cylindrical extrusion. Classify the B-rep
# face by its oriented normal so downstream acceptance claims
# can prove holes without mistaking an exterior wall for one.
unit_axis = (direction.X, direction.Y, direction.Z)
radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z)
axial_projection = sum(radial[component] * unit_axis[component] for component in range(3))
radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3))
radial_length = sum(component * component for component in radial) ** 0.5
if geometry["surface_type"] == "cylinder":
# OCC can report a cylinder surface with ``radius=None``
# after a non-planar-side Boolean cut. The face centre is
# still on that cylinder, so its perpendicular distance to
# the rotation axis is an equivalent measured radius. Do
# not fail an otherwise valid build merely because that
# optional OCC convenience property is absent.
if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)):
geometry["radius_mm"] = float(raw_cylinder_radius)
elif radial_length > 1e-9:
geometry["radius_mm"] = radial_length
if radial_length > 1e-9:
normal_components = (normal.X, normal.Y, normal.Z)
alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length
geometry["radial_normal_alignment"] = alignment
geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown"
else:
geometry["cylinder_role"] = "unknown"
face_edge_indexes.append(set(boundary_edge_indexes))
face_geometries.append(geometry)
records.append(TopologyRecord(
+105 -26
View File
@@ -16,30 +16,30 @@ from .runtime_types import CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeD
from .operation_contracts import materialized_feature_contracts
_SELECTOR_REQUIRED = frozenset({"fillet", "chamfer"})
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_")
_SELECTOR_REQUIRED = frozenset({"extrude_add_blind_with_hole", "loft_add_with_cap_face", "fillet", "chamfer", "shell"})
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_")
# 开放轮廓(closed=false / role=open)只有"刀具截面补槽口边闭合后作切除"的
# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。
_OPEN_PROFILE_ATOMICS = frozenset({"extrude_cut_blind", "extrude_cut_through"})
_PRIMARY_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface",
"extrude_cut_through",
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
"revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
})
_HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"})
_ACTIVE_BODY_REQUIRED = frozenset({
"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through",
"revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer",
"loft_add_with_cap_face", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer", "shell",
# thread_cut 是 cut 型特征:必须在已有主体(宿主)上做布尔差,不能凭空
# 造实体;无宿主时按 active_body 前置阻止而非让 executor 在 None 上崩溃。
"thread_cut",
})
_BODY_MUTATING_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add",
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add", "thread_cut", *_HOLE_ATOMICS, "fillet", "chamfer",
"thread_add", "thread_cut", *_HOLE_ATOMICS, "fillet", "chamfer", "shell", "boolean_bodies",
})
# A pattern may replay a previous pattern as well as a direct body mutation.
# Context-only features have no geometry definition to instance. thread_add
@@ -47,7 +47,7 @@ _BODY_MUTATING_ATOMICS = frozenset({
# parametric axis, so a replayed thread would silently re-run at the original
# location.
_REPLAYABLE_ATOMICS = (
_BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut"})
_BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut", "shell"})
) | frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"})
_SUPPORTED_EXTENTS = frozenset({
"blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind",
@@ -163,7 +163,7 @@ def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]:
atomic_id = str(feature.get("atomic_id") or "")
if feature.get("sketch_id") is not None and (contracts.get(atomic_id) or {}).get("requires_sketch"):
required.add(str(feature["sketch_id"]))
if atomic_id == "loft_add":
if atomic_id in {"loft_add", "loft_add_with_cap_face"}:
for sketch_id in (feature.get("params") or {}).get("profile_sketch_ids") or ():
required.add(str(sketch_id))
return frozenset(required)
@@ -313,12 +313,13 @@ class CapabilityAnalyzer:
sketch_id=node.sketch_id,
atomic_id=node.atomic_id,
))
if node.atomic_id == "loft_add":
if node.atomic_id in {"loft_add", "loft_add_with_cap_face"}:
profile_ids = params.get("profile_sketch_ids")
if not isinstance(profile_ids, list) or len(profile_ids) < 2:
minimum_profiles = 1 if node.atomic_id == "loft_add_with_cap_face" else 2
if not isinstance(profile_ids, list) or len(profile_ids) < minimum_profiles:
blockers.append(self._blocker(
node.feature_id, "invalid_loft_profiles",
"Loft requires at least two profile sketch ids",
f"Loft requires at least {minimum_profiles} profile sketch ids",
))
elif len({str(sketch_id) for sketch_id in profile_ids}) != len(profile_ids):
blockers.append(self._blocker(
@@ -361,20 +362,90 @@ class CapabilityAnalyzer:
"Loft currently requires exactly one outer profile without holes",
sketch_id=sketch_id,
))
if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES):
# #2 draftextrudeParams.draft 在 cdsl_schema.json 中被允许,
# 但 runtime 的拉伸执行器(build123d Solid.extrude)没有锥形
# 拉伸能力,人读契约 profile_schema.json 也未声明该参数。
# 若 importer 把 SolidWorks 的 draft_angle_rad 写进 CDSL
# 当前 runtime 会静默产出无拔模角的直壁实体。这里把它从
# "静默忽略"改为"显式拒绝"(与 unsupported_extent 同模式)。
if params.get("draft"):
if node.atomic_id == "sweep_add":
path = params.get("path")
segment = path.get("segment") if isinstance(path, dict) else None
kind = segment.get("type") if isinstance(segment, dict) else None
if kind not in {"line", "bspline"}:
blockers.append(self._blocker(
node.feature_id, "unsupported_draft",
"Extrude draft/taper is not implemented; the runtime would silently ignore it",
node.feature_id, "unsupported_sweep_path",
"Sweep requires one captured line or B-spline path",
))
elif kind == "line" and not all(
isinstance(segment.get(key), list) and len(segment[key]) == 2
for key in ("start", "end")
):
blockers.append(self._blocker(
node.feature_id, "invalid_sweep_path",
"Sweep line path requires two-dimensional start and end points",
))
elif kind == "bspline" and (
not isinstance(segment.get("points"), list)
or len(segment.get("points") or []) < 3
):
blockers.append(self._blocker(
node.feature_id, "invalid_sweep_path",
"Sweep B-spline path requires at least three interpolation points",
))
if node.atomic_id == "boolean_bodies":
target_ids = params.get("target_feature_ids")
tool_ids = params.get("tool_feature_ids")
operation = params.get("operation")
if operation not in {"union", "subtract", "intersect"}:
blockers.append(self._blocker(
node.feature_id, "unsupported_boolean_operation",
"booleanBodies requires union, subtract or intersect",
))
for parameter, feature_ids in (("target_feature_ids", target_ids), ("tool_feature_ids", tool_ids)):
if not isinstance(feature_ids, list) or not feature_ids:
blockers.append(self._blocker(
node.feature_id, "missing_boolean_bodies",
"booleanBodies requires explicit target and tool feature ids", parameter=parameter,
))
continue
for feature_id in feature_ids:
source = nodes_by_id.get(str(feature_id))
if source is None:
blockers.append(self._blocker(
node.feature_id, "boolean_body_unavailable",
"booleanBodies source feature does not exist", feature_id=feature_id,
))
elif source.feature_id not in completed:
blockers.append(self._blocker(
node.feature_id, "boolean_body_unavailable",
"booleanBodies source feature did not become executable", feature_id=feature_id,
))
if isinstance(target_ids, list) and isinstance(tool_ids, list) and set(target_ids) & set(tool_ids):
blockers.append(self._blocker(
node.feature_id, "boolean_body_overlap",
"booleanBodies targets and tools must be disjoint",
))
if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES):
end_condition = params.get("end_condition") or {"type": "blind"}
end_type = end_condition.get("type")
draft = params.get("draft")
if draft is not None:
# 目前只将 CADFS 单侧盲向拔模映射到 build123d
# Solid.extrude_taper。双向、到面和非实体 profile 的中性面
# 语义尚无 CDSL 表达,必须保留为明确能力缺口。
if (
node.atomic_id not in {"extrude_add_blind", "extrude_cut_blind"}
or end_type != "blind"
):
blockers.append(self._blocker(
node.feature_id, "unsupported_draft_extent",
"Draft currently supports only one-sided blind extrusions",
))
elif not (
isinstance(draft, dict)
and isinstance(draft.get("angle_deg"), (int, float))
and 0 < float(draft["angle_deg"]) < 90
and isinstance(draft.get("pull_direction"), bool)
):
blockers.append(self._blocker(
node.feature_id, "invalid_draft",
"Draft requires angle_deg in (0, 90) and boolean pull_direction",
))
required.append(f"extent:{end_type}")
if end_type not in _SUPPORTED_EXTENTS:
blockers.append(self._blocker(node.feature_id, "unsupported_extent", "The extent needs a resolved topology selector or is not implemented", extent=end_type))
@@ -430,6 +501,13 @@ class CapabilityAnalyzer:
))
if node.atomic_id in _SELECTOR_REQUIRED and not node.selectors:
blockers.append(self._blocker(node.feature_id, "missing_selector", "Dress-up features require an explicit selector"))
if node.atomic_id in {"extrude_add_blind_with_hole", "loft_add_with_cap_face"}:
face_selectors = [selector for selector in node.selectors if selector.get("kind") == "face"]
if len(face_selectors) != 1 or len(node.selectors) != 1:
blockers.append(self._blocker(
node.feature_id, "invalid_profile_hole_selector",
"Derived profile features require exactly one cap-face selector",
))
if node.atomic_id in _HOLE_ATOMICS:
required.append("selector:host_face")
if not params.get("host_face"):
@@ -529,16 +607,17 @@ class CapabilityAnalyzer:
if node.atomic_id in _BODY_MUTATING_ATOMICS:
body_available = True
body_producers = {
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add",
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "boolean_bodies",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add",
# thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由
# active_body 前置阻止,文档含该类特征即视为携带可执行几何。
"thread_cut",
}
surface_producers = {"extrude_surface", "revolve_surface"}
document_blockers: list[RuntimeDiagnostic] = []
if not any(node.atomic_id in body_producers for node in plan):
if not any(node.atomic_id in body_producers | surface_producers for node in plan):
document_blockers.append(RuntimeDiagnostic(
"no_solid_feature", "CDSL contains no feature capable of creating a solid body",
))
+102 -10
View File
@@ -85,7 +85,8 @@
"properties": {
"type": {"type": "string", "minLength": 1},
"solidworks_code": {"type": "integer"},
"reference": {"$ref": "#/$defs/selectorRef"}
"reference": {"$ref": "#/$defs/selectorRef"},
"offset_mm": {"type": "number", "minimum": 0}
},
"required": ["type", "solidworks_code"],
"additionalProperties": false
@@ -98,10 +99,16 @@
},
"extrudeParams": {
"type": "object",
"properties": {"distance_mm": {"$ref": "#/$defs/number"}, "reverse": {"type": "boolean"}, "reverse_distance_mm": {"$ref": "#/$defs/number"}, "end_condition": {"$ref": "#/$defs/endCondition"}, "reverse_end_condition": {"$ref": "#/$defs/endCondition"}, "draft": {"type": "object"}},
"properties": {"distance_mm": {"$ref": "#/$defs/number"}, "reverse": {"type": "boolean"}, "reverse_distance_mm": {"$ref": "#/$defs/number"}, "end_condition": {"$ref": "#/$defs/endCondition"}, "reverse_end_condition": {"$ref": "#/$defs/endCondition"}, "draft": {"$ref": "#/$defs/extrudeDraft"}, "result_mode": {"enum": ["fuse", "new_body"]}},
"required": ["distance_mm"],
"additionalProperties": false
},
"extrudeDraft": {
"type": "object",
"properties": {"angle_deg": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 90}, "pull_direction": {"type": "boolean"}},
"required": ["angle_deg", "pull_direction"],
"additionalProperties": false
},
"extrudeCutThroughParams": {
"type": "object",
"properties": {"reverse": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}},
@@ -122,12 +129,57 @@
"required": ["profile_sketch_ids"],
"additionalProperties": false
},
"loftCapFaceParams": {
"type": "object",
"properties": {
"profile_sketch_ids": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"uniqueItems": true,
"items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
}
},
"required": ["profile_sketch_ids"],
"additionalProperties": false
},
"sweepPath": {
"type": "object",
"properties": {
"workplane": {"$ref": "#/$defs/workplane"},
"segment": {"$ref": "#/$defs/analyticSegment"}
},
"required": ["workplane", "segment"],
"additionalProperties": false
},
"sweepParams": {
"type": "object",
"properties": {
"path": {"$ref": "#/$defs/sweepPath"},
"is_frenet": {"type": "boolean"},
"result_mode": {"enum": ["fuse", "new_body"]}
},
"required": ["path"],
"additionalProperties": false
},
"revolveParams": {
"type": "object",
"properties": {"angle_deg": {"type": "number", "minimum": 0, "maximum": 360}, "axis": {"$ref": "#/$defs/axis"}, "reverse": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}},
"properties": {"angle_deg": {"type": "number", "minimum": 0, "maximum": 360}, "axis": {"$ref": "#/$defs/axis"}, "reverse": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}, "result_mode": {"enum": ["fuse", "new_body"]}},
"required": ["angle_deg", "axis"],
"additionalProperties": false
},
"revolveSurfaceParams": {
"type": "object",
"properties": {"angle_deg": {"type": "number", "minimum": 0, "maximum": 360}, "axis": {"$ref": "#/$defs/axis"}, "reverse": {"type": "boolean"}},
"required": ["angle_deg", "axis"],
"additionalProperties": false
},
"extrudeSurfaceParams": {
"type": "object",
"properties": {"distance_mm": {"type": "number", "exclusiveMinimum": 0}, "reverse": {"type": "boolean"}, "reverse_distance_mm": {"type": "number", "exclusiveMinimum": 0}},
"required": ["distance_mm"],
"additionalProperties": false
},
"sphereParams": {
"type": "object",
"properties": {
@@ -256,10 +308,27 @@
},
"chamferParams": {
"type": "object",
"properties": {"distance_mm": {"type": "number", "minimum": 0}, "distance_2_mm": {"type": "number", "minimum": 0}, "angle_rad": {"type": "number", "minimum": 0, "maximum": 3.141592653589793}},
"properties": {"distance_mm": {"type": "number", "minimum": 0}, "distance_2_mm": {"type": "number", "minimum": 0}, "angle_rad": {"type": "number", "minimum": 0, "maximum": 3.141592653589793}, "tangent_propagation": {"type": "boolean"}},
"required": ["distance_mm"],
"additionalProperties": false
},
"shellParams": {
"type": "object",
"properties": {"thickness_mm": {"type": "number", "exclusiveMinimum": 0}, "inward": {"type": "boolean"}},
"required": ["thickness_mm"],
"additionalProperties": false
},
"booleanBodiesParams": {
"type": "object",
"properties": {
"operation": {"enum": ["union", "subtract", "intersect"]},
"target_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"tool_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"keep_tools": {"type": "boolean"}
},
"required": ["operation", "target_feature_ids", "tool_feature_ids"],
"additionalProperties": false
},
"linearPatternParams": {
"type": "object",
"properties": {
@@ -278,7 +347,8 @@
"type": "object",
"properties": {
"source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"mirror_plane": {"$ref": "#/$defs/selectorOrUnresolved"}
"mirror_plane": {"$ref": "#/$defs/selectorOrUnresolved"},
"mirror_current_body": {"type": "boolean"}
},
"required": ["source_feature_ids", "mirror_plane"],
"additionalProperties": false
@@ -289,7 +359,9 @@
"source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"axis": {"$ref": "#/$defs/axis"},
"pattern_count": {"type": "integer", "minimum": 1},
"sweep_angle_deg": {"type": "number", "minimum": -360, "maximum": 360}
"sweep_angle_deg": {"type": "number", "minimum": -360, "maximum": 360},
"operation_mode": {"enum": ["add", "remove"]},
"excluded_instance_indices": {"type": "array", "items": {"type": "integer", "minimum": 1}, "uniqueItems": true}
},
"required": ["source_feature_ids", "axis", "pattern_count"],
"additionalProperties": false
@@ -337,10 +409,14 @@
"properties": {
"kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]},
"stable_id": {"type": "string", "minLength": 1},
"owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_.:-]{1,160}$"},
"geometry": {"type": "object"},
"source": {"enum": ["solidworks", "inferred_from_step", "runtime_snapshot", "viewer_selection"]},
"snapshot_id": {"type": "string", "minLength": 1},
"binding_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"match_mode": {"enum": ["unique", "all"]},
"matched_selectors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorRef"}},
"intersection_of": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}},
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
},
"required": ["kind", "stable_id", "source", "confidence"],
@@ -349,19 +425,28 @@
"analyticSegment": {
"type": "object",
"properties": {
"type": {"enum": ["line", "arc", "circle", "bspline"]},
"type": {"enum": ["line", "arc", "circle", "ellipse", "bspline"]},
"start": {"$ref": "#/$defs/point2"},
"end": {"$ref": "#/$defs/point2"},
"center": {"$ref": "#/$defs/point2"},
"radius_mm": {"$ref": "#/$defs/positive"},
"major_radius_mm": {"$ref": "#/$defs/positive"},
"minor_radius_mm": {"$ref": "#/$defs/positive"},
"major_axis": {"$ref": "#/$defs/point2"},
"points": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}},
"clockwise": {"type": "boolean"}
"parameters": {"type": "array", "minItems": 3, "items": {"type": "number"}},
"periodic": {"type": "boolean"},
"parameterization": {"enum": ["chord", "centripetal"]},
"clockwise": {"type": "boolean"},
"start_tangent": {"$ref": "#/$defs/point2"},
"end_tangent": {"$ref": "#/$defs/point2"}
},
"required": ["type"],
"allOf": [
{"if": {"properties": {"type": {"const": "line"}}}, "then": {"required": ["start", "end"]}},
{"if": {"properties": {"type": {"const": "arc"}}}, "then": {"required": ["start", "end", "center", "radius_mm"]}},
{"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}},
{"if": {"properties": {"type": {"const": "ellipse"}}}, "then": {"required": ["center", "major_radius_mm", "minor_radius_mm", "major_axis"]}},
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["start", "end", "points"]}}
],
"additionalProperties": false
@@ -405,7 +490,7 @@
"required": ["type", "contours"],
"additionalProperties": false
},
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "loft_add", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_surface", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "fillet", "chamfer", "shell", "boolean_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
"feature": {
"type": "object",
"properties": {
@@ -423,13 +508,18 @@
"additionalProperties": false,
"allOf": [
{"if": {"properties": {"atomic_id": {"const": "extrude_add_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_add_blind_with_hole"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_add_two_sided"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_through"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeCutThroughParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_two_sided"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "loft_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "loft_add_with_cap_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftCapFaceParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "sweep_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sweepParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "revolve_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "revolve_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "revolve_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveSurfaceParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeSurfaceParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "sphere_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sphereParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "box_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/boxParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "cylinder_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/cylinderParams"}}}},
@@ -440,6 +530,8 @@
{"if": {"properties": {"atomic_id": {"const": "hole_counterbore"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeCounterboreParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "fillet"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/filletParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "chamfer"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/chamferParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "shell"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/shellParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "boolean_bodies"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/booleanBodiesParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "pattern_linear"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/linearPatternParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "pattern_mirror"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/mirrorPatternParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "pattern_circular"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/circularPatternParams"}}}},
+14 -7
View File
@@ -6,14 +6,19 @@
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours"],
"operation_contracts": {
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]},
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]},
"extrude_add_blind_with_hole": {"atomic_id":"extrude_add_blind_with_hole","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","profile_hole_face"],"candidate_verifiers":["single_connected_body"]},
"extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":[]},
"loft_add": {"atomic_id":"loft_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":16,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"]},
"extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]},
"loft_add_with_cap_face": {"atomic_id":"loft_add_with_cap_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":1,"maxItems":1,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["loft_cap_face","loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"]},
"sweep_add": {"atomic_id":"sweep_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body"]},
"extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]},
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"]},
"revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"]},
"revolve_cut": {"atomic_id":"revolve_cut","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"revolve_surface": {"atomic_id":"revolve_surface","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch","single_closed_profile_without_holes"],"candidate_verifiers":["surface_shell"]},
"hole_blind": {"atomic_id":"hole_blind","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore","through_cylindrical_bore"]},
"hole_countersink": {"atomic_id":"hole_countersink","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"countersink_diameter_mm":{"type":"number","exclusiveMinimum":0},"countersink_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","countersink_diameter_mm","countersink_angle_rad"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]},
"hole_counterbore": {"atomic_id":"hole_counterbore","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"counterbore_diameter_mm":{"type":"number","exclusiveMinimum":0},"counterbore_depth_mm":{"type":"number","exclusiveMinimum":0},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","counterbore_diameter_mm","counterbore_depth_mm"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]},
@@ -55,10 +60,12 @@
"candidate_verifiers": ["cylindrical_bore"]
},
"fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]},
"chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]},
"chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"tangent_propagation":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]},
"shell": {"atomic_id":"shell","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"inward":{"type":"boolean"}},"required":["thickness_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","selected_faces_exist"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"operation":{"enum":["union","subtract","intersect"]},"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"}},"required":["operation","target_feature_ids","tool_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[]},
"pattern_linear": {"atomic_id":"pattern_linear","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"direction_1":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_1_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_1":{"type":"integer","minimum":1,"maximum":128},"direction_2":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_2_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_2":{"type":"integer","minimum":1,"maximum":128}},"required":["source_feature_ids","direction_1","spacing_1_mm","pattern_count_1"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]},
"pattern_mirror": {"atomic_id":"pattern_mirror","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true}},"required":["source_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":"params.mirror_plane","token_kind":"plane","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.mirror_plane"],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist","mirror_plane_exists"],"candidate_verifiers":[]},
"pattern_circular": {"atomic_id":"pattern_circular","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pattern_count":{"type":"integer","minimum":1,"maximum":128},"sweep_angle_deg":{"type":"number","minimum":-360,"maximum":360}},"required":["source_feature_ids","axis","pattern_count"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]}
"pattern_mirror": {"atomic_id":"pattern_mirror","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"mirror_current_body":{"type":"boolean"}},"required":["source_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":"params.mirror_plane","token_kind":"plane","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.mirror_plane"],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist","mirror_plane_exists"],"candidate_verifiers":[]},
"pattern_circular": {"atomic_id":"pattern_circular","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pattern_count":{"type":"integer","minimum":1,"maximum":128},"sweep_angle_deg":{"type":"number","minimum":-360,"maximum":360},"operation_mode":{"enum":["add","remove"]},"excluded_instance_indices":{"type":"array","items":{"type":"integer","minimum":1,"maximum":127},"uniqueItems":true}},"required":["source_feature_ids","axis","pattern_count"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]}
},
"profiles": {
"circle": {
@@ -73,7 +80,7 @@
"constraints": ["vertices contains at least three [u, v] points"]
},
"analytic_contours": {
"summary": "Closed executable line, arc, circle and interpolation B-spline contours. Executable B-splines preserve ordered interpolation points and must be closed; imported construction B-splines remain non-executable audit geometry."
"summary": "Closed executable line, arc, circle, ellipse and interpolation B-spline contours. An ellipse retains its local center, radii and major-axis direction. Executable B-splines preserve ordered interpolation points; periodic contours repeat their first point only to state closure and are interpolated from unique points. B-splines may explicitly use chord or centripetal parameterization; CADFS closed skFitSpline uses centripetal. Imported construction B-splines remain non-executable audit geometry."
}
}
}
+541 -35
View File
@@ -20,13 +20,13 @@ from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
ALL_ATOMIC_IDS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add",
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface",
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add",
"revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
"reference_plane", "reference_axis",
"hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror",
"pattern_circular",
"hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror",
"pattern_circular", "boolean_bodies",
"thread_add", "thread_cut",
})
@@ -81,12 +81,24 @@ class GeometryAdapter(Protocol):
def topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ...
def body_solids(self, body: Any) -> list[Any]: ...
def body_geometry(self, body: Any) -> dict[str, Any]: ...
def surface_geometry(self, surface: Any) -> dict[str, Any]: ...
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
def face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ...
def loft(self, sketches: list[dict[str, Any]]) -> Any: ...
def loft_with_cap_face(self, cap_face: Any, sketches: list[dict[str, Any]]) -> Any: ...
def sweep(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> Any: ...
def sweep_path(self, points: list[Vector3], *, start_tangent: Vector3 | None = None, end_tangent: Vector3 | None = None, parameters: list[float] | None = None) -> Any: ...
def extrude(self, face: Any, direction: Vector3) -> Any: ...
def extrude_taper(self, face: Any, direction: Vector3, taper_deg: float) -> Any: ...
def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ...
def surface_wires_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
def extrude_surface(self, wires: list[Any], direction: Vector3) -> Any: ...
def combine_surfaces(self, *surfaces: Any) -> Any: ...
def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def revolve_surface(self, wire: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def intersect(self, left: Any, right: Any) -> Any: ...
def fuse(self, body: Any | None, solid: Any) -> Any: ...
def combine(self, body: Any | None, solid: Any) -> Any: ...
def cut(self, body: Any, tool: Any) -> Any: ...
def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ...
def thread_solid(self, spec: ThreadSpec) -> Any: ...
@@ -94,11 +106,16 @@ class GeometryAdapter(Protocol):
def body_center(self, body: Any) -> Vector3: ...
def body_span(self, body: Any, direction: Vector3) -> float: ...
def vertex_coordinates(self, vertex: Any) -> Vector3: ...
def intersection_vertex(self, body: Any, face_sets: list[list[Any]]) -> Any: ...
def profile_sample_points(self, face: Any) -> list[Any]: ...
def profile_touches_target(self, target: Any, faces: list[Any]) -> bool: ...
def next_body_face_after(self, body: Any, faces: list[Any], direction: Vector3, *, excluded_face: Any) -> Any: ...
def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ...
def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ...
def tangent_edges(self, body: Any, seeds: list[Any]) -> list[Any]: ...
def chamfer(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> Any: ...
def surface_limited_chamfer(self, body: Any, distance_mm: float, edges: list[Any], surfaces: list[Any]) -> Any: ...
def shell(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> Any: ...
def export(self, body: Any, path: str) -> None: ...
@@ -112,27 +129,39 @@ class ExecutionSession:
body_id: str | None = None
results: dict[str, FeatureResult] = field(default_factory=dict)
replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict)
body_members: dict[str, Any] = field(default_factory=dict)
surface_members: dict[str, Any] = field(default_factory=dict)
selector_resolutions: list[dict[str, Any]] = field(default_factory=list)
active_feature_id: str = ""
def register_body(self, feature_id: str, body: Any, *, replay_node: FeaturePlanNode | None = None) -> None:
def register_body(
self,
feature_id: str,
body: Any,
*,
replay_node: FeaturePlanNode | None = None,
body_members: dict[str, Any] | None = None,
) -> None:
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id"
# 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标
# 实体;单体保持 body:{feature}(与历史行为完全一致)。
self.body = body
self.body_id = f"body:{feature_id}"
self.body_members = dict(body_members) if body_members is not None else {feature_id: body}
solids = self.adapter.body_solids(body)
if len(solids) <= 1:
self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id))
else:
for index, solid in enumerate(solids):
member_id = f"{self.body_id}:{index}"
self.topology.replace_body_topology(
feature_id, member_id,
self.adapter.topology_records(solid, feature_id, member_id),
active_body_id=self.body_id,
)
# 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让
# 已登记的本轮成员成为下一个成员的 predecessor,进而把 pattern copy
# 的 owner 错误转移到相邻实例。必须原子替换整个多 body 拓扑快照。
members = [
(member_id, self.adapter.topology_records(solid, feature_id, member_id))
for index, solid in enumerate(solids)
for member_id in [f"{self.body_id}:{index}"]
]
self.topology.replace_body_topologies(feature_id, members, active_body_id=self.body_id)
self.topology.register(TopologyRecord(
record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id,
geometry=self.adapter.body_geometry(body), value=body, owner_feature_ids=(feature_id,),
@@ -140,16 +169,99 @@ class ExecutionSession:
if replay_node is not None:
self.replay_definitions[feature_id] = replay_node
def resolve(self, selector: dict[str, Any]) -> SelectorResolution:
resolution = self.topology.resolve(selector, active_body_id=self.body_id)
def register_surface(self, feature_id: str, surface: Any) -> str:
# 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body
# 否则 surface 会覆盖 active solid 并改变最终 STEP 的实体结果。
surface_id = f"surface:{feature_id}"
self.surface_members[feature_id] = surface
for record in self.adapter.topology_records(surface, feature_id, surface_id):
self.topology.register(record)
self.topology.register(TopologyRecord(
record_id=surface_id, kind="surface", feature_id=feature_id, body_id=surface_id,
geometry=self.adapter.surface_geometry(surface), value=surface, owner_feature_ids=(feature_id,),
))
return surface_id
def _record_selector_resolution(self, resolution: SelectorResolution) -> SelectorResolution:
evidence = resolution.as_dict()
evidence["feature_id"] = self.active_feature_id
self.selector_resolutions.append(evidence)
return resolution
def result(self, node: FeaturePlanNode, *, context: PlaneSpec | AxisSpec | None = None, diagnostics: list[RuntimeDiagnostic] | None = None) -> FeatureResult:
def _intersection_component_records(self, selector: dict[str, Any]) -> list[TopologyRecord]:
matched = selector.get("matched_selectors") if selector.get("match_mode") == "all" else None
if matched is not None:
if not isinstance(matched, list) or not matched:
raise FeatureExecutionError("intersection_selector_unbound", "Intersection selector has no bound face matches")
resolved = [self._record_selector_resolution(self.topology.resolve(item, active_body_id=self.body_id)) for item in matched]
else:
binding_feature_id = selector.get("binding_feature_id")
active_body_id = None if binding_feature_id and self.body_id != f"body:{binding_feature_id}" else self.body_id
resolved = [self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))]
failures = [item for item in resolved if item.status != "resolved" or item.record is None]
if failures:
detail = failures[0].diagnostic.message if failures[0].diagnostic else "intersection selector component was not resolved"
raise FeatureExecutionError("intersection_selector_component_unresolved", detail)
return [item.record for item in resolved if item.record is not None]
def _resolve_intersection_vertex(self, selector: dict[str, Any]) -> SelectorResolution:
components = selector.get("intersection_of")
if self.body is None:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic("missing_extent_body", "Intersection selector requires an existing body"),
)
if not isinstance(components, list) or len(components) < 2:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic("intersection_selector_incomplete", "Intersection selector requires at least two face components"),
)
try:
face_sets = [self._intersection_component_records(component) for component in components]
if any(record.kind != "face" for records in face_sets for record in records):
raise FeatureExecutionError("intersection_selector_kind", "Intersection selector components must resolve to faces")
vertex = self.adapter.intersection_vertex(self.body, [[record.value for record in records] for records in face_sets])
except FeatureExecutionError as error:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic(error.code, str(error), detail=error.detail),
)
except ValueError as error:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic("intersection_vertex_unresolved", str(error)),
)
point = self.adapter.vertex_coordinates(vertex)
record = TopologyRecord(
record_id=str(selector.get("stable_id") or f"intersection:{id(vertex)}"),
kind="vertex", feature_id=self.active_feature_id, body_id=self.body_id,
geometry={"center_mm": list(point)}, value=vertex,
owner_feature_ids=tuple(filter(None, [str(selector.get("owner_feature_id") or "")])),
)
return SelectorResolution(
selector=selector, status="resolved", record=record,
candidates=({"score": 1.0, **record.public_dict()},),
)
def resolve(self, selector: dict[str, Any]) -> SelectorResolution:
if selector.get("intersection_of") is not None:
return self._record_selector_resolution(self._resolve_intersection_vertex(selector))
owner = str(selector.get("owner_feature_id") or "")
active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id
return self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))
def result(
self,
node: FeaturePlanNode,
*,
context: PlaneSpec | AxisSpec | None = None,
diagnostics: list[RuntimeDiagnostic] | None = None,
include_body: bool = True,
surface_id: str | None = None,
) -> FeatureResult:
result = FeatureResult(
feature_id=node.feature_id, atomic_id=node.atomic_id, status="executed", body_id=self.body_id,
feature_id=node.feature_id, atomic_id=node.atomic_id, status="executed",
body_id=self.body_id if include_body else None, surface_id=surface_id,
context=context, replay_definition={"atomic_id": node.atomic_id, "params": deepcopy(node.params), "sketch_id": node.sketch_id},
diagnostics=diagnostics or [],
)
@@ -232,6 +344,17 @@ def _targeted_extent_vector(
raise FeatureExecutionError("non_uniform_extent_target", "The target vertex does not define one extrusion distance", extent=condition)
distance = sum(projections) / len(projections)
else:
if condition == "up_to_surface" and session.adapter.profile_touches_target(target, faces):
# 草图轮廓本身就在所选终止面上时,selected face 只是拉伸的起始
# 边界。应沿实际拉伸方向穿过当前 body,取下一张完整截获 profile
# 的边界面作为终止面;直接裁剪到 selected face 会生成零厚度工具体。
try:
next_face = session.adapter.next_body_face_after(
session.body, faces, direction, excluded_face=target,
)
except ValueError as error:
raise FeatureExecutionError("extent_target_not_reached", str(error), extent=condition) from error
return ExtentVector(vector_scale(direction, 1.0), trim_to=next_face)
try:
distance = session.adapter.uniform_intersection_distance(target, faces, direction)
except ValueError as error:
@@ -250,13 +373,15 @@ def _targeted_extent_vector(
# 裁剪体层,仍保持显式拒绝。
return ExtentVector(vector_scale(direction, 1.0), trim_to=target)
raise FeatureExecutionError(code, message, extent=condition) from error
offset = abs(float((end_condition or {}).get("offset_mm") or 0.0))
if condition == "offset_from_surface":
offset = abs(float(offset_mm if offset_mm is not None else node.params.get("distance_mm") or 0.0))
offset = abs(float(offset_mm if offset_mm is not None else offset or node.params.get("distance_mm") or 0.0))
if offset:
distance -= offset
if distance <= 1e-6:
raise FeatureExecutionError(
"invalid_extent_offset",
"Offset distance reaches or passes the target surface",
"Offset distance reaches or passes the target extent",
extent=condition, offset_mm=offset,
)
return ExtentVector(vector_scale(direction, distance))
@@ -410,6 +535,14 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
faces = session.adapter.faces_for_sketch(selected_sketch)
if not faces:
raise ValueError("sketch does not create a closed profile region")
if node.atomic_id == "extrude_add_blind_with_hole":
resolved = [session.resolve(selector) for selector in node.selectors]
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face":
raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "profile hole selector is unresolved")
if len(faces) != 1:
raise ValueError("profile hole extrusion requires exactly one outer sketch region")
faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])]
# 3. 按特征类型生成子实体:
if node.atomic_id.startswith("extrude_"):
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
@@ -417,10 +550,18 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
# profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
extents = _extent_vectors(node, faces, selected_sketch, session)
draft = node.params.get("draft")
taper_deg = 0.0
if isinstance(draft, dict):
taper_deg = float(draft["angle_deg"])
if not bool(draft["pull_direction"]):
taper_deg = -taper_deg
solids: list[Any] = []
for face in faces:
for extent in extents:
if extent.trim_to is None:
if draft is not None:
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
elif extent.trim_to is None:
solids.append(session.adapter.extrude(face, extent.vector))
else:
solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector))
@@ -451,11 +592,106 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
if session.body is None:
raise ValueError("cut feature has no body")
body = session.adapter.cut(session.body, tool)
members = {node.feature_id: body}
elif node.params.get("result_mode") == "new_body":
# FeatureScript NEW creates an independent result body even when it
# intersects a prior body. Keep both shapes in the exported compound.
body = session.adapter.combine(session.body, tool)
members = {**session.body_members, node.feature_id: tool}
else:
# 添加类特征:将工具体并到当前主体上(fuse),首个特征时 body 为 None 也能直接成立。
body = session.adapter.fuse(session.body, tool)
members = {node.feature_id: body}
# 6. 登记新主体(更新拓扑、记录重放定义),并返回该特征的结果对象。
session.register_body(node.feature_id, body, replay_node=node)
session.register_body(node.feature_id, body, replay_node=node, body_members=members)
return session.result(node)
def _execute_revolve_surface(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# Surface revolve 的 profile 是单一闭合 wire。它只生成独立 shell,不能参与
# 当前实体 body 的 fuse/cut,也不能把其结果误报为新的实体 body。
sketch = session.sketches.get(str(node.sketch_id))
if sketch is None:
raise ValueError("surface revolve has no resolved sketch")
faces = session.adapter.faces_for_sketch(sketch)
if len(faces) != 1 or faces[0].inner_wires():
raise ValueError("surface revolve requires exactly one closed profile without holes")
axis = _revolve_axis(node, session)
_validate_revolve_axis_in_sketch_plane(axis, sketch)
angle = float(node.params.get("angle_deg") or 0.0)
if angle <= 0:
raise ValueError("surface revolve requires angle_deg > 0")
if bool(node.params.get("reverse")):
angle = -angle
surface_id = session.register_surface(
node.feature_id,
session.adapter.revolve_surface(faces[0].outer_wire(), angle, axis),
)
return session.result(node, include_body=False, surface_id=surface_id)
def _execute_extrude_surface(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# surfaceEntities 的曲面拉伸沿用实体特征已 lower 的距离,但始终独立登记为
# shell。它既不改变 active solid,也不以曲面参与实体 fuse/cut。
sketch = session.sketches.get(str(node.sketch_id))
if sketch is None:
raise ValueError("surface extrude has no resolved sketch")
direction = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal")
if bool(node.params.get("reverse")):
direction = vector_scale(direction, -1)
distance = float(node.params.get("distance_mm") or 0.0)
if distance <= 0:
raise ValueError("surface extrude requires distance_mm > 0")
wires = session.adapter.surface_wires_for_sketch(sketch)
surface = session.adapter.extrude_surface(wires, vector_scale(direction, distance))
reverse_distance = float(node.params.get("reverse_distance_mm") or 0.0)
if reverse_distance > 0:
opposite = session.adapter.extrude_surface(wires, vector_scale(direction, -reverse_distance))
surface = session.adapter.combine_surfaces(surface, opposite)
surface_id = session.register_surface(node.feature_id, surface)
return session.result(node, include_body=False, surface_id=surface_id)
def _combine_members(session: ExecutionSession, members: dict[str, Any]) -> Any:
body = None
for member in members.values():
body = session.adapter.combine(body, member)
if body is None:
raise ValueError("booleanBodies produced no result bodies")
return body
def _execute_boolean_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# booleanBodies 总是作用于 source feature 的明确 body 输出,不能回退为
# 当前聚合 body。这样相邻独立实体不会意外成为工具或目标。
params = node.params
target_ids = [str(value) for value in params.get("target_feature_ids") or []]
tool_ids = [str(value) for value in params.get("tool_feature_ids") or []]
missing = [feature_id for feature_id in target_ids + tool_ids if feature_id not in session.body_members]
if missing:
raise ValueError("booleanBodies source bodies are unavailable: " + ", ".join(missing))
targets = {feature_id: session.body_members[feature_id] for feature_id in target_ids}
tools = {feature_id: session.body_members[feature_id] for feature_id in tool_ids}
target = _combine_members(session, targets)
tool = _combine_members(session, tools)
operation = str(params.get("operation") or "")
if operation == "union":
result = session.adapter.fuse(target, tool)
elif operation == "subtract":
result = session.adapter.cut(target, tool)
elif operation == "intersect":
result = session.adapter.intersect(target, tool)
else:
raise ValueError(f"unsupported booleanBodies operation {operation!r}")
members = {
feature_id: body
for feature_id, body in session.body_members.items()
if feature_id not in set(target_ids + tool_ids)
}
members[node.feature_id] = result
if bool(params.get("keep_tools")):
members.update(tools)
session.register_body(node.feature_id, _combine_members(session, members), body_members=members)
return session.result(node)
@@ -474,6 +710,79 @@ def _execute_loft_add(node: FeaturePlanNode, session: ExecutionSession) -> Featu
return session.result(node)
def _execute_loft_add_with_cap_face(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
resolved = [session.resolve(selector) for selector in node.selectors]
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face":
raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "cap-face loft selector is unresolved")
profile_ids = node.params.get("profile_sketch_ids") or []
profiles: list[dict[str, Any]] = []
for sketch_id in profile_ids:
sketch = session.sketches.get(str(sketch_id))
if sketch is None:
raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved")
profiles.append(sketch)
solid = session.adapter.loft_with_cap_face(resolved[0].record.value, profiles)
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
return session.result(node)
def _sweep_path(node: FeaturePlanNode, session: ExecutionSession) -> Any:
# 路径是 self-contained CDSL 数据,避免重放时依赖临时草图或 source id。
path = node.params.get("path") or {}
if not isinstance(path, dict):
raise ValueError("sweep path must be an object")
plane = PlaneSpec.from_mapping(path.get("workplane") or {})
segment = path.get("segment") or {}
if not isinstance(segment, dict):
raise ValueError("sweep path segment must be an object")
kind = str(segment.get("type") or "")
if kind == "line":
local_points = [segment.get("start"), segment.get("end")]
elif kind == "bspline":
local_points = segment.get("points") or []
else:
raise ValueError(f"unsupported sweep path segment {kind!r}")
if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points):
raise ValueError("sweep path requires two-dimensional points")
def point(value: list[float]) -> Vector3:
return vector_add(
plane.origin_mm,
vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))),
)
def tangent(value: Any) -> Vector3 | None:
if value is None:
return None
if not isinstance(value, list) or len(value) != 2:
raise ValueError("sweep path tangent must contain two coordinates")
return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1])))
return session.adapter.sweep_path(
[point(value) for value in local_points],
start_tangent=tangent(segment.get("start_tangent")),
end_tangent=tangent(segment.get("end_tangent")),
parameters=[float(value) for value in segment.get("parameters") or []] or None,
)
def _execute_sweep_add(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None = None) -> FeatureResult:
profile = sketch or session.sketches.get(str(node.sketch_id))
if profile is None:
raise ValueError("sweep has no resolved profile sketch")
faces = session.adapter.faces_for_sketch(profile)
if len(faces) != 1:
raise ValueError("sweep requires exactly one closed profile region")
solid = session.adapter.sweep(
faces[0], _sweep_path(node, session),
is_frenet=bool(node.params.get("is_frenet", False)),
)
body = session.adapter.combine(session.body, solid) if node.params.get("result_mode") == "new_body" else session.adapter.fuse(session.body, solid)
session.register_body(node.feature_id, body, replay_node=node)
return session.result(node)
def _execute_reference_plane(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 基准面特征(reference_plane)执行入口:从参数解析平面并登记为拓扑上下文。
@@ -708,17 +1017,93 @@ def _selector_edges(node: FeaturePlanNode, session: ExecutionSession, *, tangent
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed:
raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed")
def is_body_boundary(edge: Any) -> bool:
# 圆柱、圆锥等周期面会带一条仅属于自身的参数 seam。该线不是实体
# 边界;FeatureScript 以 FACE 选择倒角时不应将其当作额外的待倒角边,
# 否则连续的锥面会被错误切成两段。显式 EDGE selector 仍可表达真正的
# 单边选择,所以这里只约束由 FACE 展开的候选边。
face_count = sum(
1
for face in session.body.faces()
if any(candidate.is_same(edge) for candidate in face.edges())
)
return face_count >= 2
edges: list[Any] = []
for item in resolved:
if item.record.kind == "edge":
edges.append(item.record.value)
elif item.record.kind == "face":
edges.extend(item.record.value.edges())
edges.extend(edge for edge in item.record.value.edges() if is_body_boundary(edge))
if not edges:
raise ValueError("selectors did not resolve any edges")
return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges
def _shell_target(node: FeaturePlanNode, session: ExecutionSession) -> tuple[Any, list[Any]]:
# shell 的 remove-face selector 必须全部属于同一实体。CADFS 允许一个
# Compound 中保留多个独立 body,不能将整组 body 交给 OCC 后由内核猜测
# 应抽壳的成员。
resolved = [session.resolve(selector) for selector in node.selectors]
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed:
raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed")
records = [item.record for item in resolved if item.record is not None]
if not records or any(record.kind != "face" for record in records):
raise ValueError("shell selectors must resolve to faces")
target_ids = {record.body_id for record in records}
if len(target_ids) != 1:
raise ValueError("shell faces must belong to one target body")
target_id = next(iter(target_ids))
members = session.adapter.body_solids(session.body)
if len(members) == 1:
return members[0], [record.value for record in records]
if target_id is None or session.body_id is None:
raise ValueError("shell target body is unresolved")
prefix = f"{session.body_id}:"
if not target_id.startswith(prefix):
raise ValueError("shell target body is outside the active body set")
try:
member_index = int(target_id[len(prefix):])
except ValueError as error:
raise ValueError("shell target body has an invalid member id") from error
if member_index < 0 or member_index >= len(members):
raise ValueError("shell target body member is unavailable")
return members[member_index], [record.value for record in records]
def _replace_shell_target(session: ExecutionSession, target: Any, replacement: Any) -> Any:
# 仅替换抽壳目标实体;其他独立实体保持原样和原有相对顺序。
members = session.adapter.body_solids(session.body)
if len(members) == 1:
return replacement
replaced = False
result = None
for member in members:
if member.is_same(target):
result = session.adapter.combine(result, replacement)
replaced = True
else:
result = session.adapter.combine(result, member)
if not replaced or result is None:
raise ValueError("shell target solid is no longer part of the active body")
return result
def _execute_shell(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 抽壳特征:移除 selector 所指面,并按 CADFS thickness 向实体内部偏置。
if session.body is None:
raise ValueError("shell has no body")
thickness = float(node.params.get("thickness_mm") or 0)
if thickness <= 0:
raise ValueError("shell thickness_mm must be > 0")
target, faces = _shell_target(node, session)
result = session.adapter.shell(target, faces, thickness, inward=bool(node.params.get("inward", True)))
session.register_body(node.feature_id, _replace_shell_target(session, target, result), replay_node=node)
return session.result(node)
def _execute_fillet(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。
@@ -758,13 +1143,31 @@ def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> Featur
if distance_2 is None and angle_rad is not None:
distance_2 = distance * math.tan(float(angle_rad))
# 4. 解析目标边(支持相切传播),执行倒角。
body = session.adapter.chamfer(
session.body, distance, distance_2,
_selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
)
edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation")))
diagnostics: list[RuntimeDiagnostic] = []
try:
body = session.adapter.chamfer(session.body, distance, distance_2, edges)
except ValueError as error:
# 显式 surfaceEntities 可以在后续实体上留下曲面分区边界。若标准
# OCC 倒角因环域宽度不足而拒绝,只允许在该 shell 给出同轴边界证据
# 时按原始距离构造受限倒角;没有证明时仍保留原始内核失败。
if distance_2 is not None or not session.surface_members:
raise
try:
body = session.adapter.surface_limited_chamfer(
session.body, distance, edges, list(session.surface_members.values()),
)
except ValueError:
raise error
diagnostics.append(RuntimeDiagnostic(
"chamfer_surface_limited",
"Chamfer was limited by an explicit coaxial surface boundary",
feature_id=node.feature_id,
detail={"distance_mm": distance, "surface_count": len(session.surface_members)},
))
# 5. 登记新主体并返回结果。
session.register_body(node.feature_id, body, replay_node=node)
return session.result(node)
return session.result(node, diagnostics=diagnostics)
def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any]:
@@ -1111,6 +1514,15 @@ def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) ->
resolution = session.resolve(mirror)
if resolution.status != "resolved" or not isinstance(resolution.record.value, PlaneSpec):
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "mirror plane was not resolved")
if node.params.get("mirror_current_body"):
# CADFS SWEPT_BODY 表示被后续 feature 持续修改的同一实体。这里复制
# 当前 B-rep 再镜像并合并,不能重放其初始 additive feature,否则会
# 丢失后续 cut/fillet 并生成独立错误实体。
if session.body is None:
raise ValueError("mirror current body has no active body")
mirrored = session.adapter.mirror(session.body, resolution.record.value)
session.register_body(node.feature_id, session.adapter.fuse(session.body, mirrored), replay_node=node)
return session.result(node)
sources = session.replay_sources(node.params.get("source_feature_ids") or [])
if not sources:
raise ValueError("mirror pattern source features have no replay definitions")
@@ -1210,6 +1622,14 @@ def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle
plane[key] = _rotated_point(plane[key], axis, angle_rad)
else:
plane[key] = list(_rotated_vector(tuple(float(v) for v in plane[key]), axis, angle_rad))
path = params.get("path")
path_plane = path.get("workplane") if isinstance(path, dict) else None
if isinstance(path_plane, dict):
if path_plane.get("origin_mm"):
path_plane["origin_mm"] = _rotated_point(path_plane["origin_mm"], axis, angle_rad)
for key in ("x_dir", "y_dir", "normal"):
if path_plane.get(key):
path_plane[key] = list(_rotated_vector(tuple(float(v) for v in path_plane[key]), axis, angle_rad))
host = params.get("host_face")
host_frame = host.get("frame") if isinstance(host, dict) else None
positions_are_local = isinstance(host_frame, dict) and all(
@@ -1280,6 +1700,25 @@ def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle
return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature)
def _pattern_operation_node(node: FeaturePlanNode, operation_mode: str) -> FeaturePlanNode:
# REMOVE pattern 的实例必须沿用 source 的 profile/extent,但以 cut 而不是
# add 写入当前主体。lowering 已将初始 source 同步改写,运行时保留此处以
# 支持完整的 CDSL replay contract。
if operation_mode != "remove": return node
atomic_id = {
"extrude_add_blind": "extrude_cut_blind",
"extrude_add_two_sided": "extrude_cut_two_sided",
"revolve_add": "revolve_cut",
}.get(node.atomic_id, node.atomic_id)
if atomic_id == node.atomic_id and "cut" not in atomic_id:
raise ValueError("REMOVE pattern source is not a replayable cutting feature")
params = {key: value for key, value in node.params.items() if key != "result_mode"}
return FeaturePlanNode(
node.feature_id, atomic_id, node.name, node.depends_on, params,
node.selectors, node.sketch_id, node.declared_status, node.source_feature,
)
def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult:
# 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征
# 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。
@@ -1292,10 +1731,18 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession,
if count < 1:
raise ValueError("circular pattern pattern_count must be >= 1")
sweep_angle_deg = float(params.get("sweep_angle_deg") or 360.0)
operation_mode = str(params.get("operation_mode") or "add")
if operation_mode not in {"add", "remove"}:
raise ValueError("circular pattern operation_mode must be add or remove")
excluded = {int(value) for value in params.get("excluded_instance_indices") or []}
if any(instance < 1 or instance >= count for instance in excluded):
raise ValueError("circular pattern excluded instance is outside the generated range")
sources = session.replay_sources(params.get("source_feature_ids") or [])
if not sources:
raise ValueError("circular pattern source features have no replay definitions")
for instance in range(1, count):
if instance in excluded:
continue
# 实例 i 位于包角 sweep_angle_deg 的 i/count 处(i=0 即源特征本身)。
angle_deg = sweep_angle_deg * instance / count
angle_rad = math.radians(angle_deg)
@@ -1309,10 +1756,25 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession,
"by multiples of 180 degrees"
)
cloned = _rotated_node(source, f"{node.feature_id}.c{instance}.{source.feature_id}", axis, angle_rad, session)
cloned = _pattern_operation_node(cloned, operation_mode)
# CADFS pattern instances are copies of the source result, not
# independent `NEW` operations. Replay them through normal add
# semantics: intersecting or face-sharing instances fuse, while
# spatially separate copies remain separate solids in the result.
if cloned.params.get("result_mode") == "new_body":
cloned = FeaturePlanNode(
cloned.feature_id, cloned.atomic_id, cloned.name, cloned.depends_on,
{key: value for key, value in cloned.params.items() if key != "result_mode"},
cloned.selectors, cloned.sketch_id, cloned.declared_status, cloned.source_feature,
)
sketch = session.sketches.get(str(source.sketch_id))
execute(cloned, session, _rotated_sketch(sketch, axis, angle_rad) if sketch else None)
# 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放。
session.replay_definitions[node.feature_id] = node
# 环形阵列本身是完整 B-rep 结果的 producer。每个 replay 子特征都会更新
# active body;循环结束后必须用 pattern feature 重新登记最终快照,否则后续
# selector binding 会只保留最后一个实例的 body id,漏掉其它 COPY 实例。
if session.body is None:
raise ValueError("circular pattern produced no body")
session.register_body(node.feature_id, session.body, replay_node=node)
return session.result(node)
@@ -1351,11 +1813,30 @@ def _primary_executor(node: FeaturePlanNode, session: ExecutionSession, sketch:
return _shape_from_primary(node, session, sketch=sketch)
def _revolve_surface_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_revolve_surface(node, session)
def _extrude_surface_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_extrude_surface(node, session)
def _loft_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_loft_add(node, session)
def _loft_cap_face_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_loft_add_with_cap_face(node, session)
def _sweep_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
return _execute_sweep_add(node, session, sketch)
def _reference_plane_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_reference_plane(node, session)
@@ -1401,6 +1882,11 @@ def _chamfer_executor(node: FeaturePlanNode, session: ExecutionSession, sketch:
return _execute_chamfer(node, session)
def _shell_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_shell(node, session)
def _linear_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_linear_pattern(node, session, _execute_node)
@@ -1420,19 +1906,26 @@ EXECUTORS: dict[str, ExecutorFunction] = {
"thread_add": _thread_executor,
"thread_cut": _thread_executor,
"extrude_add_blind": _primary_executor,
"extrude_add_blind_with_hole": _primary_executor,
"extrude_add_two_sided": _primary_executor,
"extrude_cut_blind": _primary_executor,
"extrude_cut_two_sided": _primary_executor,
"extrude_cut_through": _primary_executor,
"loft_add": _loft_executor,
"loft_add_with_cap_face": _loft_cap_face_executor,
"sweep_add": _sweep_executor,
"revolve_add": _primary_executor,
"revolve_cut": _primary_executor,
"revolve_surface": _revolve_surface_executor,
"extrude_surface": _extrude_surface_executor,
"hole_blind": _hole_executor,
"hole_countersink": _hole_executor,
"hole_counterbore": _hole_executor,
"hole_wizard": _hole_wizard_executor,
"fillet": _fillet_executor,
"chamfer": _chamfer_executor,
"shell": _shell_executor,
"boolean_bodies": lambda node, session, sketch: _execute_boolean_bodies(node, session),
"pattern_linear": _linear_pattern_executor,
"pattern_mirror": _mirror_pattern_executor,
"pattern_circular": _circular_pattern_executor,
@@ -1497,20 +1990,33 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
diagnostics.append(diagnostic)
if strict:
raise RuntimeExecutionError(diagnostic, list(session.selector_resolutions)) from error
if session.body is None:
raise ValueError("CDSL execution produced no body")
output = session.body
surface_geometry: dict[str, Any] | None = None
if output is None:
if not session.surface_members:
raise ValueError("CDSL execution produced no body")
# 纯曲面文档没有 active solid,但依然是可执行的 CAD 结果。只有在
# 没有实体时才将 surface members 作为 STEP 输出,混合模型继续只导出
# 实体,避免曲面意外改变既有实体比较和下游消费语义。
output = session.adapter.combine_surfaces(*session.surface_members.values())
surface_geometry = session.adapter.surface_geometry(output)
out_step.parent.mkdir(parents=True, exist_ok=True)
session.adapter.export(session.body, str(out_step))
geometry = session.adapter.body_geometry(session.body)
session.adapter.export(output, str(out_step))
geometry = session.adapter.body_geometry(session.body) if session.body is not None else surface_geometry
if geometry is None:
raise ValueError("CDSL execution produced no exportable geometry")
bbox = geometry["bbox_mm"]
return {
"engine": "cdsl_session_runtime",
"out_step": str(out_step),
"volume_mm3": float(geometry["volume_mm3"]),
"volume_mm3": float(geometry.get("volume_mm3") or 0.0),
"bbox_mm": {"min": bbox[:3], "max": bbox[3:]},
# #7 multi-body:重建结果里的独立实体数(Compound 成员数),
# 与 batch 验证的 document_truth.geometry.solid_body_count 对齐。
"solid_count": len(session.adapter.body_solids(session.body)),
"solid_count": len(session.adapter.body_solids(session.body)) if session.body is not None else 0,
"surface_count": len(session.surface_members),
"surface_face_count": int(surface_geometry["face_count"]) if surface_geometry is not None else 0,
"surface_area_mm2": float(surface_geometry["area_mm2"]) if surface_geometry is not None else 0.0,
"feature_results": [result.as_dict() for result in session.results.values()],
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics],
"topology_records": [record.public_dict() for record in session.topology.records()],
+37 -11
View File
@@ -412,6 +412,7 @@ class FeatureResult:
atomic_id: str
status: str
body_id: str | None = None
surface_id: str | None = None
context: PlaneSpec | AxisSpec | None = None
replay_definition: dict[str, Any] | None = None
diagnostics: list[RuntimeDiagnostic] = field(default_factory=list)
@@ -425,6 +426,8 @@ class FeatureResult:
}
if self.body_id is not None:
output["body_id"] = self.body_id
if self.surface_id is not None:
output["surface_id"] = self.surface_id
if self.context is not None:
output["context"] = self.context.as_dict()
if self.replay_definition is not None:
@@ -536,6 +539,12 @@ class TopologyRegistry:
def replace_body_topology(
self, feature_id: str, body_id: str, records: Iterable[TopologyRecord],
*, active_body_id: str | None = None,
) -> None:
self.replace_body_topologies(feature_id, [(body_id, records)], active_body_id=active_body_id)
def replace_body_topologies(
self, feature_id: str, bodies: Iterable[tuple[str, Iterable[TopologyRecord]]],
*, active_body_id: str | None = None,
) -> None:
"""Record a fresh B-rep snapshot after a feature mutates the body.
@@ -559,15 +568,31 @@ class TopologyRegistry:
or record.body_id.startswith(f"{self._active_body_id}:")
)
]
records = list(records)
# 同一 source feature 的 pattern copy 可以产生完全相同的几何面。它们
# 必须保留为多个实例,不能在跨 body 的全局 predecessor 匹配中互相消费。
# pattern 的 Compound 成员顺序是稳定的:已有实例以同一 member index
# 延续,新增实例只会出现在末尾。按该 index 限定后继匹配。
current = [(body_id, list(records)) for body_id, records in bodies]
previous_member_ids = {
suffix for record in previous
for suffix in [str(record.body_id).rsplit(":", 1)[-1]]
if suffix.isdigit()
}
use_member_indexes = len(current) > 1 and previous_member_ids
consumed_predecessors: set[str] = set()
for record in records:
predecessor = self._unique_equivalent_predecessor(record, previous, consumed_predecessors)
owners = predecessor.owners if predecessor is not None else (feature_id,)
if predecessor is not None:
consumed_predecessors.add(predecessor.record_id)
self.register(
TopologyRecord(
registered: list[TopologyRecord] = []
for body_id, records in current:
member_id = str(body_id).rsplit(":", 1)[-1]
local_predecessors = [
record for record in previous
if not use_member_indexes or str(record.body_id).rsplit(":", 1)[-1] == member_id
]
for record in records:
predecessor = self._unique_equivalent_predecessor(record, local_predecessors, consumed_predecessors)
owners = predecessor.owners if predecessor is not None else (feature_id,)
if predecessor is not None:
consumed_predecessors.add(predecessor.record_id)
registered.append(TopologyRecord(
record_id=record.record_id,
kind=record.kind,
feature_id=feature_id,
@@ -575,8 +600,9 @@ class TopologyRegistry:
geometry=dict(record.geometry),
value=record.value,
owner_feature_ids=owners,
)
)
))
for record in registered:
self.register(record)
# #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续
# selector 的 stable_id 引用解析到 active body 内的新形态。多条演化
# 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段
@@ -588,7 +614,7 @@ class TopologyRegistry:
candidates = sorted(
(
(self._evolved_drift(prior, record), record.record_id)
for record in records if self._evolved_equivalent(prior, record)
for record in registered if self._evolved_equivalent(prior, record)
),
key=lambda item: item[0],
)
@@ -87,7 +87,8 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
deferred.append(fid)
for index, selector in enumerate(feature.get("selectors") or []):
owner = selector.get("owner_feature_id")
if owner is not None and owner not in feature_ids:
binding_owner = selector.get("binding_feature_id")
if owner is not None and owner not in feature_ids and binding_owner not in feature_ids:
raise ValueError(f"Feature {fid} selector {index} has a forward or missing owner_feature_id")
if feature.get("unresolved"):
unresolved.append({"feature_id": fid, "reasons": list(feature["unresolved"])})
+123 -7
View File
@@ -1,7 +1,7 @@
"""Core CDSL sketch resolver.
The runtime accepts only direct geometric descriptions: circles, straight-edge
polygons, and closed analytic line/arc/circle/B-spline contours. Semantic
polygons, and closed analytic line/arc/circle/ellipse/B-spline contours. Semantic
shapes and historical profile macros belong to the importer compatibility
layer and must be lowered before this module is invoked.
"""
@@ -58,6 +58,12 @@ def _to_3d(workplane: _Ctx, u: float, v: float) -> list[float]:
return [origin[0] + u * x_dir[0] + v * y_dir[0], origin[1] + u * x_dir[1] + v * y_dir[1], origin[2] + u * x_dir[2] + v * y_dir[2]]
def _to_3d_vector(workplane: _Ctx, u: float, v: float) -> list[float]:
origin = _to_3d(workplane, 0.0, 0.0)
target = _to_3d(workplane, u, v)
return [target[index] - origin[index] for index in range(3)]
def _dot(left: Iterable[float], right: Iterable[float]) -> float:
return sum(a * b for a, b in zip(left, right))
@@ -81,11 +87,19 @@ def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]:
if edge["type"] == "arc":
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
output["normal"] = list(normal)
elif edge["type"] == "ellipse":
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
output["major_axis_mm"] = _to_3d_vector(workplane, edge["major_axis_mm"][0], edge["major_axis_mm"][1])
output["normal"] = list(normal)
elif edge["type"] == "bspline":
output["points_mm"] = [
_to_3d(workplane, point[0], point[1])
for point in edge["points_mm"]
]
for key in ("start_tangent_mm", "end_tangent_mm"):
if key in edge:
tangent = edge[key]
output[key] = _to_3d_vector(workplane, tangent[0], tangent[1])
transformed.append(output)
return transformed
@@ -96,8 +110,9 @@ def _gen_circle(profile: _Ctx, _: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
if radius <= 0:
raise ValueError("circle radius must be > 0")
cx, cy = float(center[0]), float(center[1])
points = [[cx + radius, cy, 0.0], [cx, cy + radius, 0.0], [cx - radius, cy, 0.0], [cx, cy - radius, 0.0], [cx + radius, cy, 0.0]]
return [_circle([cx, cy], radius)], [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius) for index in range(4)]
# 直接圆 profile 必须保留为一条完整的圆边。若拆成四条圆弧,后续按边
# 选择的圆角/倒角会把同一拓扑圆误解为四个独立目标。
return [_circle([cx, cy], radius)], []
def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
@@ -118,6 +133,19 @@ def _distance(left: list[float], right: list[float]) -> float:
return math.hypot(float(left[0]) - float(right[0]), float(left[1]) - float(right[1]))
def _centripetal_parameters(points: list[list[float]], periodic: bool) -> list[float]:
pairs = list(zip(points, points[1:]))
if periodic:
pairs.append((points[-1], points[0]))
parameters = [0.0]
for start, end in pairs:
distance = math.dist(start, end)
if distance <= _TOLERANCE_MM:
raise ValueError("analytic_contours: centripetal bspline has coincident interpolation points")
parameters.append(parameters[-1] + math.sqrt(distance))
return parameters
def _reverse(edge: _Ctx) -> _Ctx:
output = deepcopy(edge)
output["start_mm"], output["end_mm"] = output["end_mm"], output["start_mm"]
@@ -125,6 +153,16 @@ def _reverse(edge: _Ctx) -> _Ctx:
output["clockwise"] = not bool(output["clockwise"])
if output.get("type") == "bspline":
output["points_mm"] = list(reversed(output["points_mm"]))
parameters = output.get("parameters")
if parameters is not None:
final_parameter = float(parameters[-1])
output["parameters"] = [final_parameter - float(value) for value in reversed(parameters)]
start_tangent = output.pop("start_tangent_mm", None)
end_tangent = output.pop("end_tangent_mm", None)
if end_tangent is not None:
output["start_tangent_mm"] = [-float(value) for value in end_tangent]
if start_tangent is not None:
output["end_tangent_mm"] = [-float(value) for value in start_tangent]
return output
@@ -174,6 +212,27 @@ def _circle_edges(segment: _Ctx) -> list[_Ctx]:
return [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)]
def _ellipse_edges(segment: _Ctx) -> list[_Ctx]:
center = segment.get("center") or [0.0, 0.0]
major_radius = float(segment.get("major_radius_mm") or 0.0)
minor_radius = float(segment.get("minor_radius_mm") or 0.0)
major_axis = segment.get("major_axis") or []
if major_radius <= 0 or minor_radius <= 0:
raise ValueError("analytic_contours: ellipse radii must be > 0")
if len(major_axis) < 2:
raise ValueError("analytic_contours: ellipse major_axis must have two components")
axis_length = math.hypot(float(major_axis[0]), float(major_axis[1]))
if axis_length <= _TOLERANCE_MM:
raise ValueError("analytic_contours: ellipse major_axis is degenerate")
cx, cy = float(center[0]), float(center[1])
ux, uy = float(major_axis[0]) / axis_length, float(major_axis[1]) / axis_length
start = [cx + major_radius * ux, cy + major_radius * uy, 0.0]
return [{
"type": "ellipse", "start_mm": start, "end_mm": list(start), "center_mm": [cx, cy, 0.0],
"major_axis_mm": [ux, uy, 0.0], "major_radius_mm": major_radius, "minor_radius_mm": minor_radius,
}]
def _segment_edges(segment: _Ctx) -> list[_Ctx]:
kind = segment.get("type")
if kind == "line":
@@ -182,17 +241,55 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
if kind == "circle":
return _circle_edges(segment)
if kind == "ellipse":
return _ellipse_edges(segment)
if kind == "bspline":
points = segment.get("points") or []
if len(points) < 3:
raise ValueError("analytic_contours: bspline needs at least 3 interpolation points")
converted = [_point(point) for point in points]
return [{
periodic = bool(segment.get("periodic"))
if periodic:
if _distance(converted[0], converted[-1]) > _TOLERANCE_MM:
raise ValueError("analytic_contours: periodic bspline endpoints do not meet")
# The duplicated closing interpolation point describes topology,
# not an additional periodic interpolation constraint. OCC's
# periodic interpolator receives each unique point exactly once.
interpolation_points = converted[:-1]
else:
interpolation_points = converted
parameterization = segment.get("parameterization")
if parameterization not in {None, "chord", "centripetal"}:
raise ValueError(f"analytic_contours: unsupported bspline parameterization {parameterization!r}")
parameters = segment.get("parameters")
if parameters is not None:
expected_count = len(interpolation_points) + int(periodic)
if len(parameters) != expected_count:
raise ValueError("analytic_contours: bspline parameter count does not match interpolation points")
parameters = [float(value) for value in parameters]
if not all(math.isfinite(value) for value in parameters):
raise ValueError("analytic_contours: bspline parameters must be finite")
if any(right - left <= _TOLERANCE_MM for left, right in zip(parameters, parameters[1:])):
raise ValueError("analytic_contours: bspline parameters must be strictly increasing")
output: _Ctx = {
"type": "bspline",
"start_mm": converted[0],
"end_mm": converted[-1],
"points_mm": converted,
}]
"points_mm": interpolation_points,
"periodic": periodic,
**({"parameters": parameters} if parameters is not None else {}),
**({"parameters": _centripetal_parameters(interpolation_points, periodic)} if parameters is None and parameterization == "centripetal" else {}),
}
start_tangent = segment.get("start_tangent")
end_tangent = segment.get("end_tangent")
if (start_tangent is None) != (end_tangent is None):
raise ValueError("analytic_contours: bspline requires both endpoint tangents")
if start_tangent is not None:
if periodic:
raise ValueError("analytic_contours: periodic bspline does not accept endpoint tangents")
output["start_tangent_mm"] = _point(start_tangent)
output["end_tangent_mm"] = _point(end_tangent)
return [output]
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
@@ -204,6 +301,25 @@ def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]:
if edge.get("type") == "bspline":
points.extend((float(point[0]), float(point[1])) for point in edge["points_mm"][1:-1])
continue
if edge.get("type") == "ellipse":
center, axis = edge["center_mm"], edge["major_axis_mm"]
major_radius = float(edge["major_radius_mm"])
minor_radius = float(edge["minor_radius_mm"])
normal = edge.get("normal") or [0.0, 0.0, 1.0]
axis_length = math.sqrt(sum(float(value) * float(value) for value in axis))
normal_length = math.sqrt(sum(float(value) * float(value) for value in normal))
if axis_length <= _TOLERANCE_MM or normal_length <= _TOLERANCE_MM:
raise ValueError("analytic_contours: ellipse axis is degenerate")
x_axis = [float(value) / axis_length for value in axis]
z_axis = [float(value) / normal_length for value in normal]
y_axis = [z_axis[1] * x_axis[2] - z_axis[2] * x_axis[1], z_axis[2] * x_axis[0] - z_axis[0] * x_axis[2], z_axis[0] * x_axis[1] - z_axis[1] * x_axis[0]]
for step in range(1, 8):
angle = math.tau * step / 8
points.append((
float(center[0]) + major_radius * math.cos(angle) * x_axis[0] + minor_radius * math.sin(angle) * y_axis[0],
float(center[1]) + major_radius * math.cos(angle) * x_axis[1] + minor_radius * math.sin(angle) * y_axis[1],
))
continue
if edge.get("type") != "arc":
continue
center, end = edge["center_mm"], edge["end_mm"]
@@ -328,7 +444,7 @@ SHAPE_GENERATORS = CORE_SHAPE_GENERATORS
SHAPE_CAPABILITIES: dict[str, _Ctx] = {
"circle": {"detectable": True, "arity": "circle", "description": "single circular contour"},
"polygon": {"detectable": True, "arity": "polygon", "description": "closed straight-edge contour"},
"analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, and circle contours"},
"analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, circle, ellipse and B-spline contours"},
}
@@ -5,6 +5,7 @@ import unittest
from pathlib import Path
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:
@@ -28,6 +29,22 @@ def _spline_profile(sketch_id: str, z: float) -> dict:
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):
cdsl = {
"schema": "cad.cdsl.llm.v1",
@@ -58,6 +75,32 @@ class LoftGeometryTests(unittest.TestCase):
rebuilt = rebuild_cdsl(cdsl, Path(tmp) / "open-bspline-contour.step")
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):
cdsl = {
"schema": "cad.cdsl.llm.v1",
@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib
import json
import math
import sys
import tempfile
import unittest
@@ -129,6 +130,133 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
self.assertEqual(len(sketch["contour_regions_mm"]), 2)
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:
cdsl = {
"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.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:
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
from cdsl_engine.runtime import _mirrored_sketch
@@ -622,6 +825,120 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
self.assertAlmostEqual(center.Y, 3.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:
from cdsl_engine.runtime import rebuild_cdsl
@@ -983,6 +1300,131 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
self.assertLess(result["volume_mm3"], 1000.0)
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:
from cdsl_engine.runtime import analyze_cdsl
@@ -1057,6 +1499,53 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
result = rebuild_cdsl(cdsl, root / f"{name}.step")
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:
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")
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:
from cdsl_engine.runtime import rebuild_cdsl
+1
View File
@@ -1,3 +1,4 @@
output/
output-history/
__pycache__/
*.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
`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
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]:
surface = report["surface"]; metrics = report["metrics"]
maximum = max(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"])
maximum = [surface["gold_to_rebuilt"]["max_mm"], surface["rebuilt_to_gold"]["max_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 = {
"surface_max": maximum <= limits["surface_mm"], "surface_p99": p99 <= limits["surface_mm"],
"bbox": metrics["bbox_max_delta_mm"] <= limits["bbox_mm"],
"surface_max": surface_ready and max(maximum) <= limits["surface_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"],
"area": metrics["surface_area_relative_error"] <= limits["area_relative"],
"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]:
from onshape_to_cdsl.src.onshape_to_cdsl.compare import strict_compare
raw = strict_compare(gold_step, rebuilt_step, surface_tolerance_mm=surface_tessellation_mm)
from onshape_to_cdsl.compare import strict_compare
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)
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}
+30 -7
View File
@@ -63,21 +63,44 @@ class Parser:
return left
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":
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 == "=":
name = self.pop().value; self.pop()
try:
environment[name] = self.expression()
if isinstance(environment[name], Call) and environment[name].name == "newSketch":
value = environment[name]
value.args = [_resolve(arg, environment) for arg in value.args]
scope = assignment_scope(name); scope[name] = _resolve(self.expression(), environment())
if isinstance(scope[name], Call) and scope[name].name == "newSketch":
value = scope[name]
found.append(value)
except Exception: pass
elif self.peek().kind == "ident" and self.i + 1 < len(self.tokens) and self.tokens[self.i + 1].value == "(":
value = self.expression()
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)
else: self.i += 1
return found
@@ -119,7 +142,7 @@ def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR:
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)}"
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)}"
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)
+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]:
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
# executable CDSL prefix. That prefix is valuable engine evidence and
# 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)
status["comparison_decision"] = comparison["decision"]
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
+2 -1
View File
@@ -45,7 +45,8 @@ def parse_query(value: Any) -> QueryInfo:
elif "isStart" in definition: info.is_start = bool(definition["isStart"])
if call.name in {"sQuery", "sketchEntityQuery"} and len(call.args) >= 3:
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:
info.source_sketch = symbolic_string(call.args[0]); info.kind = "face"
return info
+11 -4
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
import csv, json, re
import csv, json, re, tempfile
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path
@@ -9,9 +9,16 @@ from typing import Any
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path)
temporary: Path | None = None
try:
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"))
+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:
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:
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
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
# orientation of the resulting face. The two kernels may
# report the same cap with inverse normals, especially for a
# start cap. Keep axis direction orientation-sensitive.
delta = min(delta, math.sqrt(sum((float(a) + float(b)) ** 2 for a, b in zip(left, right))))
# start cap. A rotational-face axis is likewise a geometric
# 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))
for key in ("radius_mm", "plane_offset_mm"):
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
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:
actual_box = actual.get("bbox_mm")
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
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]]]:
"""Rebuild every selector-bearing prefix and bind against its active body."""
from engine.cdsl_engine.runtime import rebuild_cdsl
bound = deepcopy(cdsl); evidence = []
with tempfile.TemporaryDirectory(prefix="cadfs-bind-") as temporary:
for index, feature in enumerate(bound.get("features") or []):
placeholders = list(feature.get("selectors") or [])
if not placeholders: continue
prefix = deepcopy(bound); prefix["features"] = bound["features"][:index]
if not prefix["features"]: raise ValueError(f"{feature['id']}: selector has no executable prefix")
report = rebuild_cdsl(prefix, Path(temporary) / f"prefix-{index}.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)
records = [
item for item in report.get("topology_records") or []
# 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}:")
]
roots = list(feature.get("selectors") or [])
for name in ("end_condition", "reverse_end_condition"):
condition = (feature.get("params") or {}).get(name)
reference = condition.get("reference") if isinstance(condition, dict) else None
if isinstance(reference, dict): roots.append(reference)
targets = [target for root in roots for target in _binding_targets(root)]
if not targets: continue
prefix_cache: dict[int, tuple[list[dict[str, Any]], str | None]] = {}
def prefix_records(binding_feature_id: str | None, owner_feature_id: str | None) -> list[dict[str, Any]]:
prefix_count = index
if binding_feature_id is not None:
binding_index = next((item_index for item_index, item in enumerate(bound["features"][:index]) if item["id"] == binding_feature_id), None)
if binding_index is None: raise ValueError(f"{feature['id']}: selector binding feature is missing or forward")
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 = []
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 {}
candidates = _circle_records(geometry, records) if geometry.get("source_circle_radius_mm") else []
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.sort(key=lambda value: (-value[0], str(value[1].get("record_id"))))
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")
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")
for record in candidates:
owners = record.get("owner_feature_ids") or [record.get("feature_id")]
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 {}})
unique = {selector["stable_id"]: selector for selector in resolved}; feature["selectors"] = list(unique.values())
selector, bound_selectors = _bound_selector(placeholder, candidates)
placeholder.clear(); placeholder.update(selector)
resolved.extend(bound_selectors)
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":
planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"]
if len(planes) != 1:
raise ValueError(f"{feature['id']}: mirror plane binding is not unique")
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
+158 -6
View File
@@ -23,7 +23,7 @@ class IntegrationTests(unittest.TestCase):
self.assertTrue(comparison["rp"]["passed"])
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"
feature = root / "featurescript_rp/0000/00002243.txt"
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["params"]["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")
self.assertEqual(gap["code"], "unsupported_engine_capability")
self.assertEqual(gap["capability"], "extrude_cut_two_sided")
cut = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")
self.assertEqual(cut["atomic_id"], "extrude_cut_two_sided")
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"]["counterbore"]["diameter_mm"], 17.25)
self.assertEqual(hole["params"]["counterbore"]["depth_mm"], 10.0)
self.assertEqual(hole["params"]["diameter_mm"], 15.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()
+530 -13
View File
@@ -1,16 +1,26 @@
from __future__ import annotations
import json, tempfile, unittest
import json, math, tempfile, unittest
from pathlib import Path
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
from cadfs_to_cdsl.lowering import lower_model
from cadfs_to_cdsl.pipeline import convert_one
from cadfs_to_cdsl.lowering import _arc, _contours, _global, lower_model
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 scan_dataset
from cadfs_to_cdsl.tests.test_parser import SOURCE, TRANSFORM_SOURCE
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):
with tempfile.TemporaryDirectory() as tmp:
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
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):
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"), {})
self.assertEqual(result.status, "deferred_no_executable_feature")
self.assertIsNone(result.cdsl)
@@ -86,21 +108,225 @@ class LoweringTests(unittest.TestCase):
self.assertEqual(feature["atomic_id"], expected_atomic)
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)
result = lower_model(parse_featurescript(source, "open-profile"), {})
self.assertIsNone(result.cdsl)
self.assertEqual(result.diagnostics[0]["code"], "sketch_deferred")
self.assertIn("open non-construction", result.diagnostics[0]["message"])
self.assertEqual(result.status, "converted_complete")
profile = result.cdsl["geometry"]["sketches"][0]["profile"]
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"
feature = root / "featurescript_rp/0095/00957738.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
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(gap["capability"], "revolve_surface")
self.assertIsNone(result.cdsl)
self.assertEqual(result.status, "converted_complete")
feature = next(item for item in result.cdsl["features"] if item["id"] == "f_F2")
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):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -128,6 +354,171 @@ class LoweringTests(unittest.TestCase):
self.assertEqual(rebuilt["status"], "rebuilt")
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):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
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})
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_F9"]["params"]["result_mode"], "new_body")
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]):
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):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
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.assertNotIn("F2", {item.get("feature_id") for item in result.diagnostics})
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})
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):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
cases = {
@@ -179,6 +634,57 @@ class LoweringTests(unittest.TestCase):
diagnostics = [item for item in result.diagnostics if item.get("operation") == "cPlane"]
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):
source = SOURCE.replace('qSketchRegion(id + "F0", true)', 'makeQuery(id+"F1.opExtrude","CAP_FACE",FACE,{"isStart":false})')
result = lower_model(parse_featurescript(source, "face-profile"), {})
@@ -197,5 +703,16 @@ class LoweringTests(unittest.TestCase):
self.assertTrue((directory / name).exists(), name)
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()
+13
View File
@@ -4,6 +4,7 @@ import unittest
from cadfs_to_cdsl.featurescript_lexer import lex
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
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.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.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):
self.assertEqual(length_mm("2 * inch"), 50.8)
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 pathlib import Path
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):
@@ -13,12 +16,62 @@ class SelectorBindingTests(unittest.TestCase):
)
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(
{"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)
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)
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]]:
"""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]] = {}
def add(point: tuple[float, float, float]) -> None:
points.setdefault(tuple(round(value / 1e-8) for value in point), point)
for face in shape.faces():
try:
vertices, triangles = face.tessellate(tolerance_mm)
rendered = [_point(vertex) for vertex in vertices]
for vertex in rendered:
add(vertex)
for triangle in triangles:
indices = list(triangle)
if len(indices) >= 3:
first, second, third = (rendered[int(index)] for index in indices[:3])
add(tuple((first[axis] + second[axis] + third[axis]) / 3.0 for axis in range(3)))
vertices, _triangles = face.tessellate(tolerance_mm)
# Tessellation vertices are evaluated on the source B-rep. A
# triangle centroid is generally inside a curved face's chord,
# not on its surface, so including it can reject geometrically
# identical STEP shapes whose face partitions differ.
for vertex in vertices:
rendered = _point(vertex)
add(rendered)
except Exception:
# A malformed individual face should not make the comparison pass.
for vertex in face.vertices():
@@ -65,22 +64,76 @@ def _percentile(values: list[float], percentile: float) -> float:
return ordered[index]
def _bbox(shape: Any) -> list[float]:
box = shape.bounding_box()
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)]
def _bbox(points: list[tuple[float, float, float]]) -> list[float]:
"""Return B-rep sampled bounds instead of OCC's cached shape bounds."""
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."""
from build123d import import_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)
rebuilt_samples = _surface_points(rebuilt, surface_tolerance_mm)
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))
volume_error = abs(float(gold.volume) - float(rebuilt.volume)) / max(abs(float(gold.volume)), 1e-12)
area_error = abs(float(gold.area) - float(rebuilt.area)) / max(abs(float(gold.area)), 1e-12)
metrics.update({"gold_bbox_mm": gold_box, "rebuilt_bbox_mm": rebuilt_box, "bbox_max_delta_mm": bbox_delta})
forward_max, reverse_max = max(forward), max(reverse)
forward_p99, reverse_p99 = _percentile(forward, 0.99), _percentile(reverse, 0.99)
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")
if area_error > RELATIVE_TOLERANCE:
reasons.append("surface_area_relative_error_exceeds_1e-5")
gold_solids, rebuilt_solids = len(gold.solids()), len(rebuilt.solids())
if gold_solids != rebuilt_solids:
reasons.append("solid_count_mismatch")
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": 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)}},
"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())},
}
+26 -1
View File
@@ -6,7 +6,7 @@ import shutil
import tempfile
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.issues import write_issue_register
from onshape_to_cdsl.merge_scans import merge_scans
@@ -100,6 +100,31 @@ class PipelineTests(unittest.TestCase):
self.assertFalse(rejection["passed"])
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:
fixture = Path.cwd() / "json_to_cdsl/input/onshape_complete/00000352"
if not (fixture / "model.step").exists():