Compare commits

...

3 Commits

Author SHA1 Message Date
likang f79c28759d Merge pull request 'Lk dev' (#20) from lk_dev into main
Reviewed-on: #20
2026-09-09 10:41:30 +08:00
likang a5c3ec1f36 merge: resolve main integration conflicts 2026-09-09 10:39:34 +08:00
likang 2f89d7930c feat(cadfs): expand reconstruction capability coverage 2026-09-09 10:11:46 +08:00
26 changed files with 8474 additions and 419 deletions
@@ -1 +1 @@
只使用当前工具 schema、`operation_contract` 和服务端提供的 topology/reference token;它们高于本指引、示例和任何经验。每次只完成被调度的一个原子操作,不编造 atom、字段、selector 或能力。参数须为有限 mm/deg 数值。已有可执行 checkpoint 是应保留的最佳结果;能力缺口、未验证项和视觉疑点要如实交给服务端证据流程,不能用虚构几何掩盖
以当前 schema、`operation_contract` topology token 为准;每次只执行一个原子操作,不编造字段或选择器。数值须为有限 mm/deg。保留最后可执行 checkpoint,并如实报告未满足项
@@ -1 +1 @@
世界坐标默认是右手 mm。根 `XY``+Z` 挤出只用于当前 contract 允许的根挤出。原点按功能基准选取:对称件取中心,板件取占地中心,轴对称件取轴线,存在安装或配合接口时取安装面、配合轴或明确接口基准。`workplane.origin_mm` 是局部 `(0,0)` 的世界点,`x_dir` 是局部 `+X``normal` 指向正挤出;草图局部坐标不是世界坐标。孔 `positions[].mm` 是宿主面上的绝对世界点,不是面内偏移。非根特征依据当前 contract、测量宿主和 token 建立 frame,不能猜测最后生成面或全局平面。`reference_plane`/`reference_axis` 是唯一支持的命名定位上下文;先创建基准,再创建依赖它的旋转、镜像、阵列和草图。位置必须由基准、中心线、偏移、节距或半径导出;定位失败时改 frame、偏移或方向,不修改已发布工件
世界坐标右手 mm;根特征只在 contract 允许时用 `XY`/`+Z``workplane.origin_mm``x_dir``normal` 定义局部 frame;孔位是世界坐标。后续特征仅用已验证 datum/token,不能猜测最后生成面
@@ -1,6 +1,6 @@
{
"schema_version": "cdsl.author-guidance.manifest.v1",
"version": "2026-09-03.1",
"version": "2026-09-09.1",
"sections": [
{"id": "00-author-contract", "file": "00-author-contract.md", "title": "00 Author Contract", "priority": 100, "mandatory": true},
{"id": "01-brief-and-assumptions", "file": "01-brief-and-assumptions.md", "title": "01 Brief And Assumptions", "priority": 70, "mandatory": false},
@@ -42,13 +42,19 @@
"final_sections": ["00-author-contract", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"],
"operation_sections": {
"extrude_add_blind": ["op-extrude-add"],
"extrude_add_blind_with_hole": ["op-extrude-add"],
"extrude_add_two_sided": ["op-extrude-add"],
"extrude_from_face": ["op-extrude-add"],
"extrude_surface": ["op-extrude-add"],
"extrude_cut_blind": ["op-extrude-cut"],
"extrude_cut_two_sided": ["op-extrude-cut"],
"extrude_cut_through": ["op-extrude-cut"],
"loft_add": ["op-loft"],
"loft_add_with_cap_face": ["op-loft"],
"revolve_add": ["op-revolve"],
"revolve_cut": ["op-revolve"],
"revolve_surface": ["op-revolve"],
"sweep_add": ["op-loft"],
"hole_blind": ["op-hole"],
"hole_counterbore": ["op-hole"],
"hole_countersink": ["op-hole"],
@@ -60,6 +66,10 @@
"pattern_circular": ["op-pattern"],
"fillet": ["op-finish"],
"chamfer": ["op-finish"],
"shell": ["op-finish"],
"boolean_bodies": ["op-finish"],
"transform_bodies": ["op-finish"],
"delete_bodies": ["op-finish"],
"sphere_add": ["op-sphere"],
"box_add": ["op-primitives"],
"cylinder_add": ["op-primitives"],
@@ -1 +1 @@
`fillet``chamfer` 仅接受当前 revision 中唯一且合格的 edge selector token。先完成影响这些边的布尔、孔和阵列,再按 contract 使用正半径或距离。局部材料不足、相邻过渡相交或 token 已失效时,不用全局边选择兜底;保留主体并报告该收尾特征的风险。
`fillet``chamfer` 仅接受当前 revision 中唯一且合格的 edge selector token。`shell` 必须保留显式目标 body 与待移除 face token。`boolean_bodies``transform_bodies``delete_bodies` 只操作 contract 指定且仍独立存在的 body;不以当前 body 或隐式 union 兜底。失败时保留主体并报告风险。
@@ -1 +1 @@
`loft_add``params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。不要以挤出替代放样,也不要把 selector 选中的实体面当作放样截面,除非 operation contract 明确支持。
`loft_add``params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。`loft_add_with_cap_face` 只能使用 contract 许可的 cap-face token。`sweep_add` 必须保留闭合截面与显式、非退化路径,不以放样或挤出替代。不要把 selector 选中的实体面当作放样截面,除非 contract 明确支持。
+800 -24
View File
@@ -5,26 +5,34 @@ from __future__ import annotations
import math
from typing import Any, Iterable
from build123d import Axis, Compound, Edge, Face, Location, Plane, ShapeList, Shell, Solid, Vector, Wire, export_step
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
from build123d import AngularDirection, Axis, Compound, Edge, Face, Location, Plane, ShapeList, Shell, Solid, Vector, Wire, export_step
from OCP.BOPAlgo import BOPAlgo_Splitter
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
from OCP.BRep import BRep_Tool
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
from OCP.BRepOffset import BRepOffset_Skin
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol
from OCP.Geom import Geom_SurfaceOfRevolution
from OCP.GeomAbs import GeomAbs_Arc
from OCP.LocOpe import LocOpe_DPrism
from OCP.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle
from OCP.TopAbs import TopAbs_SHELL
from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL
from OCP.TopExp import TopExp_Explorer
from OCP.TopTools import TopTools_ListOfShape
from OCP.TopoDS import TopoDS
from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Vec
from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
from .parametric_bend import build_bend_solid
from .parametric_gears import build_gear_solid, build_rack_solid
from .parametric_thread import build_thread_solid
from .runtime_types import AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec, TopologyRecord, Vector3, canonical_plane_signature
from .runtime_types import (
AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec,
TopologyDelta, TopologyDeltaRelation, TopologyRecord, Vector3,
canonical_plane_signature,
)
def _vector(value: list[float] | tuple[float, float, float]) -> Vector:
@@ -69,6 +77,9 @@ def _arc_midpoint(edge: dict[str, Any], start: Vector, end: Vector, center: Vect
class Build123dGeometryAdapter:
"""All B-rep construction and mutation lives in this adapter."""
CONTACT_FUSE_TOLERANCE_MM = 1e-7
COINCIDENT_FUSE_TOLERANCE_MM = 1e-3
@staticmethod
def plane(spec: PlaneSpec) -> Plane:
# 将运行时平面定义 PlaneSpec 转换为 build123d 的 Plane。
@@ -84,20 +95,48 @@ class Build123dGeometryAdapter:
# 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。
built: list[Edge] = []
for edge in edges:
if edge.get("type") == "circle":
center = _vector(edge["center_mm"])
x_dir = _vector(edge.get("x_dir_mm") or [1, 0, 0])
normal = _vector(edge.get("normal") or [0, 0, 1])
radius = float(edge.get("radius_mm") or 0.0)
if radius <= 0.0 or x_dir.length <= 1e-9 or normal.length <= 1e-9:
raise ValueError("circle contour edge has a degenerate frame")
direction = AngularDirection.CLOCKWISE if bool(edge.get("clockwise")) else AngularDirection.COUNTER_CLOCKWISE
built.append(Edge.make_circle(radius, Plane(origin=center, x_dir=x_dir, z_dir=normal), angular_direction=direction))
continue
if edge.get("type") == "bspline":
points = [_vector(point) for point in edge.get("points_mm") or []]
if len(points) < 3:
raise ValueError("bspline contour edge needs at least 3 points")
parameters = edge.get("parameters")
start_tangent = edge.get("start_tangent_mm")
end_tangent = edge.get("end_tangent_mm")
if len(points) < 2:
raise ValueError("bspline contour edge needs at least 2 points")
if (start_tangent is None) != (end_tangent is None):
raise ValueError("bspline contour edge requires both endpoint tangents")
if len(points) == 2:
if math.dist(
(points[0].X, points[0].Y, points[0].Z),
(points[1].X, points[1].Y, points[1].Z),
) <= 1e-5:
raise ValueError("two-point bspline contour edge endpoints must be distinct")
if bool(edge.get("periodic")) or start_tangent is None or end_tangent is None:
raise ValueError("two-point bspline contour edge requires non-periodic endpoint tangents")
if not isinstance(parameters, list) or len(parameters) != 2:
raise ValueError("two-point bspline contour edge requires explicit parameters")
try:
parameter_values = [float(value) for value in parameters]
except (OverflowError, TypeError, ValueError) as exc:
raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing") from exc
if not all(math.isfinite(value) for value in parameter_values) or parameter_values[1] - parameter_values[0] <= 1e-5:
raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing")
else:
parameter_values = [float(value) for value in parameters] if parameters is not None else None
built.append(Edge.make_spline(
points,
tangents=[_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None,
periodic=bool(edge.get("periodic")),
parameters=[float(value) for value in parameters] if parameters is not None else None,
parameters=parameter_values,
scale=False,
))
continue
@@ -167,6 +206,222 @@ class Build123dGeometryAdapter:
faces.append(face.make_holes(holes) if holes else face)
return faces
@staticmethod
def _split_images(splitter: BOPAlgo_Splitter, edge: Edge) -> list[Edge]:
"""Return OCC split history, keeping an unchanged input as one image."""
images = [Edge.cast(shape) for shape in splitter.Modified(edge.wrapped)]
return images or [edge]
@staticmethod
def _imprint_support_face(edges: list[Edge], plane_spec: PlaneSpec) -> Face:
"""Build a finite support face around all source curves.
A fixed world-aligned box would silently make the result depend on a
sketch's orientation. Projecting every edge bounding-box corner into
the explicit workplane creates a deterministic support boundary for
the OCC splitter. Any selected face that touches that boundary is
rejected later as an unbounded IMPRINT region.
"""
if not edges:
raise ValueError("planar_imprint has no source edges")
origin = _vector(plane_spec.origin_mm)
x_dir = _vector(plane_spec.x_dir)
y_dir = _vector(plane_spec.y_dir)
coordinates: list[tuple[float, float]] = []
for edge in edges:
box = edge.bounding_box()
for x in (box.min.X, box.max.X):
for y in (box.min.Y, box.max.Y):
for z in (box.min.Z, box.max.Z):
offset = Vector(x, y, z) - origin
coordinates.append((offset.dot(x_dir), offset.dot(y_dir)))
if not coordinates:
raise ValueError("planar_imprint cannot bound source geometry")
u_values, v_values = zip(*coordinates)
u_min, u_max = min(u_values), max(u_values)
v_min, v_max = min(v_values), max(v_values)
span = max(u_max - u_min, v_max - v_min, 1.0)
margin = max(span * 0.1, 1.0)
corners = [
origin + x_dir * (u_min - margin) + y_dir * (v_min - margin),
origin + x_dir * (u_max + margin) + y_dir * (v_min - margin),
origin + x_dir * (u_max + margin) + y_dir * (v_max + margin),
origin + x_dir * (u_min - margin) + y_dir * (v_max + margin),
]
return Face(Wire([
Edge.make_line(corners[index], corners[(index + 1) % len(corners)])
for index in range(len(corners))
]))
@staticmethod
def _intersection_parameters(source: Edge, anchors: list[Edge]) -> list[float]:
"""Return unique exact intersection points ordered by source direction."""
parameters: list[float] = []
for anchor in anchors:
distance = BRepExtrema_DistShapeShape(source.wrapped, anchor.wrapped)
distance.Perform()
if not distance.IsDone() or distance.Value() > 1e-6:
continue
for index in range(1, distance.NbSolution() + 1):
point = Vector(distance.PointOnShape1(index))
parameter = float(source.param_at_point(point))
if not math.isfinite(parameter):
continue
if not any(abs(parameter - existing) <= 1e-7 for existing in parameters):
parameters.append(parameter)
return sorted(parameters)
@staticmethod
def _selected_imprint_edges(
splitter: BOPAlgo_Splitter,
source: Edge,
anchors: list[Edge],
fragment: dict[str, Any] | None,
) -> list[Edge]:
"""Resolve a logical source or one ordered split fragment exactly."""
images = Build123dGeometryAdapter._split_images(splitter, source)
if not fragment:
# A bare IMPRINT source query denotes every builder image of the
# same logical FeatureScript edge. The caller proves that every
# requested-side candidate is bounded; it must not pick an
# arbitrary image just because OCC introduced vertices at contact
# points.
return images
intersections = Build123dGeometryAdapter._intersection_parameters(source, anchors)
if not intersections:
raise ValueError("planar_imprint fragment source and anchor do not intersect")
order = fragment.get("intersection_index")
if order is None:
if len(intersections) != 1:
raise ValueError("planar_imprint fragment intersection is not unique")
anchor_parameter = intersections[0]
elif isinstance(order, int) and 0 <= order < len(intersections):
anchor_parameter = intersections[order]
else:
raise ValueError("planar_imprint fragment intersection index is invalid")
# FeatureScript's topology disambiguation uses -1 for the directed
# successor of a vertex and +1 for its predecessor. Comparing raw
# parameters would break at a periodic curve's 0/1 seam, so resolve
# the image by the exact split endpoint and its source-aligned tangent.
forward = float(fragment.get("side")) < 0.0
anchor_point = source.position_at(anchor_parameter)
source_tangent = source.tangent_at(anchor_parameter)
if source_tangent.length <= 1e-9:
raise ValueError("planar_imprint source edge has no directed tangent")
if len(images) == 1 and bool(source.wrapped.Closed()):
# One exact vertex does not divide a periodic OCC edge. Both
# directed choices therefore refer to its sole logical fragment.
return images
candidates: list[Edge] = []
for edge in images:
endpoint = edge.position_at(0 if forward else 1)
tangent = edge.tangent_at(0 if forward else 1)
if (endpoint - anchor_point).length > 1e-6:
continue
if tangent.length <= 1e-9 or source_tangent.normalized().dot(tangent.normalized()) < 1.0 - 1e-7:
continue
candidates.append(edge)
if len(candidates) != 1:
raise ValueError("planar_imprint fragment side does not resolve one split edge")
return candidates
@staticmethod
def _face_uses_boundary(face: Face, boundary_edges: list[Edge]) -> bool:
return any(
edge.wrapped.IsSame(boundary.wrapped)
for edge in face.edges()
for boundary in boundary_edges
)
def _faces_from_planar_imprint(self, sketch: dict[str, Any]) -> list[Face]:
"""Materialize exact bounded IMPRINT regions with OCC planar splitting."""
source_entries = sketch.get("imprint_entities_mm") or []
selections = sketch.get("imprint_selections") or []
if not source_entries or not selections:
raise ValueError("planar_imprint is missing resolved source entities or selections")
source_edges: dict[str, list[Edge]] = {}
all_edges: list[Edge] = []
for entry in source_entries:
source_id = str(entry.get("id") or "")
raw_edges = entry.get("edges") or []
if not source_id or not raw_edges or source_id in source_edges:
raise ValueError("planar_imprint source entities are invalid")
edges = list(self._wire(raw_edges).edges())
if not edges:
raise ValueError(f"planar_imprint source entity {source_id!r} has no OCC edge")
source_edges[source_id] = edges
all_edges.extend(edges)
plane_spec = PlaneSpec.from_mapping(sketch.get("workplane") or {})
support = self._imprint_support_face(all_edges, plane_spec)
splitter = BOPAlgo_Splitter()
splitter.AddArgument(support.wrapped)
for edge in all_edges:
splitter.AddTool(edge.wrapped)
splitter.Perform()
if splitter.HasErrors():
raise ValueError("planar_imprint OCC splitter failed")
explorer = TopExp_Explorer(splitter.Shape(), TopAbs_FACE)
regions: list[Face] = []
while explorer.More():
face = Face.cast(explorer.Current())
if face.area > 1e-10:
regions.append(face)
explorer.Next()
if not regions:
raise ValueError("planar_imprint OCC splitter produced no regions")
boundary_edges = [
image
for edge in support.edges()
for image in self._split_images(splitter, edge)
]
normal = _vector(plane_spec.normal)
selected: list[Face] = []
for selection in selections:
source_id = str(selection.get("source_entity_id") or "")
source = source_edges.get(source_id) or []
if len(source) != 1:
raise ValueError("planar_imprint selection source must resolve to one analytic edge")
fragment = selection.get("fragment")
anchor_id = str((fragment or {}).get("anchor_entity_id") or "")
anchors = source_edges.get(anchor_id) or [] if fragment else []
if fragment and not anchors:
raise ValueError("planar_imprint fragment anchor is unavailable")
edges = self._selected_imprint_edges(splitter, source[0], anchors, fragment)
face_side = float(selection.get("face_side") or 0.0)
if face_side not in {-1.0, 1.0}:
raise ValueError("planar_imprint face side is invalid")
candidate_faces: list[Face] = []
for edge in edges:
tangent = edge.tangent_at(0.5)
lateral = normal.cross(tangent)
if lateral.length <= 1e-9:
raise ValueError("planar_imprint selected edge has no in-plane side")
extent = max(edge.length, 1e-4)
probe_distance = max(1e-5, min(extent / 1000.0, 0.01))
probe = edge.position_at(0.5) + lateral.normalized() * (probe_distance * face_side)
candidates = [face for face in regions if face.is_inside(probe, probe_distance / 10.0)]
if len(candidates) != 1:
raise ValueError("planar_imprint face side does not resolve one region")
face = candidates[0]
if not any(face.wrapped.IsSame(existing.wrapped) for existing in candidate_faces):
candidate_faces.append(face)
unbounded = [
face for face in candidate_faces
if self._face_uses_boundary(face, boundary_edges)
]
if unbounded:
if len(candidate_faces) == 1 and self._face_uses_boundary(candidate_faces[0], boundary_edges):
raise ValueError("planar_imprint selected region is unbounded")
raise ValueError("planar_imprint source side includes an unbounded region")
# A bare source query can legitimately select every bounded face
# adjacent to its OCC split descendants. This is a set-valued
# FeatureScript IMPRINT result, not an invitation to choose one
# segment by length, order, or proximity.
for face in candidate_faces:
if not any(face.wrapped.IsSame(existing.wrapped) for existing in selected):
selected.append(face)
return selected
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Face]:
# 从草图数据解析出可拉伸/旋转的轮廓面,按三种数据来源依次回退。
# 1. 单圆 contour 不应先被 sketch_solver 展开成四段圆弧。圆弧分段会
@@ -175,6 +430,8 @@ class Build123dGeometryAdapter:
# 只含闭合整圆的轮廓,保留每个圆一条原生 circle edge;同心圆仍由
# _faces_from_circles 的包含关系生成带孔面。
profile = sketch.get("profile") or {}
if profile.get("type") == "planar_imprint":
return self._faces_from_planar_imprint(sketch)
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
if isinstance(contours, list) and contours and all(
isinstance(contour, dict)
@@ -228,8 +485,8 @@ class Build123dGeometryAdapter:
raise ValueError("profile hole feature requires at least one cap face")
return Face(outer.outer_wire()).make_holes(wires)
def loft(self, sketches: list[dict[str, Any]]) -> Solid:
"""由多条简单闭合草图轮廓生成实体放样。"""
def _loft_wires(self, sketches: list[dict[str, Any]]) -> list[Wire]:
"""Resolve the bounded closed-wire CDSL loft contract once."""
wires: list[Wire] = []
for index, sketch in enumerate(sketches):
# Solid.make_loft 接收 Wire;复用 faces_for_sketch 保持放样、
@@ -243,7 +500,35 @@ class Build123dGeometryAdapter:
wires.append(faces[0].outer_wire())
if len(wires) < 2:
raise ValueError("loft requires at least two profile sketches")
return Solid.make_loft(wires)
return wires
def loft(self, sketches: list[dict[str, Any]]) -> Solid:
result, _delta = self.loft_with_topology_delta(sketches)
return result
def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Solid, TopologyDelta]:
"""Build a simple closed-wire solid loft through one OCC builder."""
wires = self._loft_wires(sketches)
builder = BRepOffsetAPI_ThruSections(True, False)
builder.CheckCompatibility(True)
for wire in wires:
builder.AddWire(wire.wrapped)
builder.Build()
if not builder.IsDone():
raise ValueError("OCC loft operation did not complete")
result = Solid(builder.Shape())
if not result.is_valid or not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9:
raise ValueError("OCC loft operation did not produce a valid solid")
relations: list[TopologyDeltaRelation] = []
for wire, output, role in (
(wires[0], builder.FirstShape(), "loft.start"),
(wires[-1], builder.LastShape(), "loft.end"),
):
if not output.IsNull() and output.ShapeType() == TopAbs_FACE:
relations.append(TopologyDeltaRelation(
"generated", "face", wire.wrapped, (output,), output_role=role,
))
return result, TopologyDelta(operation="loft", relations=tuple(relations))
def loft_with_cap_face(self, cap_face: Face, sketches: list[dict[str, Any]]) -> Solid:
"""Loft from one cap face's outer wire to one closed sketch profile."""
@@ -286,13 +571,62 @@ class Build123dGeometryAdapter:
return Solid.extrude(face, _vector(direction))
@staticmethod
def extrude_taper(face: Face, direction: Vector3, taper_deg: float) -> Solid:
# 沿给定方向以锥角拉伸一个面。build123d 正角收缩外轮廓,负角扩张;
# CADFS 的 draftPullDirection 已由 lowering 映射到该符号。
# build123d 对负锥角回退为 offset wire loft;椭圆等解析曲线在该
# 路径会产生仅能留在内存、STEP round-trip 后退化为 Shell 的 B-rep。
# LocOpe_DPrism 同时支持正负拔模角,且保留一张解析侧面,因此在
# 无内环、拉伸方向与 face normal 同向时始终优先使用它。
def face_normal(face: Face) -> Vector3:
normal = face.normal_at()
return (float(normal.X), float(normal.Y), float(normal.Z))
@staticmethod
def extrude_with_topology_delta(face: Face, direction: Vector3) -> tuple[Solid, TopologyDelta]:
"""Extrude one B-rep face and retain its two builder-proven cap faces."""
vector = _vector(direction)
if vector.length <= 1e-9:
raise ValueError("extrude direction must be non-zero")
builder = BRepPrimAPI_MakePrism(
face.wrapped, gp_Vec(vector.X, vector.Y, vector.Z), True, True,
)
if not builder.IsDone():
raise ValueError("OCC extrude operation did not complete")
result = Solid(builder.Shape())
if not result.is_valid or not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9:
raise ValueError("OCC extrude operation did not produce a valid solid")
relations: list[TopologyDeltaRelation] = []
for output, role in ((builder.FirstShape(), "extrude.start"), (builder.LastShape(), "extrude.end")):
if not output.IsNull() and output.ShapeType() == TopAbs_FACE:
relations.append(TopologyDeltaRelation(
"generated", "face", face.wrapped, (output,), output_role=role,
))
return result, TopologyDelta(operation="extrude", relations=tuple(relations))
@staticmethod
def _single_face_from_shape(shape: Any) -> Face | None:
"""Return one face only when an OCC builder output contains exactly one.
``LocOpe_DPrism.FirstShape`` and ``LastShape`` are shells in the OCP
binding, even for the single cap faces they represent. Requiring one
contained face keeps those roles tied to the builder output rather
than guessing a cap from a coincident planar result face.
"""
explorer = TopExp_Explorer(shape, TopAbs_FACE)
faces: list[Face] = []
while explorer.More():
faces.append(Face.cast(explorer.Current()))
explorer.Next()
return faces[0] if len(faces) == 1 else None
@staticmethod
def extrude_taper_with_topology_delta(
face: Face,
direction: Vector3,
taper_deg: float,
) -> tuple[Solid, TopologyDelta | None]:
"""Extrude a drafted face and retain exact caps from ``LocOpe_DPrism``.
The native tapered-extrude fallback has no history interface. Only
the narrow ``LocOpe_DPrism`` path can expose a cap role, and only when
each of its ``FirstShape``/``LastShape`` outputs contains exactly one
face. All other valid draft results deliberately retain no topology
delta instead of inferring one from geometry.
"""
vector = _vector(direction)
normal = face.normal_at()
if (
@@ -310,8 +644,31 @@ class Build123dGeometryAdapter:
if prism.IsDone():
result = Solid(TopoDS.Solid_s(prism.Shape()))
if result.is_valid:
return result
return Solid.extrude_taper(face, _vector(direction), taper_deg)
relations: list[TopologyDeltaRelation] = []
for cap_shape, role in (
(prism.FirstShape(), "extrude.start"),
(prism.LastShape(), "extrude.end"),
):
cap = Build123dGeometryAdapter._single_face_from_shape(cap_shape)
if cap is not None:
relations.append(TopologyDeltaRelation(
"generated", "face", face.wrapped, (cap.wrapped,), output_role=role,
))
return result, TopologyDelta(operation="extrude_taper", relations=tuple(relations))
return Solid.extrude_taper(face, vector, taper_deg), None
@staticmethod
def extrude_taper(face: Face, direction: Vector3, taper_deg: float) -> Solid:
# 沿给定方向以锥角拉伸一个面。build123d 正角收缩外轮廓,负角扩张;
# CADFS 的 draftPullDirection 已由 lowering 映射到该符号。
# build123d 对负锥角回退为 offset wire loft;椭圆等解析曲线在该
# 路径会产生仅能留在内存、STEP round-trip 后退化为 Shell 的 B-rep。
# LocOpe_DPrism 同时支持正负拔模角,且保留一张解析侧面,因此在
# 无内环、拉伸方向与 face normal 同向时始终优先使用它。
result, _topology_delta = Build123dGeometryAdapter.extrude_taper_with_topology_delta(
face, direction, taper_deg,
)
return result
@staticmethod
def extrude_trimmed(face: Face, target: Any, direction: Vector3) -> Any:
@@ -588,10 +945,38 @@ class Build123dGeometryAdapter:
@staticmethod
def fuse(body: Any | None, solid: Any) -> Any:
"""Fuse bodies without retaining a builder history."""
result, _delta = Build123dGeometryAdapter.fuse_with_topology_delta(
body, solid, record_history=False,
)
return result
@staticmethod
def fuse_with_topology_delta(
body: Any | None, solid: Any, *, record_history: bool = True,
) -> tuple[Any, TopologyDelta | None]:
"""Fuse one explicit body pair and retain history when it stays exact.
The established fallback sequence changes the kernel result: a normal
build123d fuse or fuzzy OCC fuse has a different history object. Such
results remain executable, but must not inherit relationships from the
discarded first builder. Multi-member inputs are likewise outside the
one-builder proof boundary.
"""
# 布尔并:没有既有主体时,直接以该实体作为新主体。
# 实参类型放宽为 Any:build123d 的布尔结果可能是 Solid 或 Compound。
if body is None:
return solid
return solid, None
if (
not record_history
or len(Build123dGeometryAdapter.body_solids(body)) != 1
or len(Build123dGeometryAdapter.body_solids(solid)) != 1
):
return Build123dGeometryAdapter._fuse_without_history(body, solid), None
return Build123dGeometryAdapter._fuse_with_history(body, solid)
@staticmethod
def _fuse_without_history(body: Any, solid: Any) -> Any:
# build123d.Shape.fuse 未启用 OBB 加速器;镜像后的重叠实体在该路径
# 会偶发返回反向、无效的 B-rep。直接采用 OCC 的稳定布尔配置,保留
# 一般 add/replay 的同一 union 语义。
@@ -604,6 +989,30 @@ class Build123dGeometryAdapter:
raise ValueError("OCC union operation did not complete")
result = Solid(operation.Shape())
if result.is_valid:
# OBB 对多个相交的曲面 sweep 偶尔会把交叠区单独保留为一个
# Solid。普通 fuse 若能以更少的有效实体表示相同并集,应优先
# 使用它;不相交结果仍保留 OBB 的多实体 body 语义。
if len(Build123dGeometryAdapter.body_solids(result)) > 1:
fallback = Build123dGeometryAdapter._coerce_single_or_compound(body.fuse(solid))
if fallback is not None and fallback.is_valid and (
len(Build123dGeometryAdapter.body_solids(fallback))
< len(Build123dGeometryAdapter.body_solids(result))
):
return fallback
# 两个输入在数学上已经接触时,曲面 sweep 的近似交界可能只因
# 内核容差留下重叠成员。仅在这种零距离情形重试 fuzzy boolean
# 有实际间隙的独立 body 不参与该修复,不能被错误地桥接合并。
if body.distance_to(solid) <= Build123dGeometryAdapter.CONTACT_FUSE_TOLERANCE_MM:
operation = BRepAlgoAPI_Fuse()
operation.SetRunParallel(True); operation.SetUseOBB(True)
operation.SetFuzzyValue(Build123dGeometryAdapter.COINCIDENT_FUSE_TOLERANCE_MM)
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
fuzzy = Solid(operation.Shape()) if operation.IsDone() else None
if fuzzy is not None and fuzzy.is_valid and (
len(Build123dGeometryAdapter.body_solids(fuzzy))
< len(Build123dGeometryAdapter.body_solids(result))
) and fuzzy.volume + 1e-6 >= max(float(body.volume), float(solid.volume)):
return fuzzy
return result
# 保留 build123d 的既有调用作为内核版本差异下的兼容回退;无效结果
# 不能悄然进入后续 feature history。
@@ -612,6 +1021,39 @@ class Build123dGeometryAdapter:
return fallback
raise ValueError("OCC union operation produced an invalid shape")
@staticmethod
def _fuse_with_history(body: Any, solid: Any) -> tuple[Any, TopologyDelta | None]:
"""Run the primary fuse algorithm with its own exact history object."""
arguments = TopTools_ListOfShape(); arguments.Append(body.wrapped)
tools = TopTools_ListOfShape(); tools.Append(solid.wrapped)
operation = BRepAlgoAPI_Fuse()
operation.SetRunParallel(True); operation.SetUseOBB(True); operation.SetToFillHistory(True)
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
if not operation.IsDone():
raise ValueError("OCC union operation did not complete")
result = Solid(operation.Shape())
if not result.is_valid:
return Build123dGeometryAdapter._fuse_without_history(body, solid), None
if len(Build123dGeometryAdapter.body_solids(result)) > 1:
fallback = Build123dGeometryAdapter._coerce_single_or_compound(body.fuse(solid))
if fallback is not None and fallback.is_valid and (
len(Build123dGeometryAdapter.body_solids(fallback))
< len(Build123dGeometryAdapter.body_solids(result))
):
return fallback, None
if body.distance_to(solid) <= Build123dGeometryAdapter.CONTACT_FUSE_TOLERANCE_MM:
fuzzy = BRepAlgoAPI_Fuse()
fuzzy.SetRunParallel(True); fuzzy.SetUseOBB(True)
fuzzy.SetFuzzyValue(Build123dGeometryAdapter.COINCIDENT_FUSE_TOLERANCE_MM)
fuzzy.SetArguments(arguments); fuzzy.SetTools(tools); fuzzy.Build()
fuzzy_result = Solid(fuzzy.Shape()) if fuzzy.IsDone() else None
if fuzzy_result is not None and fuzzy_result.is_valid and (
len(Build123dGeometryAdapter.body_solids(fuzzy_result))
< len(Build123dGeometryAdapter.body_solids(result))
) and fuzzy_result.volume + 1e-6 >= max(float(body.volume), float(solid.volume)):
return fuzzy_result, None
return result, Build123dGeometryAdapter._builder_topology_delta(operation, (body, solid), "union")
@staticmethod
def combine(body: Any | None, solid: Any) -> Any:
# 保留独立 result body:不得调用 fuse,否则相交实体会被内核合并。
@@ -622,10 +1064,50 @@ class Build123dGeometryAdapter:
@staticmethod
def cut(body: Any, tool: Any) -> Any:
# 从主体上减去工具实体。
# Compound 内的独立实体分别切除再组合,与整体差集的集合语义一致。
# 对包含抽壳薄壁的多个成员,直接对整个 Compound 做 OCC boolean 会在
# 内核中长时间求解,且不会改善任何成员间不存在的拓扑关系。
members = Build123dGeometryAdapter.body_solids(body)
if len(members) > 1:
result = None
for member in members:
cut_member = Build123dGeometryAdapter._coerce_single_or_compound(member.cut(tool))
# 多实体差集允许 cutter 完全移除其中一个成员;其他成员仍是
# 当前 feature 的有效结果。只有所有成员均被移除才是空切除。
if cut_member is not None:
result = Build123dGeometryAdapter.combine(result, cut_member)
if result is None:
raise ValueError("OCC cut operation produced no shape")
return result
return Build123dGeometryAdapter._coerce_single_or_compound(
body.cut(tool), empty_error="OCC cut operation produced no shape",
)
@staticmethod
def cut_with_topology_delta(body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]:
"""Subtract single explicit bodies while preserving exact OCC history.
The established multi-member path cuts each member independently to
bound OCC work. It has no single builder history for the aggregate,
so it deliberately keeps the executable result but returns no delta
rather than composing an unproven history graph.
"""
if len(Build123dGeometryAdapter.body_solids(body)) != 1 or len(Build123dGeometryAdapter.body_solids(tool)) != 1:
return Build123dGeometryAdapter.cut(body, tool), None
arguments = TopTools_ListOfShape(); arguments.Append(body.wrapped)
tools = TopTools_ListOfShape(); tools.Append(tool.wrapped)
operation = BRepAlgoAPI_Cut()
operation.SetRunParallel(True); operation.SetUseOBB(True); operation.SetToFillHistory(True)
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
if not operation.IsDone():
raise ValueError("OCC cut operation did not complete")
result = Build123dGeometryAdapter._coerce_single_or_compound(
Solid(operation.Shape()), empty_error="OCC cut operation produced no shape",
)
return result, Build123dGeometryAdapter._builder_topology_delta(
operation, (body, tool), "subtract",
)
@staticmethod
def sphere(radius_mm: float, center_mm: Vector3) -> Solid:
# 以给定球心与半径生成球体实体。
@@ -657,6 +1139,147 @@ class Build123dGeometryAdapter:
left.intersect(right), empty_error="boolean intersection produced no solid",
)
@staticmethod
def intersect_with_topology_delta(left: Any, right: Any) -> tuple[Any, TopologyDelta | None]:
"""Intersect single bodies through one OCC builder and retain history."""
if len(Build123dGeometryAdapter.body_solids(left)) != 1 or len(Build123dGeometryAdapter.body_solids(right)) != 1:
return Build123dGeometryAdapter.intersect(left, right), None
arguments = TopTools_ListOfShape(); arguments.Append(left.wrapped)
tools = TopTools_ListOfShape(); tools.Append(right.wrapped)
operation = BRepAlgoAPI_Common()
operation.SetToFillHistory(True)
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
if not operation.IsDone():
raise ValueError("OCC intersection operation did not complete")
result = Build123dGeometryAdapter._coerce_single_or_compound(
Solid(operation.Shape()), empty_error="boolean intersection produced no solid",
)
return result, Build123dGeometryAdapter._builder_topology_delta(
operation, (left, right), "intersect",
)
@staticmethod
def transform(body: Any, transform: dict[str, Any]) -> Any:
"""Apply one explicit body transform without exposing kernel history."""
result, _delta = Build123dGeometryAdapter.transform_with_topology_delta(body, transform)
return result
@staticmethod
def transform_with_topology_delta(body: Any, transform: dict[str, Any]) -> tuple[Any, TopologyDelta]:
"""Apply one body transform and retain exact OCC subshape history."""
kind = str(transform.get("type") or "")
conversion = gp_Trsf()
if kind == "translation":
offset = transform.get("translation_mm")
if not isinstance(offset, list) or len(offset) != 3:
raise ValueError("translation transform requires translation_mm")
conversion.SetTranslation(gp_Vec(*(float(value) for value in offset)))
elif kind == "rotation":
axis = AxisSpec.from_mapping(transform.get("axis") or {})
angle_deg = transform.get("angle_deg")
if not isinstance(angle_deg, (int, float)):
raise ValueError("rotation transform requires angle_deg")
conversion.SetRotation(
gp_Ax1(gp_Pnt(*axis.origin_mm), gp_Dir(*axis.direction)),
math.radians(float(angle_deg)),
)
elif kind == "uniform_scale":
center = transform.get("center_mm")
scale_factor = transform.get("scale_factor")
if not isinstance(center, list) or len(center) != 3:
raise ValueError("uniform_scale transform requires center_mm")
if not isinstance(scale_factor, (int, float)) or not math.isfinite(float(scale_factor)) or float(scale_factor) <= 0:
raise ValueError("uniform_scale transform requires a finite positive scale_factor")
conversion.SetScale(gp_Pnt(*(float(value) for value in center)), float(scale_factor))
else:
raise ValueError(f"unsupported body transform type {kind!r}")
operation = BRepBuilderAPI_Transform(body.wrapped, conversion, True)
operation.Build()
if not operation.IsDone():
raise ValueError("OCC body transform did not complete")
result = Solid(operation.Shape())
if not result.is_valid:
raise ValueError("OCC body transform produced an invalid shape")
return result, Build123dGeometryAdapter._builder_topology_delta(operation, (body,), kind)
@staticmethod
def _builder_topology_delta(operation: Any, sources: Iterable[Any], operation_name: str) -> TopologyDelta:
"""Translate OCC builder history into adapter-neutral opaque relations."""
relations: list[TopologyDeltaRelation] = []
for source in sources:
for kind, shapes in (
("face", list(source.faces())),
("edge", list(source.edges())),
("vertex", list(source.vertices())),
):
for shape in shapes:
source_value = shape.wrapped
is_deleted = bool(
operation.IsDeleted(source_value)
if hasattr(operation, "IsDeleted") else operation.IsRemoved(source_value)
)
if is_deleted:
relations.append(TopologyDeltaRelation("deleted", kind, source_value))
modified = tuple(operation.Modified(source_value))
generated = tuple(operation.Generated(source_value))
same_modified = (
len(modified) == 1 and bool(modified[0].IsSame(source_value))
)
if modified:
relations.append(TopologyDeltaRelation(
"preserved" if same_modified and not generated else "modified",
kind, source_value, modified,
))
elif not generated and not is_deleted:
# A no-op transform can retain the original OCC object.
# The registry still requires it to appear in the result
# snapshot before treating this as a continuation.
relations.append(TopologyDeltaRelation("preserved", kind, source_value, (source_value,)))
if generated:
relations.append(TopologyDeltaRelation("generated", kind, source_value, generated))
return TopologyDelta(operation=operation_name, relations=tuple(relations))
@staticmethod
def _shell_topology_delta(
operation: Any, source: Solid, closing_faces: Iterable[Face],
) -> TopologyDelta:
"""Annotate exact shell history with only builder-proven output roles."""
base_delta = Build123dGeometryAdapter._builder_topology_delta(operation, (source,), "shell")
closing_values = tuple(face.wrapped for face in closing_faces)
closing_edge_values = tuple(
edge.wrapped for face in closing_faces for edge in face.edges()
)
def is_member(value: Any, candidates: tuple[Any, ...]) -> bool:
return any(bool(value.IsSame(candidate)) for candidate in candidates)
def output_role(relation: TopologyDeltaRelation) -> str | None:
if relation.kind == "face":
is_closing = is_member(relation.source_value, closing_values)
if is_closing and relation.event in {"preserved", "modified", "generated"}:
return "shell.closing_descendant"
if not is_closing and relation.event == "generated":
return "shell.offset_face"
if not is_closing and relation.event in {"preserved", "modified"}:
return "shell.body_face"
if (
relation.kind == "edge" and relation.event == "generated"
and is_member(relation.source_value, closing_edge_values)
):
return "shell.wall"
return None
return TopologyDelta(
operation=base_delta.operation,
relations=tuple(
TopologyDeltaRelation(
relation.event, relation.kind, relation.source_value, relation.result_values,
output_role=output_role(relation),
)
for relation in base_delta.relations
),
)
def hole_tool(self, spec: HoleSpec, starts: Iterable[Vector3], inward: Vector3, through_depth_mm: float) -> Solid:
"""Build a neutral ``HoleSpec`` into one OCC cutting tool."""
# 将孔规格 HoleSpec 转成一个可直接切除的 OCC 工具体。
@@ -713,6 +1336,18 @@ class Build123dGeometryAdapter:
sheet thickness on ``spec.frame.normal`` and the width axis on
``spec.frame.x_dir``.
"""
# ``bend_add`` 生成器是可选的几何实现。不能因该模块未随部署产物
# 提交而让所有非钣金 CDSL 在 adapter import 阶段失效;真正执行
# 折弯时仍须报出精确的缺失依赖,不能退化为虚构实体。
try:
from .parametric_bend import build_bend_solid
except ModuleNotFoundError as error:
if error.name != f"{__package__}.parametric_bend":
raise
raise RuntimeError(
"bend_add requires cdsl_engine.parametric_bend.build_bend_solid, "
"but the generator module is not present in this checkout"
) from error
solid = build_bend_solid(spec)
frame = spec.frame
plane = Plane(
@@ -768,6 +1403,26 @@ class Build123dGeometryAdapter:
# 对指定边以给定半径做圆角。
return body.fillet(radius_mm, list(edges))
@staticmethod
def fillet_with_topology_delta(
body: Any, radius_mm: float, edges: Iterable[Edge],
) -> tuple[Any, TopologyDelta | None]:
"""Fillet a single body and retain its direct OCC builder history."""
selected = list(edges)
if len(Build123dGeometryAdapter.body_solids(body)) != 1:
return Build123dGeometryAdapter.fillet(body, radius_mm, selected), None
builder = BRepFilletAPI_MakeFillet(body.wrapped)
for edge in selected:
builder.Add(radius_mm, edge.wrapped)
builder.Build()
if builder.IsDone():
result = Solid(builder.Shape())
if result.is_valid:
return result, Build123dGeometryAdapter._builder_topology_delta(builder, (body,), "fillet")
# Preserve build123d's existing fallback/error semantics when OCC's
# direct builder cannot construct this dress-up.
return Build123dGeometryAdapter.fillet(body, radius_mm, selected), None
@staticmethod
def tangent_edges(body: Any, seeds: Iterable[Edge], *, angular_tolerance: float = 1e-6) -> list[Edge]:
"""Expand selected edges through actual tangent, vertex-adjacent chains.
@@ -838,6 +1493,25 @@ class Build123dGeometryAdapter:
return result
return body.chamfer(distance_mm, distance_2_mm, selected, face=face)
@staticmethod
def chamfer_with_topology_delta(
body: Any, distance_mm: float, distance_2_mm: float | None,
edges: Iterable[Edge], face: Face | None = None,
) -> tuple[Any, TopologyDelta | None]:
"""Retain history for the equal-distance single-body chamfer subset."""
selected = list(edges)
if distance_2_mm is not None or face is not None or len(Build123dGeometryAdapter.body_solids(body)) != 1:
return Build123dGeometryAdapter.chamfer(body, distance_mm, distance_2_mm, selected, face=face), None
builder = BRepFilletAPI_MakeChamfer(body.wrapped)
for edge in selected:
builder.Add(distance_mm, edge.wrapped)
builder.Build()
if builder.IsDone():
result = Solid(builder.Shape())
if result.is_valid:
return result, Build123dGeometryAdapter._builder_topology_delta(builder, (body,), "chamfer")
return Build123dGeometryAdapter.chamfer(body, distance_mm, distance_2_mm, selected, face=face), None
@staticmethod
def _surface_limited_chamfer_tool(body: Any, edge: Edge, distance_mm: float, surfaces: Iterable[Any]) -> Solid:
"""Build the removable material for one surface-supported circular chamfer.
@@ -962,6 +1636,15 @@ class Build123dGeometryAdapter:
@staticmethod
def shell(body: Any, faces: Iterable[Face], thickness_mm: float, *, inward: bool = True) -> Any:
result, _delta = Build123dGeometryAdapter.shell_with_topology_delta(
body, faces, thickness_mm, inward=inward,
)
return result
@staticmethod
def shell_with_topology_delta(
body: Any, faces: Iterable[Face], thickness_mm: float, *, inward: bool = True,
) -> tuple[Any, TopologyDelta]:
# 对单个实体移除指定面并偏置其余面,生成薄壁实体。多 body 的目标
# 选择与结果合并由 runtime 处理;OCC 的 MakeThickSolidByJoin 只接受
# 一个 Solid,不能把 Compound 直接交给内核并猜测其 body 生命周期。
@@ -995,7 +1678,7 @@ class Build123dGeometryAdapter:
result = Solid(builder.Shape())
if not result.is_valid:
raise ValueError("OCC shell operation produced an invalid shape")
return result
return result, Build123dGeometryAdapter._shell_topology_delta(builder, solids[0], selected)
@staticmethod
def sweep(
@@ -1006,6 +1689,22 @@ class Build123dGeometryAdapter:
make_solid: bool = True,
is_frenet: bool = False,
transition: Any = None,
) -> Solid:
result, _delta = Build123dGeometryAdapter.sweep_with_topology_delta(
section, spine, inner_wires=inner_wires, make_solid=make_solid,
is_frenet=is_frenet, transition=transition,
)
return result
@staticmethod
def _sweep_without_topology_delta(
section: Face | Wire,
spine: Edge | Wire,
*,
inner_wires: list[Wire] | None = None,
make_solid: bool = True,
is_frenet: bool = False,
transition: Any = None,
) -> Solid:
# 沿路径线扫掠截面生成实体(build123d 原生扫掠,路径可为直线/曲线/螺旋边)。
# 默认值对齐 build123d Solid.sweepmake_solid=True 封盖成体;is_frenet=True
@@ -1025,6 +1724,54 @@ class Build123dGeometryAdapter:
raise ValueError("OCC sweep operation did not produce a solid")
return result
@staticmethod
def sweep_with_topology_delta(
section: Face | Wire,
spine: Edge | Wire,
*,
inner_wires: list[Wire] | None = None,
make_solid: bool = True,
is_frenet: bool = False,
transition: Any = None,
) -> tuple[Solid, TopologyDelta | None]:
"""Sweep one simple profile through its direct pipe-shell builder.
The existing native path remains authoritative for hollow profiles,
transition variants and non-solid output. Those cases can still
execute, but their final builder provenance is not available through
this bounded contract.
"""
if (
not isinstance(section, Face)
or section.inner_wires()
or inner_wires
or not make_solid
or transition is not None
):
return Build123dGeometryAdapter._sweep_without_topology_delta(
section, spine, inner_wires=inner_wires, make_solid=make_solid,
is_frenet=is_frenet, transition=transition,
), None
path = spine if isinstance(spine, Wire) else Wire.combine([spine])[0]
builder = BRepOffsetAPI_MakePipeShell(path.wrapped)
builder.SetMode(bool(is_frenet))
builder.Add(section.outer_wire().wrapped, False, False)
builder.Build()
if not builder.IsDone():
raise ValueError("OCC sweep operation did not complete")
if not builder.MakeSolid():
raise ValueError("OCC sweep operation did not produce a solid")
result = Solid(builder.Shape())
if not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9 or not result.is_valid:
raise ValueError("OCC sweep operation did not produce a valid solid")
relations: list[TopologyDeltaRelation] = []
for output, role in ((builder.FirstShape(), "sweep.start"), (builder.LastShape(), "sweep.end")):
if not output.IsNull() and output.ShapeType() == TopAbs_FACE:
relations.append(TopologyDeltaRelation(
"generated", "face", section.wrapped, (output,), output_role=role,
))
return result, TopologyDelta(operation="sweep", relations=tuple(relations))
@staticmethod
def sweep_path(
points: Iterable[Vector3],
@@ -1047,6 +1794,21 @@ class Build123dGeometryAdapter:
tangents = [_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None
if parameters is not None and len(parameters) != len(vertices):
raise ValueError("sweep B-spline path parameters must match point count")
direction = vertices[-1] - vertices[0]
tolerance = 1e-9 * max(1.0, direction.length)
collinear_points = direction.length > tolerance and all(
(point - vertices[0]).cross(direction).length <= tolerance
for point in vertices[1:-1]
)
collinear_tangents = tangents is None or all(
tangent.cross(direction).length <= tolerance and tangent.dot(direction) > tolerance
for tangent in tangents
)
if collinear_points and collinear_tangents:
# OCC 对完全共线的插值 B-spline 做实体 sweep 时可能无限求解。
# 此处的点列和端切线没有曲率信息,几何上严格等价于一条直线;
# 仅在同向条件成立时退化,反向切线仍保留 B-spline 语义。
return Edge.make_line(vertices[0], vertices[-1])
return Edge.make_spline(vertices, tangents=tangents, parameters=parameters, scale=False)
@staticmethod
@@ -1426,6 +2188,20 @@ class Build123dGeometryAdapter:
"curve_type": str(edge.geom_type).split(".")[-1].lower(),
"adjacent_face_count": len(edge_faces[index]),
}
if geometry["curve_type"] == "circle":
# ``Edge.center()`` is a point on a periodic circle, not its
# geometric centre. Preserve the OCC circle data separately
# so a provenance-backed rotational selector can distinguish
# concentric full circles at different axial locations.
try:
circle_center = edge.arc_center
radius = float(edge.radius)
values = (circle_center.X, circle_center.Y, circle_center.Z, radius)
except (AttributeError, TypeError, ValueError):
values = ()
if values and all(math.isfinite(float(value)) for value in values) and radius > 0:
geometry["circle_center_mm"] = [circle_center.X, circle_center.Y, circle_center.Z]
geometry["radius_mm"] = radius
if vertices:
geometry["start_mm"] = list(vertices[0])
geometry["end_mm"] = list(vertices[-1])
+481 -18
View File
@@ -12,17 +12,20 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
from .runtime_types import CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeDiagnostic
from .runtime_types import (
CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeDiagnostic,
pattern_instance_member_id, transform_copy_member_id,
)
from .operation_contracts import materialized_feature_contracts
_SELECTOR_REQUIRED = frozenset({"extrude_add_blind_with_hole", "loft_add_with_cap_face", "fillet", "chamfer", "shell"})
_SELECTOR_REQUIRED = frozenset({"extrude_add_blind_with_hole", "extrude_from_face", "loft_add_with_cap_face", "fillet", "chamfer", "shell"})
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_")
# 开放轮廓(closed=false / role=open)只有"刀具截面补槽口边闭合后作切除"的
# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。
_OPEN_PROFILE_ATOMICS = frozenset({"extrude_cut_blind", "extrude_cut_through"})
_PRIMARY_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface",
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face", "extrude_surface",
"extrude_cut_through",
"revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
@@ -36,11 +39,18 @@ _ACTIVE_BODY_REQUIRED = frozenset({
"thread_cut",
})
_BODY_MUTATING_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face",
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", *_HOLE_ATOMICS, "fillet", "chamfer",
"thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", *_HOLE_ATOMICS, "fillet", "chamfer", "shell",
})
# ``ExecutionSession.body_members`` only contains independently selectable
# body outputs. A normal additive/cut/dress-up feature replaces the active
# aggregate, while ``result_mode: new_body`` and ``keep_tools`` are the two
# contracts that preserve a previous member. Circular patterns over those
# members can additionally expose a proven COPY instance; replayed/fused
# patterns remain ineligible because no exact instance ownership exists.
_PATTERN_ATOMICS = frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"})
# A pattern may replay a previous pattern as well as a direct body mutation.
# Context-only features have no geometry definition to instance. thread_add,
# thread_cut, bend_add, gear_add and rack_add are excluded: pattern
@@ -61,6 +71,167 @@ _EXTENT_TARGET_KINDS = {
}
def _mappings(value: Any):
"""Yield nested feature mappings for capability-only contract checks."""
if isinstance(value, dict):
yield value
for child in value.values():
yield from _mappings(child)
elif isinstance(value, list):
for child in value:
yield from _mappings(child)
def _transform_member_sources(params: dict[str, Any]) -> set[str]:
"""Return internal body-member keys named by a transform contract."""
source_ids = {str(value) for value in params.get("source_feature_ids") or ()}
source_ids.update(
pattern_instance_member_id(
str(reference.get("pattern_feature_id") or ""),
str(reference.get("source_feature_id") or ""),
int(reference.get("instance_index") or 0),
)
for reference in params.get("pattern_instance_refs") or ()
if isinstance(reference, dict)
)
source_ids.update(
transform_copy_member_id(
str(reference.get("transform_feature_id") or ""),
str(reference.get("source_feature_id") or ""),
)
for reference in params.get("transform_copy_refs") or ()
if isinstance(reference, dict)
)
return source_ids
def _pattern_instance_member_sources(params: dict[str, Any], parameter: str) -> set[str]:
"""Project structured pattern refs to internal body-member keys."""
return {
pattern_instance_member_id(
str(reference.get("pattern_feature_id") or ""),
str(reference.get("source_feature_id") or ""),
int(reference.get("instance_index") or 0),
)
for reference in params.get(parameter) or ()
if isinstance(reference, dict)
}
def _next_body_graph(
node: FeaturePlanNode,
members: set[str],
has_active_body: bool,
nodes_by_id: dict[str, FeaturePlanNode],
) -> tuple[set[str], bool]:
"""Project the explicit runtime body-member lifecycle without geometry.
The capability phase cannot know whether two B-reps intersect, but it can
mirror the ownership contract used by ``ExecutionSession``. This keeps a
historical feature's successful execution separate from its continued
availability as an independently selectable body. In particular, this
must never turn an absorbed feature or pattern replay into ``session.body``.
"""
atomic_id = node.atomic_id
feature_id = node.feature_id
if atomic_id == "boolean_bodies":
target_ids = {str(value) for value in node.params.get("target_feature_ids") or ()}
target_ids.update(_pattern_instance_member_sources(node.params, "target_pattern_instance_refs"))
tool_ids = {str(value) for value in node.params.get("tool_feature_ids") or ()}
tool_ids.update(_pattern_instance_member_sources(node.params, "tool_pattern_instance_refs"))
next_members = members - target_ids - tool_ids
next_members.add(feature_id)
if bool(node.params.get("keep_tools")):
next_members.update(tool_ids)
return next_members, True
if atomic_id == "transform_bodies":
source_ids = _transform_member_sources(node.params)
next_members = set(members)
if not bool(node.params.get("make_copy")):
next_members.difference_update(source_ids)
next_members.add(feature_id)
elif len(node.params.get("source_feature_ids") or ()) > 1:
# Multi-source COPY outputs have no aggregate body-member owner.
# Keep each transformed source addressable by its exact origin.
next_members.update(
transform_copy_member_id(feature_id, source_id)
for source_id in source_ids
)
else:
next_members.add(feature_id)
return next_members, True
if atomic_id == "delete_bodies":
source_ids = {str(value) for value in node.params.get("target_feature_ids") or ()}
next_members = members - source_ids
return next_members, bool(next_members)
if atomic_id in _PATTERN_ATOMICS:
source_ids = {str(value) for value in node.params.get("source_feature_ids") or ()}
if (
atomic_id == "pattern_mirror"
and source_ids
and source_ids <= members
and all(
(source := nodes_by_id.get(source_id)) is not None
and source.params.get("result_mode") == "new_body"
for source_id in source_ids
)
):
# A mirror produces an independently addressable COPY only when
# every source is an explicit NEW body. A hole, dress-up, or
# ordinary additive feature may be the current aggregate's
# successor, not a standalone body: its mirror must remain a
# feature replay and cannot be exposed as a body member.
next_members = set(members)
next_members.update(
pattern_instance_member_id(node.feature_id, source_id, 1)
for source_id in source_ids
)
return next_members, True
if (
atomic_id == "pattern_circular"
and str(node.params.get("operation_mode") or "add") == "add"
and source_ids
and source_ids <= members
):
count = int(node.params.get("pattern_count") or 0)
excluded = {int(value) for value in node.params.get("excluded_instance_indices") or ()}
next_members = set(members)
for instance in range(1, count):
if instance in excluded:
continue
next_members.update(
pattern_instance_member_id(node.feature_id, source_id, instance)
for source_id in source_ids
)
return next_members, True
# Replay/fused pattern output has no member-level contract. It creates
# active geometry, but cannot prove which replay instance a later body
# query names.
return set(), has_active_body
if atomic_id not in _BODY_MUTATING_ATOMICS:
return members, has_active_body
if atomic_id in {"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "revolve_cut"} or (
atomic_id == "extrude_from_face" and node.params.get("operation") == "cut"
):
# Primary cuts execute per explicit member in the runtime so a later
# body query still addresses the same CADFS NEW/COPY lifecycle node.
return set(members), True
if atomic_id in _PRIMARY_ATOMICS and "cut" not in atomic_id and node.params.get("operation") != "cut" and node.params.get("result_mode") == "new_body":
return members | {feature_id}, True
# All remaining body-mutating executors register their output as the sole
# explicit member. This includes fused additive features, cuts and
# dress-ups, whose source-member topology no longer has an identity.
return {feature_id}, True
def _has_explicit_axis(axis: Any) -> bool:
return isinstance(axis, dict) and axis.get("origin_mm") is not None and axis.get("direction") is not None
@@ -171,6 +342,13 @@ def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]:
def _has_closed_region(sketch: dict[str, Any]) -> bool:
"""Mirror the adapter's input contract without importing the geometry kernel."""
profile = sketch.get("profile") or {}
if profile.get("type") == "planar_imprint":
# The exact bounded-region proof happens in the OCC splitter. At this
# stage the typed contract proves only that region selection work is
# possible; an unbounded or ambiguous runtime result remains a stable
# feature execution diagnostic rather than a guessed sketch contour.
return bool(sketch.get("imprint_entities_mm") and sketch.get("imprint_selections"))
regions = sketch.get("contour_regions_mm") or []
if any(len(region.get("outer") or []) >= 1 for region in regions if isinstance(region, dict)):
return True
@@ -254,6 +432,7 @@ class CapabilityAnalyzer:
results: list[CapabilityResult] = []
completed: set[str] = set()
body_available = False
body_members: set[str] = set()
for node in plan:
blockers: list[RuntimeDiagnostic] = []
contract = self.contracts.get(node.atomic_id)
@@ -267,7 +446,9 @@ class CapabilityAnalyzer:
for dependency in node.depends_on:
if dependency not in completed:
blockers.append(self._blocker(node.feature_id, "dependency_unavailable", "Feature dependency did not become executable", dependency=dependency))
if node.atomic_id in _ACTIVE_BODY_REQUIRED:
if node.atomic_id in _ACTIVE_BODY_REQUIRED or (
node.atomic_id == "extrude_from_face" and node.params.get("operation") == "cut"
):
required.append("active_body")
if not body_available:
blockers.append(self._blocker(
@@ -362,6 +543,74 @@ class CapabilityAnalyzer:
"Loft currently requires exactly one outer profile without holes",
sketch_id=sketch_id,
))
for selector in _mappings(params):
if selector.get("output_role") is not None:
blockers.append(self._blocker(
node.feature_id,
"unsupported_output_role_selector_context",
"Feature output role selectors are only supported in feature.selectors",
))
for selector_index, selector in enumerate(node.selectors):
if selector.get("output_role") is None:
continue
required.append("selector:feature_output_role")
if contract is None or contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face":
blockers.append(self._blocker(
node.feature_id,
"unsupported_output_role_selector",
"This feature contract cannot consume a feature output role selector",
selector_index=selector_index,
))
if selector.get("kind") != "face" or not isinstance(selector.get("owner_feature_id"), str):
blockers.append(self._blocker(
node.feature_id,
"invalid_output_role_selector",
"A feature output role selector requires kind face and owner_feature_id",
selector_index=selector_index,
))
if selector.get("source") != "runtime_snapshot":
blockers.append(self._blocker(
node.feature_id,
"invalid_output_role_selector",
"A feature output role selector requires runtime_snapshot evidence",
selector_index=selector_index,
))
if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")):
blockers.append(self._blocker(
node.feature_id,
"invalid_output_role_selector",
"A feature output role selector cannot mix stable or geometry evidence",
selector_index=selector_index,
))
role_source = selector.get("output_role_source")
if role_source is not None:
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
source = nodes_by_id.get(str(source_owner or ""))
source_params = (source.params if source is not None else {}) or {}
if not isinstance(source_owner, str) or not isinstance(source_role, str):
blockers.append(self._blocker(
node.feature_id, "invalid_output_role_source",
"An output role source requires owner_feature_id and output_role",
selector_index=selector_index,
))
elif selector.get("output_role") != "shell.offset_face" or node.atomic_id != "shell":
blockers.append(self._blocker(
node.feature_id, "unsupported_output_role_source",
"Output role sources are currently supported only for shell.offset_face",
selector_index=selector_index,
))
elif source_role not in {"extrude.start", "extrude.end"} or (
source is None
or source.atomic_id != "extrude_add_blind"
or source_params.get("result_mode") != "new_body"
or (source_params.get("end_condition") or {}).get("type") != "blind"
):
blockers.append(self._blocker(
node.feature_id, "unsupported_output_role_source",
"shell.offset_face requires a direct new_body blind extrusion cap source",
selector_index=selector_index,
))
if node.atomic_id == "sweep_add":
path = params.get("path")
segment = path.get("segment") if isinstance(path, dict) else None
@@ -389,18 +638,29 @@ class CapabilityAnalyzer:
))
if node.atomic_id == "boolean_bodies":
target_ids = params.get("target_feature_ids")
target_instance_refs = params.get("target_pattern_instance_refs")
tool_ids = params.get("tool_feature_ids")
tool_instance_refs = params.get("tool_pattern_instance_refs")
operation = params.get("operation")
if operation not in {"union", "subtract", "intersect"}:
blockers.append(self._blocker(
node.feature_id, "unsupported_boolean_operation",
"booleanBodies requires union, subtract or intersect",
))
for parameter, feature_ids in (("target_feature_ids", target_ids), ("tool_feature_ids", tool_ids)):
if not isinstance(feature_ids, list) or not feature_ids:
target_members: set[str] = set()
tool_members: set[str] = set()
for parameter, instance_parameter, feature_ids, instance_refs, selected_members in (
("target_feature_ids", "target_pattern_instance_refs", target_ids, target_instance_refs, target_members),
("tool_feature_ids", "tool_pattern_instance_refs", tool_ids, tool_instance_refs, tool_members),
):
if feature_ids is None:
feature_ids = []
if instance_refs is None:
instance_refs = []
if not isinstance(feature_ids, list) or not isinstance(instance_refs, list) or not (feature_ids or instance_refs):
blockers.append(self._blocker(
node.feature_id, "missing_boolean_bodies",
"booleanBodies requires explicit target and tool feature ids", parameter=parameter,
"booleanBodies requires explicit target and tool body references", parameter=parameter,
))
continue
for source_id in feature_ids:
@@ -408,18 +668,222 @@ class CapabilityAnalyzer:
if source is None:
blockers.append(self._blocker(
node.feature_id, "boolean_body_unavailable",
"booleanBodies source feature does not exist", missing_feature_id=source_id,
"booleanBodies source feature does not exist", source_feature_id=source_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", missing_feature_id=source_id,
"booleanBodies source feature did not become executable", source_feature_id=source_id,
))
if isinstance(target_ids, list) and isinstance(tool_ids, list) and set(target_ids) & set(tool_ids):
elif source.feature_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "boolean_body_unavailable",
"Selected source no longer has an independently selectable body output",
source_feature_id=source_id,
))
else:
selected_members.add(str(source_id))
for index, reference in enumerate(instance_refs):
if not isinstance(reference, dict):
blockers.append(self._blocker(
node.feature_id, "invalid_pattern_instance_ref",
"Pattern instance body reference must be an object", parameter=instance_parameter, index=index,
))
continue
instance = reference.get("instance_index")
if not isinstance(instance, int):
blockers.append(self._blocker(
node.feature_id, "invalid_pattern_instance_ref",
"Pattern instance body reference requires an integer instance_index",
parameter=instance_parameter, index=index,
))
continue
member_id = pattern_instance_member_id(
str(reference.get("pattern_feature_id") or ""),
str(reference.get("source_feature_id") or ""),
instance,
)
if member_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "pattern_instance_unavailable",
"Pattern instance has no independently selectable body output",
pattern_feature_id=reference.get("pattern_feature_id"),
source_feature_id=reference.get("source_feature_id"),
instance_index=reference.get("instance_index"),
))
continue
selected_members.add(member_id)
if target_members & tool_members:
blockers.append(self._blocker(
node.feature_id, "boolean_body_overlap",
"booleanBodies targets and tools must be disjoint",
))
if node.atomic_id == "shell" and params.get("target_feature_id") is not None:
target_feature_id = params.get("target_feature_id")
required.append("shell:explicit_target_body")
target = nodes_by_id.get(str(target_feature_id or ""))
if not isinstance(target_feature_id, str) or not target_feature_id:
blockers.append(self._blocker(
node.feature_id, "invalid_shell_target_body",
"shell target_feature_id must name one preceding body member",
))
elif target is None:
blockers.append(self._blocker(
node.feature_id, "shell_target_body_unavailable",
"shell target body feature does not exist",
target_feature_id=target_feature_id,
))
elif target.feature_id not in completed:
blockers.append(self._blocker(
node.feature_id, "shell_target_body_unavailable",
"shell target body feature did not become executable",
target_feature_id=target_feature_id,
))
elif target.feature_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "shell_target_body_unavailable",
"shell target no longer has an independently selectable body output",
target_feature_id=target_feature_id,
))
if node.atomic_id in {"transform_bodies", "delete_bodies"}:
parameter = "source_feature_ids" if node.atomic_id == "transform_bodies" else "target_feature_ids"
source_ids = params.get(parameter)
pattern_instances = params.get("pattern_instance_refs") if node.atomic_id == "transform_bodies" else []
transform_copies = params.get("transform_copy_refs") if node.atomic_id == "transform_bodies" else []
if source_ids is None:
source_ids = []
if pattern_instances is None:
pattern_instances = []
if transform_copies is None:
transform_copies = []
if (
not isinstance(source_ids, list)
or not isinstance(pattern_instances, list)
or not isinstance(transform_copies, list)
or not (source_ids or pattern_instances or transform_copies)
):
blockers.append(self._blocker(
node.feature_id, "missing_body_sources",
f"{node.atomic_id} requires explicit source body feature ids", parameter=parameter,
))
else:
for source_id in source_ids:
source = nodes_by_id.get(str(source_id))
if source is None:
blockers.append(self._blocker(
node.feature_id, "body_source_unavailable",
"Selected body source feature does not exist", source_feature_id=source_id,
))
elif source.feature_id not in completed:
blockers.append(self._blocker(
node.feature_id, "body_source_unavailable",
"Selected body source did not become executable", source_feature_id=source_id,
))
elif source.feature_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "body_source_unavailable",
"Selected source no longer has an independently selectable body output",
source_feature_id=source_id,
))
for reference in pattern_instances:
if not isinstance(reference, dict):
blockers.append(self._blocker(
node.feature_id, "invalid_pattern_instance_ref",
"Pattern instance body reference must be an object",
))
continue
pattern_id = str(reference.get("pattern_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
instance = reference.get("instance_index")
pattern = nodes_by_id.get(pattern_id)
if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}:
blockers.append(self._blocker(
node.feature_id, "pattern_instance_unavailable",
"Pattern instance owner is not a preceding circular or mirror pattern",
pattern_feature_id=pattern_id,
))
continue
if pattern.feature_id not in completed:
blockers.append(self._blocker(
node.feature_id, "pattern_instance_unavailable",
"Pattern instance owner did not become executable",
pattern_feature_id=pattern_id,
))
continue
count = int(pattern.params.get("pattern_count") or 0)
excluded = {int(value) for value in pattern.params.get("excluded_instance_indices") or ()}
surviving_instance = (
isinstance(instance, int)
and (
(pattern.atomic_id == "pattern_mirror" and instance == 1)
or (
pattern.atomic_id == "pattern_circular"
and 1 <= instance < count
and instance not in excluded
)
)
)
if (
not surviving_instance
or source_id not in {str(value) for value in pattern.params.get("source_feature_ids") or ()}
):
blockers.append(self._blocker(
node.feature_id, "pattern_instance_unavailable",
"Pattern instance reference is not a surviving source copy",
pattern_feature_id=pattern_id, source_feature_id=source_id, instance_index=instance,
))
continue
member_id = pattern_instance_member_id(pattern_id, source_id, instance)
if member_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "pattern_instance_unavailable",
"Pattern instance has no independently selectable body output",
pattern_feature_id=pattern_id, source_feature_id=source_id, instance_index=instance,
))
for reference in transform_copies:
if not isinstance(reference, dict):
blockers.append(self._blocker(
node.feature_id, "invalid_transform_copy_ref",
"Transform COPY body reference must be an object",
))
continue
transform_id = str(reference.get("transform_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
transform = nodes_by_id.get(transform_id)
if transform is None or transform.atomic_id != "transform_bodies":
blockers.append(self._blocker(
node.feature_id, "transform_copy_unavailable",
"Transform COPY owner is not a preceding body transform",
transform_feature_id=transform_id,
))
continue
if transform.feature_id not in completed:
blockers.append(self._blocker(
node.feature_id, "transform_copy_unavailable",
"Transform COPY owner did not become executable",
transform_feature_id=transform_id,
))
continue
transform_sources = transform.params.get("source_feature_ids") or []
if (
not bool(transform.params.get("make_copy"))
or not isinstance(transform_sources, list)
or len(transform_sources) < 2
or source_id not in {str(value) for value in transform_sources}
):
blockers.append(self._blocker(
node.feature_id, "transform_copy_unavailable",
"Transform COPY reference is not a source-qualified multi-body copy",
transform_feature_id=transform_id, source_feature_id=source_id,
))
continue
member_id = transform_copy_member_id(transform_id, source_id)
if member_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "transform_copy_unavailable",
"Transform COPY source has no independently selectable body output",
transform_feature_id=transform_id, source_feature_id=source_id,
))
if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES):
end_condition = params.get("end_condition") or {"type": "blind"}
end_type = end_condition.get("type")
@@ -429,7 +893,7 @@ class CapabilityAnalyzer:
# Solid.extrude_taper。双向、到面和非实体 profile 的中性面
# 语义尚无 CDSL 表达,必须保留为明确能力缺口。
if (
node.atomic_id not in {"extrude_add_blind", "extrude_cut_blind"}
node.atomic_id not in {"extrude_add_blind", "extrude_cut_blind", "extrude_from_face"}
or end_type != "blind"
):
blockers.append(self._blocker(
@@ -469,7 +933,7 @@ class CapabilityAnalyzer:
node.feature_id, "missing_offset_distance",
"Offset-from-surface requires a non-zero captured offset distance",
))
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}:
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"} or bool(params.get("two_sided")):
reverse_condition = params.get("reverse_end_condition") or {"type": "blind"}
reverse_type = reverse_condition.get("type")
required.append(f"extent:reverse:{reverse_type}")
@@ -501,7 +965,7 @@ class CapabilityAnalyzer:
))
if node.atomic_id in _SELECTOR_REQUIRED and not node.selectors:
blockers.append(self._blocker(node.feature_id, "missing_selector", "Dress-up features require an explicit selector"))
if node.atomic_id in {"extrude_add_blind_with_hole", "loft_add_with_cap_face"}:
if node.atomic_id in {"extrude_add_blind_with_hole", "extrude_from_face", "loft_add_with_cap_face"}:
face_selectors = [selector for selector in node.selectors if selector.get("kind") == "face"]
if len(face_selectors) != 1 or len(node.selectors) != 1:
blockers.append(self._blocker(
@@ -604,10 +1068,9 @@ class CapabilityAnalyzer:
results.append(CapabilityResult(node.feature_id, node.atomic_id, status, tuple(required), tuple(blockers)))
if status == "executable":
completed.add(node.feature_id)
if node.atomic_id in _BODY_MUTATING_ATOMICS:
body_available = True
body_members, body_available = _next_body_graph(node, body_members, body_available, nodes_by_id)
body_producers = {
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face",
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "boolean_bodies",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add", "bend_add", "gear_add", "rack_add",
+142 -9
View File
@@ -103,6 +103,12 @@
"required": ["distance_mm"],
"additionalProperties": false
},
"extrudeFromFaceParams": {
"type": "object",
"properties": {"distance_mm": {"$ref": "#/$defs/number"}, "operation": {"enum": ["add", "cut"]}, "reverse": {"type": "boolean"}, "reverse_distance_mm": {"$ref": "#/$defs/number"}, "two_sided": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}, "reverse_end_condition": {"$ref": "#/$defs/endCondition"}, "draft": {"$ref": "#/$defs/extrudeDraft"}, "result_mode": {"enum": ["fuse", "new_body"]}},
"required": ["distance_mm", "operation"],
"additionalProperties": false
},
"extrudeDraft": {
"type": "object",
"properties": {"angle_deg": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 90}, "pull_direction": {"type": "boolean"}},
@@ -379,7 +385,7 @@
},
"shellParams": {
"type": "object",
"properties": {"thickness_mm": {"type": "number", "exclusiveMinimum": 0}, "inward": {"type": "boolean"}},
"properties": {"thickness_mm": {"type": "number", "exclusiveMinimum": 0}, "inward": {"type": "boolean"}, "target_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"required": ["thickness_mm"],
"additionalProperties": false
},
@@ -388,10 +394,74 @@
"properties": {
"operation": {"enum": ["union", "subtract", "intersect"]},
"target_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"target_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
"tool_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"tool_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
"keep_tools": {"type": "boolean"}
},
"required": ["operation", "target_feature_ids", "tool_feature_ids"],
"required": ["operation"],
"allOf": [
{"anyOf": [{"required": ["target_feature_ids"]}, {"required": ["target_pattern_instance_refs"]}]},
{"anyOf": [{"required": ["tool_feature_ids"]}, {"required": ["tool_pattern_instance_refs"]}]}
],
"additionalProperties": false
},
"bodyTransform": {
"type": "object",
"properties": {
"type": {"enum": ["translation", "rotation", "uniform_scale"]},
"translation_mm": {"$ref": "#/$defs/point3"},
"axis": {"$ref": "#/$defs/axis"},
"angle_deg": {"type": "number"},
"center_mm": {"$ref": "#/$defs/point3"},
"scale_factor": {"type": "number", "exclusiveMinimum": 0}
},
"required": ["type"],
"allOf": [
{"if": {"properties": {"type": {"const": "translation"}}, "required": ["type"]}, "then": {"required": ["translation_mm"]}},
{"if": {"properties": {"type": {"const": "rotation"}}, "required": ["type"]}, "then": {"required": ["axis", "angle_deg"]}},
{"if": {"properties": {"type": {"const": "uniform_scale"}}, "required": ["type"]}, "then": {"required": ["center_mm", "scale_factor"]}}
],
"additionalProperties": false
},
"patternInstanceBodyRef": {
"type": "object",
"properties": {
"pattern_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"source_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"instance_index": {"type": "integer", "minimum": 1}
},
"required": ["pattern_feature_id", "source_feature_id", "instance_index"],
"additionalProperties": false
},
"transformCopyBodyRef": {
"type": "object",
"properties": {
"transform_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"source_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
},
"required": ["transform_feature_id", "source_feature_id"],
"additionalProperties": false
},
"transformBodiesParams": {
"type": "object",
"properties": {
"source_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
"transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}},
"transform": {"$ref": "#/$defs/bodyTransform"},
"make_copy": {"type": "boolean"}
},
"required": ["transform", "make_copy"],
"anyOf": [{"required": ["source_feature_ids"]}, {"required": ["pattern_instance_refs"]}, {"required": ["transform_copy_refs"]}],
"additionalProperties": false
},
"deleteBodiesParams": {
"type": "object",
"properties": {
"target_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}
},
"required": ["target_feature_ids"],
"additionalProperties": false
},
"linearPatternParams": {
@@ -469,12 +539,30 @@
"required": ["hole_type", "diameter_mm", "depth_mm", "end_condition"],
"additionalProperties": false
},
"featureOutputRole": {
"enum": [
"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end",
"shell.offset_face", "shell.closing_descendant", "shell.body_face"
]
},
"outputRoleSource": {
"type": "object",
"properties": {
"owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_.:-]{1,160}$"},
"output_role": {"$ref": "#/$defs/featureOutputRole"}
},
"required": ["owner_feature_id", "output_role"],
"additionalProperties": false
},
"selectorRef": {
"type": "object",
"properties": {
"kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]},
"stable_id": {"type": "string", "minLength": 1},
"output_role": {"$ref": "#/$defs/featureOutputRole"},
"output_role_source": {"$ref": "#/$defs/outputRoleSource"},
"owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_.:-]{1,160}$"},
"owner_match_required": {"type": "boolean"},
"geometry": {"type": "object"},
"source": {"enum": ["solidworks", "inferred_from_step", "runtime_snapshot", "viewer_selection"]},
"snapshot_id": {"type": "string", "minLength": 1},
@@ -484,7 +572,8 @@
"intersection_of": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}},
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
},
"required": ["kind", "stable_id", "source", "confidence"],
"required": ["kind", "source", "confidence"],
"anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}],
"additionalProperties": false
},
"analyticSegment": {
@@ -498,8 +587,8 @@
"major_radius_mm": {"$ref": "#/$defs/positive"},
"minor_radius_mm": {"$ref": "#/$defs/positive"},
"major_axis": {"$ref": "#/$defs/point2"},
"points": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}},
"parameters": {"type": "array", "minItems": 3, "items": {"type": "number"}},
"points": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/point2"}},
"parameters": {"type": "array", "minItems": 2, "items": {"type": "number"}},
"periodic": {"type": "boolean"},
"parameterization": {"enum": ["chord", "centripetal"]},
"clockwise": {"type": "boolean"},
@@ -512,7 +601,8 @@
{"if": {"properties": {"type": {"const": "arc"}}}, "then": {"required": ["start", "end", "center", "radius_mm"]}},
{"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}},
{"if": {"properties": {"type": {"const": "ellipse"}}}, "then": {"required": ["center", "major_radius_mm", "minor_radius_mm", "major_axis"]}},
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["start", "end", "points"]}}
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["start", "end", "points"]}},
{"if": {"properties": {"type": {"const": "bspline"}, "points": {"maxItems": 2}}}, "then": {"required": ["start_tangent", "end_tangent", "parameters"], "properties": {"periodic": {"const": false}}}}
],
"additionalProperties": false
},
@@ -555,7 +645,46 @@
"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", "sweep_add", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "pattern_circular", "boolean_bodies", "reference_plane", "reference_axis", "hole_wizard"]},
"imprintSourceEntity": {
"type": "object",
"properties": {
"id": {"type": "string", "minLength": 1, "maxLength": 160},
"curve": {"$ref": "#/$defs/analyticSegment"}
},
"required": ["id", "curve"],
"additionalProperties": false
},
"imprintFragment": {
"type": "object",
"properties": {
"anchor_entity_id": {"type": "string", "minLength": 1, "maxLength": 160},
"side": {"enum": [-1, 1]},
"intersection_index": {"type": "integer", "minimum": 0}
},
"required": ["anchor_entity_id", "side"],
"additionalProperties": false
},
"imprintSelection": {
"type": "object",
"properties": {
"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160},
"face_side": {"enum": [-1, 1]},
"fragment": {"$ref": "#/$defs/imprintFragment"}
},
"required": ["source_entity_id", "face_side"],
"additionalProperties": false
},
"planarImprintProfile": {
"type": "object",
"properties": {
"type": {"const": "planar_imprint"},
"source_entities": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/imprintSourceEntity"}},
"selections": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSelection"}}
},
"required": ["type", "source_entities", "selections"],
"additionalProperties": false
},
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_from_face", "extrude_surface", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", "fillet", "chamfer", "shell", "boolean_bodies", "transform_bodies", "delete_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
"feature": {
"type": "object",
"properties": {
@@ -578,6 +707,7 @@
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_through"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeCutThroughParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_two_sided"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_from_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeFromFaceParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "loft_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "loft_add_with_cap_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftCapFaceParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "sweep_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sweepParams"}}}},
@@ -600,6 +730,8 @@
{"if": {"properties": {"atomic_id": {"const": "chamfer"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/chamferParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "shell"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/shellParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "boolean_bodies"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/booleanBodiesParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "transform_bodies"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/transformBodiesParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "delete_bodies"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/deleteBodiesParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "pattern_linear"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/linearPatternParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "pattern_mirror"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/mirrorPatternParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "pattern_circular"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/circularPatternParams"}}}},
@@ -608,12 +740,13 @@
{"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}}
]
},
"profile_type": {"enum": ["circle", "polygon", "analytic_contours"]},
"profile_type": {"enum": ["circle", "polygon", "analytic_contours", "planar_imprint"]},
"profile": {
"oneOf": [
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false},
{"$ref": "#/$defs/analyticProfile"}
{"$ref": "#/$defs/analyticProfile"},
{"$ref": "#/$defs/planarImprintProfile"}
]
},
"sketch": {
@@ -30,7 +30,13 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
shape = raw.get("fragment_shape")
params_schema = raw.get("author_params_schema")
injected_paths = raw.get("server_injected_paths")
if not isinstance(shape, dict) or not isinstance(params_schema, dict) or not isinstance(injected_paths, list):
selector_policy = raw.get("selector_policy")
if (
not isinstance(shape, dict)
or not isinstance(params_schema, dict)
or not isinstance(injected_paths, list)
or not isinstance(selector_policy, dict)
):
raise ValueError(f"operation contract is incomplete for {atomic_id}")
properties = params_schema.get("properties")
required = params_schema.get("required")
@@ -51,5 +57,7 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
"required_params": materialized_required,
"optional_params": [name for name in properties if name not in materialized_required],
"requires_sketch": shape.get("sketch") == "required",
"selector_slot": selector_policy.get("slot"),
"selector_token_kind": selector_policy.get("token_kind"),
}
return derived
File diff suppressed because one or more lines are too long
+573 -83
View File
@@ -12,8 +12,9 @@ from .build123d_adapter import Build123dGeometryAdapter
from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract
from .runtime_types import (
AxisSpec, BendSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec,
GearSpec, RackSpec, ThreadSpec, Vector3,
GearSpec, RackSpec, ThreadSpec, TopologyDelta, TopologyDeltaRelation, Vector3,
RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry,
pattern_instance_member_id, transform_copy_member_id,
vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit,
)
from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
@@ -21,12 +22,12 @@ from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
ALL_ATOMIC_IDS = frozenset({
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface",
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add",
"extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add",
"revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
"reference_plane", "reference_axis",
"hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror",
"pattern_circular", "boolean_bodies",
"pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies",
"thread_add", "thread_cut",
"bend_add",
"gear_add", "rack_add",
@@ -87,10 +88,15 @@ class GeometryAdapter(Protocol):
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
def face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ...
def loft(self, sketches: list[dict[str, Any]]) -> Any: ...
def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Any, TopologyDelta | None]: ...
def loft_with_cap_face(self, cap_face: Any, sketches: list[dict[str, Any]]) -> Any: ...
def sweep(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> Any: ...
def sweep_with_topology_delta(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> tuple[Any, TopologyDelta | None]: ...
def sweep_path(self, points: list[Vector3], *, start_tangent: Vector3 | None = None, end_tangent: Vector3 | None = None, parameters: list[float] | None = None) -> Any: ...
def face_normal(self, face: Any) -> Vector3: ...
def extrude(self, face: Any, direction: Vector3) -> Any: ...
def extrude_with_topology_delta(self, face: Any, direction: Vector3) -> tuple[Any, TopologyDelta]: ...
def extrude_taper_with_topology_delta(self, face: Any, direction: Vector3, taper_deg: float) -> tuple[Any, TopologyDelta | None]: ...
def extrude_taper(self, face: Any, direction: Vector3, taper_deg: float) -> Any: ...
def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ...
def surface_wires_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
@@ -99,9 +105,14 @@ class GeometryAdapter(Protocol):
def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def revolve_surface(self, wire: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def intersect(self, left: Any, right: Any) -> Any: ...
def intersect_with_topology_delta(self, left: Any, right: Any) -> tuple[Any, TopologyDelta | None]: ...
def transform(self, body: Any, transform: dict[str, Any]) -> Any: ...
def transform_with_topology_delta(self, body: Any, transform: dict[str, Any]) -> tuple[Any, TopologyDelta]: ...
def fuse(self, body: Any | None, solid: Any) -> Any: ...
def fuse_with_topology_delta(self, body: Any | None, solid: Any) -> tuple[Any, TopologyDelta | None]: ...
def combine(self, body: Any | None, solid: Any) -> Any: ...
def cut(self, body: Any, tool: Any) -> Any: ...
def cut_with_topology_delta(self, body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]: ...
def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ...
def thread_solid(self, spec: ThreadSpec) -> Any: ...
def bend_solid(self, spec: BendSpec) -> Any: ...
@@ -117,10 +128,13 @@ class GeometryAdapter(Protocol):
def next_body_face_after(self, body: Any, faces: list[Any], direction: Vector3, *, excluded_face: Any) -> Any: ...
def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ...
def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ...
def fillet_with_topology_delta(self, body: Any, radius_mm: float, edges: list[Any]) -> tuple[Any, TopologyDelta | None]: ...
def tangent_edges(self, body: Any, seeds: list[Any]) -> list[Any]: ...
def chamfer(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> Any: ...
def chamfer_with_topology_delta(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> tuple[Any, TopologyDelta | None]: ...
def surface_limited_chamfer(self, body: Any, distance_mm: float, edges: list[Any], surfaces: list[Any]) -> Any: ...
def shell(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> Any: ...
def shell_with_topology_delta(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> tuple[Any, TopologyDelta]: ...
def export(self, body: Any, path: str) -> None: ...
@@ -146,6 +160,8 @@ class ExecutionSession:
*,
replay_node: FeaturePlanNode | None = None,
body_members: dict[str, Any] | None = None,
topology_delta: TopologyDelta | None = None,
topology_predecessors: list[TopologyRecord] | None = None,
) -> None:
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id"
@@ -156,7 +172,11 @@ class ExecutionSession:
self.body_members = dict(body_members) if body_members is not None else {feature_id: body}
solids = self.adapter.body_solids(body)
if len(solids) <= 1:
self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id))
self.topology.replace_body_topology(
feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id),
topology_delta=topology_delta,
additional_predecessors=topology_predecessors or (),
)
else:
# 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让
# 已登记的本轮成员成为下一个成员的 predecessor,进而把 pattern copy
@@ -166,7 +186,10 @@ class ExecutionSession:
for index, solid in enumerate(solids)
for member_id in [f"{self.body_id}:{index}"]
]
self.topology.replace_body_topologies(feature_id, members, active_body_id=self.body_id)
self.topology.replace_body_topologies(
feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta,
additional_predecessors=topology_predecessors or (),
)
self.topology.register(TopologyRecord(
record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id,
geometry=self.adapter.body_geometry(body), value=body, owner_feature_ids=(feature_id,),
@@ -187,6 +210,12 @@ class ExecutionSession:
))
return surface_id
def clear_body(self) -> None:
"""Clear the active solid after an explicit deleteBodies result."""
self.body = None
self.body_id = None
self.body_members = {}
def _record_selector_resolution(self, resolution: SelectorResolution) -> SelectorResolution:
evidence = resolution.as_dict()
evidence["feature_id"] = self.active_feature_id
@@ -444,14 +473,31 @@ def _extent_vectors(
sketch: dict[str, Any],
session: ExecutionSession,
) -> list[ExtentVector]:
return _extent_vectors_from_normal(
node, faces, vector_unit(_normal_from_sketch(sketch), field_name="sketch normal"), session,
)
def _extent_vectors_from_normal(
node: FeaturePlanNode,
faces: list[Any],
profile_normal: Vector3,
session: ExecutionSession,
) -> list[ExtentVector]:
"""Resolve extents from an explicit profile normal.
A derived profile can be an actual B-rep face rather than a sketch. Its
outward normal is just as authoritative as a sketch workplane normal, so
both profile sources share the same bounded extent semantics.
"""
params = node.params
normal = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal")
normal = vector_unit(profile_normal, field_name="profile normal")
if bool(params.get("reverse")):
normal = vector_scale(normal, -1)
end_condition = params.get("end_condition") or {"type": "blind"}
condition = end_condition.get("type", "blind")
distance = abs(float(params.get("distance_mm") or 0.0))
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}:
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"} or bool(params.get("two_sided")):
reverse_condition = params.get("reverse_end_condition") or {"type": "blind"}
reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0))
if reverse_distance <= 0:
@@ -528,6 +574,97 @@ def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any
)
def _cut_explicit_body_members(session: ExecutionSession, tool: Any) -> dict[str, Any]:
"""Apply a cut to each independently owned body without erasing ownership.
A CADFS NEW body stays independently addressable even when a later REMOVE
feature affects several active bodies. Cutting the aggregate first loses
that identity, so this path uses the equivalent per-member set difference
and drops only members that the tool removes completely.
"""
members: dict[str, Any] = {}
for feature_id, body in session.body_members.items():
result = session.adapter.cut(body, tool)
if abs(float(result.volume)) > 1e-12:
members[feature_id] = result
return members
def _extruded_tool(
node: FeaturePlanNode,
faces: list[Any],
profile_normal: Vector3,
session: ExecutionSession,
) -> tuple[Any, TopologyDelta | None]:
"""Build one extrude tool, retaining caps only from one exact builder result."""
extents = _extent_vectors_from_normal(node, faces, profile_normal, session)
draft = node.params.get("draft")
taper_deg = 0.0
if isinstance(draft, dict):
taper_deg = float(draft["angle_deg"])
if not bool(draft["pull_direction"]):
taper_deg = -taper_deg
topology_delta: TopologyDelta | None = None
solids: list[Any] = []
for face in faces:
for extent in extents:
if draft is not None:
if len(faces) == 1 and len(extents) == 1:
solid, topology_delta = session.adapter.extrude_taper_with_topology_delta(
face, extent.vector, taper_deg,
)
solids.append(solid)
else:
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1:
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
solids.append(solid)
elif extent.trim_to is None:
solids.append(session.adapter.extrude(face, extent.vector))
else:
solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector))
tool = None
for solid in solids:
tool = session.adapter.fuse(tool, solid)
if tool is None:
raise ValueError("extrude produced no solid")
return tool, topology_delta
def _apply_primary_tool(
node: FeaturePlanNode,
session: ExecutionSession,
tool: Any,
*,
cutting: bool,
topology_delta: TopologyDelta | None = None,
) -> FeatureResult:
"""Apply a profile-derived tool while preserving only final-snapshot topology evidence."""
if cutting:
if session.body is None:
raise ValueError("cut feature has no body")
members = _cut_explicit_body_members(session, tool)
if not members:
session.clear_body()
return session.result(node)
body = session.adapter.cut(session.body, tool)
topology_delta = None
elif node.params.get("result_mode") == "new_body":
body = session.adapter.combine(session.body, tool)
members = {**session.body_members, node.feature_id: tool}
else:
body = session.adapter.fuse(session.body, tool)
members = {node.feature_id: body}
# A fuse rebuilds subshape identity. Builder evidence belongs only to
# an unchanged standalone/new-body prism snapshot.
if session.body is not None:
topology_delta = None
session.register_body(
node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta,
)
return session.result(node)
def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, sketch: dict[str, Any] | None = None) -> FeatureResult:
# 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。
@@ -548,28 +685,16 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
if len(faces) != 1:
raise ValueError("profile hole extrusion requires exactly one outer sketch region")
faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])]
topology_delta: TopologyDelta | None = None
# 3. 按特征类型生成子实体:
if node.atomic_id.startswith("extrude_"):
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
# 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在
# profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
extents = _extent_vectors(node, faces, selected_sketch, session)
draft = node.params.get("draft")
taper_deg = 0.0
if isinstance(draft, dict):
taper_deg = float(draft["angle_deg"])
if not bool(draft["pull_direction"]):
taper_deg = -taper_deg
solids: list[Any] = []
for face in faces:
for extent in extents:
if draft is not None:
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
elif extent.trim_to is None:
solids.append(session.adapter.extrude(face, extent.vector))
else:
solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector))
tool, topology_delta = _extruded_tool(
node, faces, _normal_from_sketch(selected_sketch), session,
)
else:
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
axis = _revolve_axis(node, session)
@@ -584,32 +709,26 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
# 侧实现,使三方合同一致。
if bool(node.params.get("reverse")):
angle = -angle
solids = [session.adapter.revolve(face, angle, axis) for face in faces]
# 4. 将所有子实体做布尔并(fuse)合并为一个工具体(tool)。
tool = None
for solid in solids:
tool = session.adapter.fuse(tool, solid)
if tool is None:
raise ValueError("primary feature produced no solid")
# 5. 与当前主体做布尔操作:
if "cut" in node.atomic_id:
# 切除类特征:要求已有主体,从主体上减去工具体(cut)。
if session.body is None:
raise ValueError("cut feature has no body")
body = session.adapter.cut(session.body, tool)
members = {node.feature_id: body}
elif node.params.get("result_mode") == "new_body":
# FeatureScript NEW creates an independent result body even when it
# intersects a prior body. Keep both shapes in the exported compound.
body = session.adapter.combine(session.body, tool)
members = {**session.body_members, node.feature_id: tool}
else:
# 添加类特征:将工具体并到当前主体上(fuse),首个特征时 body 为 None 也能直接成立。
body = session.adapter.fuse(session.body, tool)
members = {node.feature_id: body}
# 6. 登记新主体(更新拓扑、记录重放定义),并返回该特征的结果对象。
session.register_body(node.feature_id, body, replay_node=node, body_members=members)
return session.result(node)
tool = None
for solid in (session.adapter.revolve(face, angle, axis) for face in faces):
tool = session.adapter.fuse(tool, solid)
if tool is None:
raise ValueError("revolve produced no solid")
return _apply_primary_tool(
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
)
def _execute_extrude_from_face(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
resolved = [session.resolve(selector) for selector in node.selectors]
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face":
raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "derived profile face is unresolved")
face = resolved[0].record.value
tool, topology_delta = _extruded_tool(node, [face], session.adapter.face_normal(face), session)
return _apply_primary_tool(
node, session, tool, cutting=node.params.get("operation") == "cut", topology_delta=topology_delta,
)
def _execute_revolve_surface(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
@@ -670,22 +789,24 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: ExecutionSession) ->
# booleanBodies 总是作用于 source feature 的明确 body 输出,不能回退为
# 当前聚合 body。这样相邻独立实体不会意外成为工具或目标。
params = node.params
target_ids = [str(value) for value in params.get("target_feature_ids") or []]
tool_ids = [str(value) for value in params.get("tool_feature_ids") or []]
missing = [feature_id for feature_id in target_ids + tool_ids if feature_id not in session.body_members]
if missing:
raise ValueError("booleanBodies source bodies are unavailable: " + ", ".join(missing))
target_ids = _member_sources(
node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs",
)
tool_ids = _member_sources(
node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs",
)
targets = {feature_id: session.body_members[feature_id] for feature_id in target_ids}
tools = {feature_id: session.body_members[feature_id] for feature_id in tool_ids}
target = _combine_members(session, targets)
tool = _combine_members(session, tools)
operation = str(params.get("operation") or "")
topology_delta: TopologyDelta | None = None
if operation == "union":
result = session.adapter.fuse(target, tool)
result, topology_delta = session.adapter.fuse_with_topology_delta(target, tool)
elif operation == "subtract":
result = session.adapter.cut(target, tool)
result, topology_delta = session.adapter.cut_with_topology_delta(target, tool)
elif operation == "intersect":
result = session.adapter.intersect(target, tool)
result, topology_delta = session.adapter.intersect_with_topology_delta(target, tool)
else:
raise ValueError(f"unsupported booleanBodies operation {operation!r}")
members = {
@@ -696,7 +817,147 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: ExecutionSession) ->
members[node.feature_id] = result
if bool(params.get("keep_tools")):
members.update(tools)
session.register_body(node.feature_id, _combine_members(session, members), body_members=members)
session.register_body(
node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta,
)
return session.result(node)
def _pattern_instance_sources(
node: FeaturePlanNode,
session: ExecutionSession,
parameter: str = "pattern_instance_refs",
) -> list[str]:
"""Resolve CDSL pattern-instance refs to their internal body-member keys."""
resolved: list[str] = []
for reference in node.params.get(parameter) or ():
if not isinstance(reference, dict):
raise ValueError("pattern instance reference must be an object")
pattern_id = str(reference.get("pattern_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
instance = reference.get("instance_index")
if not pattern_id or not source_id or not isinstance(instance, int):
raise ValueError("pattern instance reference is incomplete")
pattern = session.nodes.get(pattern_id)
if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}:
raise ValueError(f"pattern instance owner is unavailable: {pattern_id}")
params = pattern.params
if source_id not in {str(value) for value in params.get("source_feature_ids") or ()}:
raise ValueError("pattern instance source is not selected by its pattern")
count = int(params.get("pattern_count") or 0)
excluded = {int(value) for value in params.get("excluded_instance_indices") or ()}
if (
pattern.atomic_id == "pattern_mirror" and instance != 1
) or (
pattern.atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded)
):
raise ValueError("pattern instance is outside the pattern's surviving instances")
member_id = pattern_instance_member_id(pattern_id, source_id, instance)
if member_id not in session.body_members:
raise ValueError(f"pattern instance body is unavailable: {pattern_id}/{source_id}/{instance}")
if member_id not in resolved:
resolved.append(member_id)
return resolved
def _transform_copy_sources(node: FeaturePlanNode, session: ExecutionSession) -> list[str]:
"""Resolve source-qualified outputs of preceding multi-body COPY transforms."""
resolved: list[str] = []
for reference in node.params.get("transform_copy_refs") or ():
if not isinstance(reference, dict):
raise ValueError("transform COPY reference must be an object")
transform_id = str(reference.get("transform_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
if not transform_id or not source_id:
raise ValueError("transform COPY reference is incomplete")
transform = session.nodes.get(transform_id)
params = transform.params if transform is not None else {}
sources = params.get("source_feature_ids") or []
if (
transform is None
or transform.atomic_id != "transform_bodies"
or not bool(params.get("make_copy"))
or not isinstance(sources, list)
or len(sources) < 2
or source_id not in {str(value) for value in sources}
):
raise ValueError(f"transform COPY owner/source is unavailable: {transform_id}/{source_id}")
member_id = transform_copy_member_id(transform_id, source_id)
if member_id not in session.body_members:
raise ValueError(f"transform COPY body is unavailable: {transform_id}/{source_id}")
if member_id not in resolved:
resolved.append(member_id)
return resolved
def _member_sources(
node: FeaturePlanNode,
session: ExecutionSession,
parameter: str,
*,
pattern_instance_parameter: str | None = None,
allow_transform_copies: bool = False,
) -> list[str]:
source_ids = [str(value) for value in node.params.get(parameter) or []]
if pattern_instance_parameter is not None:
source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter))
if allow_transform_copies:
source_ids.extend(_transform_copy_sources(node, session))
if not source_ids:
raise ValueError(f"{node.atomic_id} requires explicit {parameter}")
missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members]
if missing:
raise ValueError(f"{node.atomic_id} source bodies are unavailable: " + ", ".join(missing))
return source_ids
def _execute_transform_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# FeatureScript transform targets explicit bodies. Do not move the
# aggregate session body, because it may include unrelated members.
source_ids = _member_sources(
node, session, "source_feature_ids", pattern_instance_parameter="pattern_instance_refs", allow_transform_copies=True,
)
make_copy = bool(node.params.get("make_copy"))
direct_sources = node.params.get("source_feature_ids") or []
if make_copy and isinstance(direct_sources, list) and len(direct_sources) > 1:
# The aggregate is only an export compound. Each source transform has
# its own B-rep builder and is the only output a later COPY query may
# select. Do not attach an aggregate topology delta to source members.
members = dict(session.body_members)
members.update({
transform_copy_member_id(node.feature_id, source_id): session.adapter.transform(
session.body_members[source_id], dict(node.params.get("transform") or {}),
)
for source_id in source_ids
})
session.register_body(
node.feature_id, _combine_members(session, members), body_members=members,
)
return session.result(node)
source = _combine_members(session, {feature_id: session.body_members[feature_id] for feature_id in source_ids})
transformed, topology_delta = session.adapter.transform_with_topology_delta(
source, dict(node.params.get("transform") or {}),
)
members = dict(session.body_members)
if not make_copy:
for feature_id in source_ids:
members.pop(feature_id)
members[node.feature_id] = transformed
session.register_body(
node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta,
)
return session.result(node)
def _execute_delete_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# Deletion is a body-graph operation, never a Boolean subtraction. A
# selected member can be disjoint or overlap another independent body.
source_ids = _member_sources(node, session, "target_feature_ids")
members = {feature_id: body for feature_id, body in session.body_members.items() if feature_id not in set(source_ids)}
if members:
session.register_body(node.feature_id, _combine_members(session, members), body_members=members)
else:
session.clear_body()
return session.result(node)
@@ -710,8 +971,14 @@ def _execute_loft_add(node: FeaturePlanNode, session: ExecutionSession) -> Featu
if sketch is None:
raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved")
profiles.append(sketch)
solid = session.adapter.loft(profiles)
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
solid, topology_delta = session.adapter.loft_with_topology_delta(profiles)
body = session.adapter.fuse(session.body, solid)
# Fusing a loft into an existing body replaces its subshapes through a
# different builder. Only an initial direct loft can expose this builder's
# cap evidence for the final B-rep snapshot.
if session.body is not None:
topology_delta = None
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
return session.result(node)
@@ -779,12 +1046,18 @@ def _execute_sweep_add(node: FeaturePlanNode, session: ExecutionSession, sketch:
faces = session.adapter.faces_for_sketch(profile)
if len(faces) != 1:
raise ValueError("sweep requires exactly one closed profile region")
solid = session.adapter.sweep(
solid, topology_delta = session.adapter.sweep_with_topology_delta(
faces[0], _sweep_path(node, session),
is_frenet=bool(node.params.get("is_frenet", False)),
)
body = session.adapter.combine(session.body, solid) if node.params.get("result_mode") == "new_body" else session.adapter.fuse(session.body, solid)
session.register_body(node.feature_id, body, replay_node=node)
is_new_body = node.params.get("result_mode") == "new_body"
body = session.adapter.combine(session.body, solid) if is_new_body else session.adapter.fuse(session.body, solid)
# A union rebuilds topology, so the pipe-shell builder cannot prove the
# final aggregate's relations. The independent-body path retains its exact
# subshape identity and may expose evidence for the new member.
if session.body is not None and not is_new_body:
topology_delta = None
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
return session.result(node)
@@ -1148,19 +1421,33 @@ def _shell_target(node: FeaturePlanNode, session: ExecutionSession) -> tuple[Any
target_id = next(iter(target_ids))
members = session.adapter.body_solids(session.body)
if len(members) == 1:
return members[0], [record.value for record in records]
if target_id is None or session.body_id is None:
raise ValueError("shell target body is unresolved")
prefix = f"{session.body_id}:"
if not target_id.startswith(prefix):
raise ValueError("shell target body is outside the active body set")
try:
member_index = int(target_id[len(prefix):])
except ValueError as error:
raise ValueError("shell target body has an invalid member id") from error
if member_index < 0 or member_index >= len(members):
raise ValueError("shell target body member is unavailable")
return members[member_index], [record.value for record in records]
target = members[0]
else:
if target_id is None or session.body_id is None:
raise ValueError("shell target body is unresolved")
prefix = f"{session.body_id}:"
if not target_id.startswith(prefix):
raise ValueError("shell target body is outside the active body set")
try:
member_index = int(target_id[len(prefix):])
except ValueError as error:
raise ValueError("shell target body has an invalid member id") from error
if member_index < 0 or member_index >= len(members):
raise ValueError("shell target body member is unavailable")
target = members[member_index]
target_feature_id = node.params.get("target_feature_id")
if target_feature_id is not None:
if not isinstance(target_feature_id, str) or not target_feature_id:
raise ValueError("shell target_feature_id is invalid")
declared = session.body_members.get(target_feature_id)
if declared is None:
raise ValueError("shell target body is no longer an independently selectable member")
declared_solids = session.adapter.body_solids(declared)
if len(declared_solids) != 1:
raise ValueError("shell target body must resolve to exactly one active solid")
if not declared_solids[0].is_same(target):
raise ValueError("shell target body does not match the resolved face member")
return target, [record.value for record in records]
def _replace_shell_target(session: ExecutionSession, target: Any, replacement: Any) -> Any:
@@ -1189,8 +1476,13 @@ def _execute_shell(node: FeaturePlanNode, session: ExecutionSession) -> FeatureR
if thickness <= 0:
raise ValueError("shell thickness_mm must be > 0")
target, faces = _shell_target(node, session)
result = session.adapter.shell(target, faces, thickness, inward=bool(node.params.get("inward", True)))
session.register_body(node.feature_id, _replace_shell_target(session, target, result), replay_node=node)
result, topology_delta = session.adapter.shell_with_topology_delta(
target, faces, thickness, inward=bool(node.params.get("inward", True)),
)
session.register_body(
node.feature_id, _replace_shell_target(session, target, result), replay_node=node,
topology_delta=topology_delta,
)
return session.result(node)
@@ -1205,11 +1497,11 @@ def _execute_fillet(node: FeaturePlanNode, session: ExecutionSession) -> Feature
if radius <= 0:
raise ValueError("fillet radius_mm must be > 0")
# 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。
body = session.adapter.fillet(
body, topology_delta = session.adapter.fillet_with_topology_delta(
session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
)
# 4. 登记新主体并返回结果。
session.register_body(node.feature_id, body, replay_node=node)
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
return session.result(node)
@@ -1235,8 +1527,9 @@ def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> Featur
# 4. 解析目标边(支持相切传播),执行倒角。
edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation")))
diagnostics: list[RuntimeDiagnostic] = []
topology_delta: TopologyDelta | None = None
try:
body = session.adapter.chamfer(session.body, distance, distance_2, edges)
body, topology_delta = session.adapter.chamfer_with_topology_delta(session.body, distance, distance_2, edges)
except ValueError as error:
# 显式 surfaceEntities 可以在后续实体上留下曲面分区边界。若标准
# OCC 倒角因环域宽度不足而拒绝,只允许在该 shell 给出同轴边界证据
@@ -1256,7 +1549,7 @@ def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> Featur
detail={"distance_mm": distance, "surface_count": len(session.surface_members)},
))
# 5. 登记新主体并返回结果。
session.register_body(node.feature_id, body, replay_node=node)
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
return session.result(node, diagnostics=diagnostics)
@@ -1604,6 +1897,31 @@ def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) ->
resolution = session.resolve(mirror)
if resolution.status != "resolved" or not isinstance(resolution.record.value, PlaneSpec):
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "mirror plane was not resolved")
source_ids = [str(value) for value in node.params.get("source_feature_ids") or ()]
if (
source_ids
and all(source_id in session.body_members for source_id in source_ids)
and all(
(source := session.nodes.get(source_id)) is not None
and source.params.get("result_mode") == "new_body"
for source_id in source_ids
)
):
# Only a direct NEW body has a standalone source identity after a
# mirror. A hole, dress-up, or ordinary additive source is merely an
# aggregate successor and must use the feature-replay path below.
# Keeping this condition identical to capability preflight prevents a
# downstream COPY body query from selecting an arbitrary aggregate.
members = dict(session.body_members)
body = session.body
for source_id in source_ids:
mirrored = session.adapter.mirror(session.body_members[source_id], resolution.record.value)
members[pattern_instance_member_id(node.feature_id, source_id, 1)] = mirrored
body = session.adapter.fuse(body, mirrored)
if body is None:
raise ValueError("mirror pattern produced no body")
session.register_body(node.feature_id, body, replay_node=node, body_members=members)
return session.result(node)
if node.params.get("mirror_current_body"):
# CADFS SWEPT_BODY 表示被后续 feature 持续修改的同一实体。这里复制
# 当前 B-rep 再镜像并合并,不能重放其初始 additive feature,否则会
@@ -1809,6 +2127,108 @@ def _pattern_operation_node(node: FeaturePlanNode, operation_mode: str) -> Featu
)
def _circular_source_is_axisymmetric(node: FeaturePlanNode, session: ExecutionSession, axis: AxisSpec) -> bool:
"""Whether rotating a direct circular extrusion creates no new geometry."""
if node.atomic_id not in {"extrude_add_blind", "extrude_add_two_sided"}:
return False
sketch = session.sketches.get(str(node.sketch_id))
if sketch is None:
return False
profile = sketch.get("profile") or {}
circle = profile if profile.get("type") == "circle" else None
if circle is None:
contours = profile.get("contours") or []
segments = (contours[0] or {}).get("segments") if len(contours) == 1 else []
circle = segments[0] if isinstance(segments, list) and len(segments) == 1 and segments[0].get("type") == "circle" else None
center = (circle or {}).get("center")
if not isinstance(center, list) or len(center) != 2:
return False
try:
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
except (TypeError, ValueError):
return False
if abs(vector_dot(plane.normal, axis.direction)) < 1 - 1e-7:
return False
world_center = vector_add(
plane.origin_mm,
vector_add(vector_scale(plane.x_dir, float(center[0])), vector_scale(plane.y_dir, float(center[1]))),
)
offset = vector_subtract(world_center, axis.origin_mm)
radial = vector_subtract(offset, vector_scale(axis.direction, vector_dot(offset, axis.direction)))
return math.sqrt(vector_dot(radial, radial)) <= 1e-6
def _advance_copy_topology_records(
records: list[TopologyRecord], topology_delta: TopologyDelta | None,
) -> list[TopologyRecord]:
"""Carry COPY provenance through one exact adapter-history operation.
Pattern copies are separate CDSL results even when their solids fuse into
a single final body. The temporary records here are never selector
candidates themselves. They only retain instance ownership while opaque
OCC history proves a unique subshape continuation to the final snapshot.
"""
if topology_delta is None:
return []
advanced: list[TopologyRecord] = []
for record in records:
values: list[Any] = []
for relation in topology_delta.relations:
if (
relation.kind != record.kind
or relation.event not in {"preserved", "modified"}
or not TopologyRegistry._same_topology_value(record.value, relation.source_value)
):
continue
for value in relation.result_values:
if not any(TopologyRegistry._same_topology_value(value, known) for known in values):
values.append(value)
# A split/merge has no unique COPY owner in the present selector
# contract. Keep the executable model, but do not make a claim that a
# later COPY selector can bind one arbitrary descendant.
if len(values) != 1:
continue
advanced.append(TopologyRecord(
record_id=record.record_id,
kind=record.kind,
feature_id=record.feature_id,
body_id=record.body_id,
geometry=dict(record.geometry),
value=values[0],
owner_feature_ids=record.owners,
output_roles=record.output_roles,
output_role_sources=record.output_role_sources,
))
return advanced
def _copy_snapshot_topology_delta(records: list[TopologyRecord]) -> TopologyDelta | None:
"""Bridge traced final COPY handles into the one registered body snapshot."""
if not records:
return None
return TopologyDelta(
operation="pattern_circular_copy_snapshot",
relations=tuple(
# ``record.value`` has already passed through every transform/fuse
# builder in this pattern and is an actual final-B-rep handle. The
# identity relation merely connects that evidence to the fresh
# adapter snapshot; it is not a geometric rebinding shortcut.
TopologyDeltaRelation("preserved", record.kind, record.value, (record.value,))
for record in records
),
)
def _has_usable_pattern_body(session: ExecutionSession, body: Any | None) -> bool:
"""Reject a formally valid but empty OCC boolean result before publishing it."""
if body is None or not session.adapter.body_solids(body):
return False
try:
return abs(float(body.volume)) > 1e-12
except (AttributeError, TypeError, ValueError):
return False
def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult:
# 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征
# 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。
@@ -1830,6 +2250,51 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession,
sources = session.replay_sources(params.get("source_feature_ids") or [])
if not sources:
raise ValueError("circular pattern source features have no replay definitions")
source_ids = [source.feature_id for source in sources]
pre_pattern_members = dict(session.body_members)
if operation_mode == "add" and all(source_id in session.body_members for source_id in source_ids):
# A pattern over explicit NEW/kept body members has a stronger contract
# than replay: each copy is an independently addressable rigid image of
# the named source member. Keep the instance keys in the body graph so
# a later CADFS COPY(BODY) transform/delete can name exactly one copy.
members = dict(session.body_members)
body = session.body
traced_copy_records: list[TopologyRecord] = []
for instance in range(1, count):
if instance in excluded:
continue
angle_deg = sweep_angle_deg * instance / count
transform = {
"type": "rotation",
"axis": {"origin_mm": list(axis.origin_mm), "direction": list(axis.direction)},
"angle_deg": angle_deg,
}
for source_id in source_ids:
member_id = pattern_instance_member_id(node.feature_id, source_id, instance)
owner_id = f"{node.feature_id}.c{instance}.{source_id}"
source_body = session.body_members[source_id]
copy, transform_delta = session.adapter.transform_with_topology_delta(source_body, transform)
source_records = session.adapter.topology_records(
source_body, owner_id, f"body:{node.feature_id}:copy:{instance}:{source_id}:source",
)
copy_records = _advance_copy_topology_records(source_records, transform_delta)
members[member_id] = copy
body, fuse_delta = session.adapter.fuse_with_topology_delta(body, copy)
traced_copy_records = _advance_copy_topology_records(
[*traced_copy_records, *copy_records], fuse_delta,
)
if _has_usable_pattern_body(session, body):
session.register_body(
node.feature_id, body, replay_node=node, body_members=members,
topology_delta=_copy_snapshot_topology_delta(traced_copy_records),
topology_predecessors=traced_copy_records,
)
return session.result(node)
# An OCC boolean may report IsDone/valid for an empty result when a
# copied fused body contains coincident internal topology. The normal
# pattern contract can replay the source feature contribution instead;
# it is the only sound fallback because it keeps source operation,
# sketch frame, and body lifecycle semantics intact.
for instance in range(1, count):
if instance in excluded:
continue
@@ -1837,6 +2302,11 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession,
angle_deg = sweep_angle_deg * instance / count
angle_rad = math.radians(angle_deg)
for source in sources:
# 与阵列轴同心、法向平行的圆形实体拉伸在任意环形实例中均与
# 原实体完全重合。重复执行它会把同一 B-rep 再次交给 OCC fuse
# 后续非轴对称 source 可能因此丢失已生成的实体分支。
if _circular_source_is_axisymmetric(source, session, axis):
continue
dependency = pattern_transform_blocker(source)
if dependency:
raise ValueError(f"circular pattern source uses an unsupported {dependency}")
@@ -1864,7 +2334,23 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession,
# selector binding 会只保留最后一个实例的 body id,漏掉其它 COPY 实例。
if session.body is None:
raise ValueError("circular pattern produced no body")
session.register_body(node.feature_id, session.body, replay_node=node)
# Replaying a fused sole-body source may be more robust than copying its
# full aggregate B-rep (for example, when a rotationally invariant base
# would otherwise be unioned with itself). If that replay still has one
# physical body, the direct source remains a proven alias of the current
# member. Preserve it for a following parts-scoped operation such as
# shell; do not extend this alias across multi-body patterns or multiple
# source members.
members = {node.feature_id: session.body}
if (
len(source_ids) == 1
and len(pre_pattern_members) == 1
and source_ids[0] in pre_pattern_members
and _has_usable_pattern_body(session, session.body)
and len(session.adapter.body_solids(session.body)) == 1
):
members[source_ids[0]] = session.body
session.register_body(node.feature_id, session.body, replay_node=node, body_members=members)
return session.result(node)
@@ -2004,6 +2490,7 @@ EXECUTORS: dict[str, ExecutorFunction] = {
"extrude_cut_blind": _primary_executor,
"extrude_cut_two_sided": _primary_executor,
"extrude_cut_through": _primary_executor,
"extrude_from_face": lambda node, session, _sketch: _execute_extrude_from_face(node, session),
"loft_add": _loft_executor,
"loft_add_with_cap_face": _loft_cap_face_executor,
"sweep_add": _sweep_executor,
@@ -2019,6 +2506,8 @@ EXECUTORS: dict[str, ExecutorFunction] = {
"chamfer": _chamfer_executor,
"shell": _shell_executor,
"boolean_bodies": lambda node, session, sketch: _execute_boolean_bodies(node, session),
"transform_bodies": lambda node, session, sketch: _execute_transform_bodies(node, session),
"delete_bodies": lambda node, session, sketch: _execute_delete_bodies(node, session),
"pattern_linear": _linear_pattern_executor,
"pattern_mirror": _mirror_pattern_executor,
"pattern_circular": _circular_pattern_executor,
@@ -2113,5 +2602,6 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
"feature_results": [result.as_dict() for result in session.results.values()],
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics],
"topology_records": [record.public_dict() for record in session.topology.records()],
"topology_deltas": list(session.topology.topology_deltas()),
"selector_resolution": session.selector_resolutions,
}
+387 -10
View File
@@ -15,6 +15,26 @@ from typing import Any, Iterable
Vector3 = tuple[float, float, float]
def pattern_instance_member_id(pattern_feature_id: str, source_feature_id: str, instance_index: int) -> str:
"""Return the runtime-only body-member key for one proven pattern copy.
CDSL keeps the three source fields separately, so callers never need to
manufacture this internal key. The body graph uses the same derivation in
runtime and capability preflight.
"""
return f"pattern:{pattern_feature_id}:{source_feature_id}:copy:{instance_index}"
def transform_copy_member_id(transform_feature_id: str, source_member_id: str) -> str:
"""Return the runtime-only key for one source of a multi-body COPY.
A multi-source ``transform_bodies`` COPY has several independently
addressable outputs. CDSL records the transform and its selected source as
separate fields; the opaque key stays internal to the body graph.
"""
return f"transform:{transform_feature_id}:{source_member_id}:copy"
# y_dir 与 x_dir / normal 点积的绝对值不超过该值时,认为 y_dir 是正交的,
# 予以保留;否则视为偏斜数据,正交化并显式警告。
_Y_DIR_ORTHOGONALITY_TOL = 1e-6
@@ -697,6 +717,14 @@ class TopologyRecord:
geometry: dict[str, Any] = field(default_factory=dict)
value: Any = None
owner_feature_ids: tuple[str, ...] = ()
# Builder-produced roles describe a particular result subshape. They are
# deliberately separate from the geometric signature: equal geometry does
# not prove that two faces have the same feature-output meaning.
output_roles: tuple[str, ...] = ()
# Generated roles may carry the direct feature-output role that the kernel
# operation transformed. This is semantic provenance, not a stable-id
# shortcut: the resolver still requires the exact active result snapshot.
output_role_sources: tuple[tuple[str, str, str], ...] = ()
@property
def owners(self) -> tuple[str, ...]:
@@ -714,9 +742,57 @@ class TopologyRecord:
output["body_id"] = self.body_id
if self.owner_feature_ids:
output["owner_feature_ids"] = list(self.owner_feature_ids)
if self.output_roles:
output["output_roles"] = list(self.output_roles)
if self.output_role_sources:
output["output_role_sources"] = [
{"output_role": role, "owner_feature_id": owner, "source_output_role": source_role}
for role, owner, source_role in self.output_role_sources
]
return output
@dataclass(frozen=True)
class TopologyDeltaRelation:
"""One opaque kernel-history relationship for a topology subshape.
Geometry adapters retain ownership of the values in this structure. They
are intentionally opaque to the runtime: a build123d/OCC adapter may use
``TopoDS_Shape`` values while another adapter can use its native handles.
The registry only asks whether a handle is exactly the same topology item;
it never uses this contract to score nearby geometry.
"""
event: str
kind: str
source_value: Any
result_values: tuple[Any, ...] = ()
output_role: str | None = None
def __post_init__(self) -> None:
if self.event not in {"preserved", "modified", "generated", "deleted"}:
raise ValueError(f"unsupported topology delta event {self.event!r}")
if self.kind not in {"face", "edge", "vertex"}:
raise ValueError(f"unsupported topology delta kind {self.kind!r}")
if self.event == "deleted" and (self.result_values or self.output_role is not None):
raise ValueError("deleted topology delta relations cannot have result values or an output role")
if self.output_role is not None and (not isinstance(self.output_role, str) or not self.output_role):
raise ValueError("topology delta output_role must be a non-empty string when provided")
@dataclass(frozen=True)
class TopologyDelta:
"""Kernel-backed topology history for one adapter operation.
``operation`` is evidence only. The runtime transfers durable provenance
solely from a unique exact relationship, never from an operation name or a
geometric resemblance.
"""
operation: str
relations: tuple[TopologyDeltaRelation, ...] = ()
@dataclass(frozen=True)
class SelectorResolution:
selector: dict[str, Any]
@@ -752,6 +828,7 @@ class TopologyRegistry:
self._records: list[TopologyRecord] = []
self._by_feature: dict[str, list[TopologyRecord]] = {}
self._active_body_id: str | None = None
self._topology_deltas: list[dict[str, Any]] = []
# #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer
# 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何
# 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的
@@ -768,6 +845,10 @@ class TopologyRegistry:
def records(self) -> tuple[TopologyRecord, ...]:
return tuple(self._records)
def topology_deltas(self) -> tuple[dict[str, Any], ...]:
"""Return serializable evidence derived from exact adapter history."""
return tuple(self._topology_deltas)
def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord:
kind = "plane" if isinstance(context, PlaneSpec) else "axis"
record = TopologyRecord(
@@ -782,13 +863,18 @@ class TopologyRegistry:
def replace_body_topology(
self, feature_id: str, body_id: str, records: Iterable[TopologyRecord],
*, active_body_id: str | None = None,
*, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None,
additional_predecessors: Iterable[TopologyRecord] = (),
) -> None:
self.replace_body_topologies(feature_id, [(body_id, records)], active_body_id=active_body_id)
self.replace_body_topologies(
feature_id, [(body_id, records)], active_body_id=active_body_id, topology_delta=topology_delta,
additional_predecessors=additional_predecessors,
)
def replace_body_topologies(
self, feature_id: str, bodies: Iterable[tuple[str, Iterable[TopologyRecord]]],
*, active_body_id: str | None = None,
*, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None,
additional_predecessors: Iterable[TopologyRecord] = (),
) -> None:
"""Record a fresh B-rep snapshot after a feature mutates the body.
@@ -804,7 +890,7 @@ class TopologyRegistry:
mutation's predecessor lookup scoped to every solid of the previous
body, while each member keeps its own ``body:{feature}:{index}`` id.
"""
previous = [
active_previous = [
record for record in self._records
if self._active_body_id is not None and record.body_id is not None
and (
@@ -812,13 +898,32 @@ class TopologyRegistry:
or record.body_id.startswith(f"{self._active_body_id}:")
)
]
# A pattern COPY can carry a chain of exact transform/boolean builder
# histories before its final aggregate snapshot is registered. These
# temporary records are valid predecessors only for that documented
# kernel-history bridge. They are deliberately excluded from geometric
# fallback matching: an equal-looking final face never proves that it
# belongs to one particular copy instance.
transient_previous = list(additional_predecessors)
previous = [*active_previous, *transient_previous]
# 同一 source feature 的 pattern copy 可以产生完全相同的几何面。它们
# 必须保留为多个实例,不能在跨 body 的全局 predecessor 匹配中互相消费。
# pattern 的 Compound 成员顺序是稳定的:已有实例以同一 member index
# 延续,新增实例只会出现在末尾。按该 index 限定后继匹配。
current = [(body_id, list(records)) for body_id, records in bodies]
current_records = [record for _body_id, records in current for record in records]
(
exact_predecessors,
exact_successors,
kernel_covered_predecessors,
exact_output_roles,
exact_output_role_sources,
delta_evidence,
) = self._exact_delta_links(
topology_delta, previous, current_records,
)
previous_member_ids = {
suffix for record in previous
suffix for record in active_previous
for suffix in [str(record.body_id).rsplit(":", 1)[-1]]
if suffix.isdigit()
}
@@ -828,13 +933,36 @@ class TopologyRegistry:
for body_id, records in current:
member_id = str(body_id).rsplit(":", 1)[-1]
local_predecessors = [
record for record in previous
record for record in active_previous
if not use_member_indexes or str(record.body_id).rsplit(":", 1)[-1] == member_id
]
for record in records:
predecessor = self._unique_equivalent_predecessor(record, local_predecessors, consumed_predecessors)
exact_predecessor_id = exact_predecessors.get(record.record_id)
# Member order is a useful isolation boundary for geometric
# fallback matching, especially for coincident pattern
# copies. It is not a provenance boundary when a kernel
# builder explicitly relates one source subshape to one
# result subshape: boolean/delete lifecycle can remove an
# earlier member and shift a surviving source to another
# member index. A unique OCC continuation remains exact
# evidence across that index change.
predecessor = next(
(prior for prior in previous if prior.record_id == exact_predecessor_id),
None,
)
if predecessor is None and record.record_id not in exact_predecessors:
predecessor = self._unique_equivalent_predecessor(record, local_predecessors, consumed_predecessors)
owners = predecessor.owners if predecessor is not None else (feature_id,)
if predecessor is not None:
output_roles = set(record.output_roles)
output_roles.update(exact_output_roles.get(record.record_id, ()))
output_role_sources = set(record.output_role_sources)
output_role_sources.update(exact_output_role_sources.get(record.record_id, ()))
# A feature-output role can survive a later operation only
# through the same unique kernel continuation used for owner
# provenance. Geometry equivalence alone never carries it.
if predecessor is not None and record.record_id in exact_predecessors:
output_roles.update(predecessor.output_roles)
if predecessor is not None and record.record_id not in exact_predecessors:
consumed_predecessors.add(predecessor.record_id)
registered.append(TopologyRecord(
record_id=record.record_id,
@@ -844,16 +972,33 @@ class TopologyRegistry:
geometry=dict(record.geometry),
value=record.value,
owner_feature_ids=owners,
output_roles=tuple(sorted(output_roles)),
output_role_sources=tuple(sorted(output_role_sources)),
))
for record in registered:
self.register(record)
for predecessor_id, successor_ids in exact_successors.items():
known = self._successors.setdefault(predecessor_id, [])
for successor_id in successor_ids:
if successor_id not in known:
known.append(successor_id)
if delta_evidence is not None:
self._topology_deltas.append({
"feature_id": feature_id,
"operation": topology_delta.operation,
"relations": delta_evidence,
})
# #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续
# selector 的 stable_id 引用解析到 active body 内的新形态。多条演化
# 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段
# 也有圆角过渡带的新边,前者的端点与原边重合、漂移更小);漂移并列
# (如竖直边被完整消费成两条等距直段)属于本质歧义,保守不登记。
for prior in previous:
if prior.record_id in consumed_predecessors:
if (
prior.record_id in consumed_predecessors
or prior.record_id in exact_successors
or prior.record_id in kernel_covered_predecessors
):
continue
candidates = sorted(
(
@@ -869,6 +1014,126 @@ class TopologyRegistry:
self._successors[prior.record_id] = [best[1]]
self._active_body_id = active_body_id or body_id
@staticmethod
def _same_topology_value(left: Any, right: Any) -> bool:
"""Compare adapter handles only through their exact topology identity."""
left_value = getattr(left, "wrapped", left)
right_value = getattr(right, "wrapped", right)
if left_value is right_value:
return True
for candidate, other in ((left_value, right_value), (right_value, left_value)):
for method_name in ("IsSame", "is_same"):
method = getattr(candidate, method_name, None)
if callable(method):
try:
return bool(method(other))
except (AttributeError, TypeError, ValueError):
continue
return False
@classmethod
def _exact_delta_links(
cls,
topology_delta: TopologyDelta | None,
previous: list[TopologyRecord],
current: list[TopologyRecord],
) -> tuple[
dict[str, str],
dict[str, list[str]],
set[str],
dict[str, tuple[str, ...]],
dict[str, tuple[tuple[str, str, str], ...]],
list[dict[str, Any]] | None,
]:
"""Bind opaque kernel history to snapshots without geometric guessing.
Ownership transfer is intentionally limited to a single source item and
a single output item. Split/merge history remains useful evidence, but
has no unique owner continuation until a later operation-specific
contract can express it.
"""
if topology_delta is None:
return {}, {}, set(), {}, {}, None
candidate_sources: dict[str, set[str]] = {}
kernel_covered_predecessors: set[str] = set()
output_roles: dict[str, set[str]] = {}
output_role_sources: dict[str, set[tuple[str, str, str]]] = {}
relation_links: list[tuple[str, str] | None] = []
evidence: list[dict[str, Any]] = []
for relation in topology_delta.relations:
sources = [
record for record in previous
if record.kind == relation.kind and cls._same_topology_value(record.value, relation.source_value)
]
outputs = [
record for record in current
if record.kind == relation.kind
and any(cls._same_topology_value(record.value, value) for value in relation.result_values)
]
item = {
"event": relation.event,
"kind": relation.kind,
"source_record_ids": [record.record_id for record in sources],
"result_record_ids": [record.record_id for record in outputs],
"proof": "kernel_history",
}
if relation.output_role is not None:
item["output_role"] = relation.output_role
role_is_unique = len(relation.result_values) == 1 and len(outputs) == 1
item["output_role_status"] = (
"unique_result_snapshot" if role_is_unique else "non_unique_or_missing_result_snapshot"
)
if role_is_unique:
output_roles.setdefault(outputs[0].record_id, set()).add(relation.output_role)
for source in sources:
for source_role in source.output_roles:
for source_owner in source.owners:
output_role_sources.setdefault(outputs[0].record_id, set()).add(
(relation.output_role, source_owner, source_role)
)
if len(sources) == 1:
kernel_covered_predecessors.add(sources[0].record_id)
can_transfer = (
relation.event in {"preserved", "modified"}
and len(relation.result_values) == 1
and len(sources) == 1
and len(outputs) == 1
)
if can_transfer:
source_id, result_id = sources[0].record_id, outputs[0].record_id
candidate_sources.setdefault(result_id, set()).add(source_id)
relation_links.append((source_id, result_id))
else:
relation_links.append(None)
evidence.append(item)
predecessors = {
result_id: next(iter(source_ids))
for result_id, source_ids in candidate_sources.items()
if len(source_ids) == 1
}
successors: dict[str, list[str]] = {}
for result_id, source_id in predecessors.items():
successors.setdefault(source_id, []).append(result_id)
for item, relation, link in zip(evidence, topology_delta.relations, relation_links):
if link is not None:
_source_id, result_id = link
item["status"] = (
"unique_exact_continuation"
if len(candidate_sources[result_id]) == 1 else "ambiguous_exact_continuation"
)
elif relation.event in {"preserved", "modified"}:
item["status"] = "non_unique_or_incomplete"
else:
item["status"] = "recorded_without_owner_transfer"
return (
predecessors,
successors,
kernel_covered_predecessors,
{record_id: tuple(sorted(roles)) for record_id, roles in output_roles.items()},
{record_id: tuple(sorted(sources)) for record_id, sources in output_role_sources.items()},
evidence,
)
@staticmethod
def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool:
try:
@@ -1010,7 +1275,7 @@ class TopologyRegistry:
if not selector_geometry:
return 0.0
scores: list[float] = []
for key in ("center_mm", "normal", "origin_mm", "direction", "plane_normal", "start_mm", "end_mm"):
for key in ("center_mm", "circle_center_mm", "normal", "origin_mm", "direction", "plane_normal", "start_mm", "end_mm"):
if key in selector_geometry:
score = cls._vector_score(selector_geometry[key], record_geometry.get(key))
if score is None:
@@ -1040,6 +1305,12 @@ class TopologyRegistry:
except (TypeError, ValueError):
return None
scores.append(max(0.0, 1.0 - delta / 1e-4))
if "radius_mm" in selector_geometry:
try:
delta = abs(float(selector_geometry["radius_mm"]) - float(record_geometry.get("radius_mm")))
except (TypeError, ValueError):
return None
scores.append(max(0.0, 1.0 - delta / 1e-4))
if "area_mm2" in selector_geometry:
try:
expected_area = float(selector_geometry["area_mm2"])
@@ -1116,6 +1387,101 @@ class TopologyRegistry:
detail={"minimum_score": minimum_score},
),
)
output_role = str(selector.get("output_role") or "").strip()
if output_role:
if not owner:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_output_role_owner_required",
message="A feature output role selector requires owner_feature_id",
detail={"output_role": output_role},
),
)
if active_body_id is None:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_output_role_active_body_required",
message="A feature output role selector requires an active body snapshot",
detail={"output_role": output_role},
),
)
if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")):
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_output_role_mixed_evidence",
message="A feature output role selector cannot mix stable or geometry evidence",
detail={"output_role": output_role},
),
)
role_candidates = [record for record in candidates if output_role in record.output_roles]
role_source = selector.get("output_role_source")
if role_source is not None:
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
if not isinstance(source_owner, str) or not isinstance(source_role, str):
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_output_role_source_invalid",
message="An output role selector source requires owner_feature_id and output_role",
),
)
if output_role != "shell.offset_face" or source_role not in {"extrude.start", "extrude.end"}:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_output_role_source_unsupported",
message="Output role sources are currently supported only for shell.offset_face from an extrusion cap",
),
)
role_candidates = [
record for record in role_candidates
if (output_role, source_owner, source_role) in record.output_role_sources
]
public_candidates = tuple(
{"score": 1.0, **record.public_dict()} for record in role_candidates
)
if len(role_candidates) == 1:
return SelectorResolution(
selector=selector,
status="resolved",
record=role_candidates[0],
candidates=public_candidates,
)
if len(role_candidates) > 1:
return SelectorResolution(
selector=selector,
status="ambiguous",
candidates=public_candidates,
diagnostic=RuntimeDiagnostic(
code="selector_output_role_ambiguous",
message="More than one active topology record has the requested output role",
detail={"output_role": output_role, "candidate_count": len(role_candidates)},
),
)
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_output_role_not_found",
message="No active topology record has the requested output role",
detail={"output_role": output_role, "candidate_count": 0},
),
)
stable_id = str(selector.get("stable_id") or "").strip()
if stable_id:
# #8 selector 持久性:stable_id 是跨 body 演化的持久标识符,精确
@@ -1180,6 +1546,17 @@ class TopologyRegistry:
record=record,
candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},),
)
if not geometry:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_stable_id_inactive",
message="The stable selector record is not active and has no geometry signature for rebinding",
detail={"stable_id": stable_id},
),
)
if len(exact) > 1:
return SelectorResolution(
selector=selector,
@@ -15,10 +15,23 @@ from typing import Any
from jsonschema import Draft202012Validator
from .operation_contracts import materialized_feature_contracts
_ID = re.compile(r"^[A-Za-z0-9_-]{1,80}$")
def _mappings(value: Any):
"""Yield nested mapping values without treating selector-like data as text."""
if isinstance(value, dict):
yield value
for child in value.values():
yield from _mappings(child)
elif isinstance(value, list):
for child in value:
yield from _mappings(child)
@lru_cache(maxsize=1)
def _schema() -> dict[str, Any]:
path = Path(__file__).with_name("cdsl_schema.json")
@@ -32,6 +45,12 @@ def _validator() -> Draft202012Validator:
return Draft202012Validator(_schema())
@lru_cache(maxsize=1)
def _operation_contracts() -> dict[str, dict[str, Any]]:
path = Path(__file__).with_name("profile_schema.json")
return materialized_feature_contracts(json.loads(path.read_text(encoding="utf-8")))
def _schema_error(document: dict[str, Any]) -> str | None:
validator = _validator()
errors = sorted(validator.iter_errors(document), key=lambda error: (list(error.absolute_path), error.message))
@@ -67,10 +86,29 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
sketch_ids = {str(sketch.get("id") or "") for sketch in sketches}
if len(sketch_ids) != len(sketches) or not all(_ID.fullmatch(item) for item in sketch_ids):
raise ValueError("Sketch ids must be unique valid CDSL identifiers")
for sketch in sketches:
profile = sketch.get("profile") or {}
if profile.get("type") != "planar_imprint":
continue
entities = profile.get("source_entities") or []
entity_ids = [str(entity.get("id") or "") for entity in entities if isinstance(entity, dict)]
if len(entity_ids) != len(entities) or len(set(entity_ids)) != len(entity_ids) or not all(entity_ids):
raise ValueError(f"Sketch {sketch.get('id')} planar_imprint source entity ids must be unique")
known = set(entity_ids)
for index, selection in enumerate(profile.get("selections") or []):
source = str(selection.get("source_entity_id") or "")
fragment = selection.get("fragment") or {}
anchor = str(fragment.get("anchor_entity_id") or "") if fragment else ""
if source not in known:
raise ValueError(f"Sketch {sketch.get('id')} planar_imprint selection {index} has an unknown source entity")
if fragment and (anchor not in known or anchor == source):
raise ValueError(f"Sketch {sketch.get('id')} planar_imprint selection {index} has an invalid fragment anchor")
feature_ids: set[str] = set()
preceding_features: dict[str, dict[str, Any]] = {}
deferred: list[str] = []
unresolved: list[dict[str, Any]] = []
contracts = _operation_contracts()
for feature in cdsl.get("features") or []:
fid = str(feature.get("id") or "")
if not _ID.fullmatch(fid) or fid in feature_ids:
@@ -85,14 +123,166 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
raise ValueError(f"Feature {fid} must declare execution_status")
if feature.get("execution_status") == "deferred":
deferred.append(fid)
for index, selector in enumerate(feature.get("selectors") or []):
contract = contracts.get(str(feature.get("atomic_id") or "")) or {}
feature_selectors = feature.get("selectors") or []
output_role_selector_ids = {id(selector) for selector in feature_selectors if isinstance(selector, dict)}
for index, selector in enumerate(feature_selectors):
owner = selector.get("owner_feature_id")
binding_owner = selector.get("binding_feature_id")
if owner is not None and owner not in feature_ids and binding_owner not in feature_ids:
raise ValueError(f"Feature {fid} selector {index} has a forward or missing owner_feature_id")
if selector.get("output_role") is not None:
if contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face":
raise ValueError(f"Feature {fid} selector {index} cannot consume a feature output role")
if selector.get("kind") != "face" or not owner or owner not in feature_ids:
raise ValueError(f"Feature {fid} selector {index} output role requires a preceding face owner_feature_id")
if selector.get("source") != "runtime_snapshot":
raise ValueError(f"Feature {fid} selector {index} output role must use runtime_snapshot evidence")
if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")):
raise ValueError(f"Feature {fid} selector {index} output role cannot mix stable or geometry evidence")
role_source = selector.get("output_role_source")
if role_source is not None:
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
source_feature = preceding_features.get(str(source_owner or ""))
source_params = (source_feature or {}).get("params") or {}
if not source_owner or source_owner not in feature_ids:
raise ValueError(f"Feature {fid} selector {index} output role source requires a preceding owner_feature_id")
if selector.get("output_role") != "shell.offset_face" or feature.get("atomic_id") != "shell":
raise ValueError(
f"Feature {fid} selector {index} output role source is only supported for shell.offset_face"
)
if source_role not in {"extrude.start", "extrude.end"}:
raise ValueError(
f"Feature {fid} selector {index} shell.offset_face source must be an extrude cap role"
)
if (
source_feature is None
or source_feature.get("atomic_id") != "extrude_add_blind"
or source_params.get("result_mode") != "new_body"
or (source_params.get("end_condition") or {}).get("type") != "blind"
):
raise ValueError(
f"Feature {fid} selector {index} shell.offset_face source requires a preceding direct new_body blind extrusion"
)
elif selector.get("output_role_source") is not None:
raise ValueError(f"Feature {fid} selector {index} output role source requires output_role")
for selector in _mappings(feature):
# ``output_role_source`` is provenance metadata nested inside a
# feature selector, not a selector on its own.
if (
selector.get("output_role") is not None
and selector.get("kind") is not None
and id(selector) not in output_role_selector_ids
):
raise ValueError(f"Feature {fid} output role selectors are only supported in feature.selectors")
if feature.get("atomic_id") == "shell":
target_feature_id = (feature.get("params") or {}).get("target_feature_id")
if target_feature_id is not None and target_feature_id not in feature_ids:
raise ValueError(f"Feature {fid} shell target_feature_id requires a preceding body feature")
if feature.get("atomic_id") == "transform_bodies":
params = feature.get("params") or {}
references = params.get("pattern_instance_refs") or []
for index, reference in enumerate(references):
if not isinstance(reference, dict):
raise ValueError(f"Feature {fid} pattern instance reference {index} is invalid")
pattern_id = str(reference.get("pattern_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
pattern = preceding_features.get(pattern_id)
pattern_atomic_id = pattern.get("atomic_id") if pattern is not None else None
if pattern is None or pattern_atomic_id not in {"pattern_circular", "pattern_mirror"}:
raise ValueError(f"Feature {fid} pattern instance reference {index} has a forward or unsupported pattern owner")
if source_id not in {str(value) for value in (pattern.get("params") or {}).get("source_feature_ids") or ()}:
raise ValueError(f"Feature {fid} pattern instance reference {index} names a source outside its pattern")
source = preceding_features.get(source_id)
if (
pattern_atomic_id == "pattern_mirror"
and (source is None or (source.get("params") or {}).get("result_mode") != "new_body")
):
raise ValueError(
f"Feature {fid} pattern instance reference {index} requires a preceding new_body mirror source"
)
instance = reference.get("instance_index")
count = int((pattern.get("params") or {}).get("pattern_count") or 0)
excluded = {int(value) for value in (pattern.get("params") or {}).get("excluded_instance_indices") or ()}
if not isinstance(instance, int) or (
pattern_atomic_id == "pattern_mirror" and instance != 1
) or (
pattern_atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded)
):
raise ValueError(f"Feature {fid} pattern instance reference {index} is not a surviving copy")
copy_references = params.get("transform_copy_refs") or []
for index, reference in enumerate(copy_references):
if not isinstance(reference, dict):
raise ValueError(f"Feature {fid} transform COPY reference {index} is invalid")
transform_id = str(reference.get("transform_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
transform = preceding_features.get(transform_id)
transform_params = (transform or {}).get("params") or {}
direct_sources = transform_params.get("source_feature_ids") or []
if (
transform is None
or transform.get("atomic_id") != "transform_bodies"
or not bool(transform_params.get("make_copy"))
or not isinstance(direct_sources, list)
or len(direct_sources) < 2
):
raise ValueError(
f"Feature {fid} transform COPY reference {index} requires a preceding multi-source make_copy transform"
)
if source_id not in {str(value) for value in direct_sources}:
raise ValueError(
f"Feature {fid} transform COPY reference {index} names a source outside its transform"
)
for source_id in params.get("source_feature_ids") or []:
source = preceding_features.get(str(source_id))
source_params = (source or {}).get("params") or {}
source_direct_ids = source_params.get("source_feature_ids") or []
if (
source is not None
and source.get("atomic_id") == "transform_bodies"
and bool(source_params.get("make_copy"))
and isinstance(source_direct_ids, list)
and len(source_direct_ids) > 1
):
raise ValueError(
f"Feature {fid} must use transform_copy_refs to select a source of multi-source COPY {source_id}"
)
if feature.get("atomic_id") == "boolean_bodies":
params = feature.get("params") or {}
for parameter in ("target_pattern_instance_refs", "tool_pattern_instance_refs"):
for index, reference in enumerate(params.get(parameter) or []):
if not isinstance(reference, dict):
raise ValueError(f"Feature {fid} {parameter} entry {index} is invalid")
pattern_id = str(reference.get("pattern_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
pattern = preceding_features.get(pattern_id)
pattern_atomic_id = pattern.get("atomic_id") if pattern is not None else None
if pattern is None or pattern_atomic_id not in {"pattern_circular", "pattern_mirror"}:
raise ValueError(f"Feature {fid} {parameter} entry {index} has a forward or unsupported pattern owner")
if source_id not in {str(value) for value in (pattern.get("params") or {}).get("source_feature_ids") or ()}:
raise ValueError(f"Feature {fid} {parameter} entry {index} names a source outside its pattern")
source = preceding_features.get(source_id)
if (
pattern_atomic_id == "pattern_mirror"
and (source is None or (source.get("params") or {}).get("result_mode") != "new_body")
):
raise ValueError(
f"Feature {fid} {parameter} entry {index} requires a preceding new_body mirror source"
)
instance = reference.get("instance_index")
count = int((pattern.get("params") or {}).get("pattern_count") or 0)
excluded = {int(value) for value in (pattern.get("params") or {}).get("excluded_instance_indices") or []}
if not isinstance(instance, int) or (
pattern_atomic_id == "pattern_mirror" and instance != 1
) or (
pattern_atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded)
):
raise ValueError(f"Feature {fid} {parameter} entry {index} is not a surviving copy")
if feature.get("unresolved"):
unresolved.append({"feature_id": fid, "reasons": list(feature["unresolved"])})
feature_ids.add(fid)
preceding_features[fid] = feature
return {
"schema_version": version,
+120 -7
View File
@@ -82,6 +82,12 @@ def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]:
normal = workplane.get("normal") or [0, 0, 1]
for edge in contours:
output = deepcopy(edge)
if edge["type"] == "circle":
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
output["x_dir_mm"] = _to_3d_vector(workplane, 1.0, 0.0)
output["normal"] = list(normal)
transformed.append(output)
continue
output["start_mm"] = _to_3d(workplane, edge["start_mm"][0], edge["start_mm"][1])
output["end_mm"] = _to_3d(workplane, edge["end_mm"][0], edge["end_mm"][1])
if edge["type"] == "arc":
@@ -245,10 +251,19 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
return _ellipse_edges(segment)
if kind == "bspline":
points = segment.get("points") or []
if len(points) < 3:
raise ValueError("analytic_contours: bspline needs at least 3 interpolation points")
if len(points) < 2:
raise ValueError("analytic_contours: bspline needs at least 2 interpolation points")
converted = [_point(point) for point in points]
periodic = bool(segment.get("periodic"))
start_tangent = segment.get("start_tangent")
end_tangent = segment.get("end_tangent")
if len(converted) == 2:
if periodic:
raise ValueError("analytic_contours: two-point bspline cannot be periodic")
if _distance(converted[0], converted[1]) <= _TOLERANCE_MM:
raise ValueError("analytic_contours: two-point bspline endpoints must be distinct")
if start_tangent is None or end_tangent is None:
raise ValueError("analytic_contours: two-point bspline requires both endpoint tangents")
if periodic:
if _distance(converted[0], converted[-1]) > _TOLERANCE_MM:
raise ValueError("analytic_contours: periodic bspline endpoints do not meet")
@@ -271,6 +286,8 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
raise ValueError("analytic_contours: bspline parameters must be finite")
if any(right - left <= _TOLERANCE_MM for left, right in zip(parameters, parameters[1:])):
raise ValueError("analytic_contours: bspline parameters must be strictly increasing")
if len(converted) == 2 and parameters is None:
raise ValueError("analytic_contours: two-point bspline requires explicit parameters")
output: _Ctx = {
"type": "bspline",
"start_mm": converted[0],
@@ -280,8 +297,6 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
**({"parameters": parameters} if parameters is not None else {}),
**({"parameters": _centripetal_parameters(interpolation_points, periodic)} if parameters is None and parameterization == "centripetal" else {}),
}
start_tangent = segment.get("start_tangent")
end_tangent = segment.get("end_tangent")
if (start_tangent is None) != (end_tangent is None):
raise ValueError("analytic_contours: bspline requires both endpoint tangents")
if start_tangent is not None:
@@ -293,13 +308,68 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
def _imprint_segment_edges(segment: _Ctx) -> list[_Ctx]:
"""Convert an IMPRINT source while retaining its FeatureScript edge identity.
Closed contour assembly intentionally divides circles into four arcs so
that its loops have explicit vertices. An IMPRINT source id, however,
denotes one logical FeatureScript edge. Splitting that circle before the
OCC arrangement loses the one-to-one source/history mapping and makes a
valid fragment appear to be an ambiguous multi-edge source.
"""
if segment.get("type") != "circle":
return _segment_edges(segment)
center = segment.get("center") or [0.0, 0.0]
radius = float(segment.get("radius_mm") or 0.0)
if radius <= 0:
raise ValueError("planar_imprint: circle radius_mm must be > 0")
output: _Ctx = {
"type": "circle",
"center_mm": [float(center[0]), float(center[1]), 0.0],
"radius_mm": radius,
}
if "clockwise" in segment:
output["clockwise"] = bool(segment["clockwise"])
return [output]
def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]:
points: list[tuple[float, float]] = []
for edge in edges:
start = edge["start_mm"]
points.append((float(start[0]), float(start[1])))
if edge.get("type") == "bspline":
points.extend((float(point[0]), float(point[1])) for point in edge["points_mm"][1:-1])
spline_points = edge["points_mm"]
if len(spline_points) == 2:
# GeomAPI_Interpolate with exactly two endpoint derivatives is
# the cubic Hermite curve over the explicit parameter span.
# There are no intermediate interpolation points to sample, so
# use its analytical points rather than incorrectly treating
# an otherwise valid curved loop as zero-area.
parameters = edge.get("parameters") or []
start_tangent = edge.get("start_tangent_mm")
end_tangent = edge.get("end_tangent_mm")
if len(parameters) != 2 or start_tangent is None or end_tangent is None:
raise ValueError("analytic_contours: two-point bspline sampling is unresolved")
parameter_span = float(parameters[1]) - float(parameters[0])
if parameter_span <= _TOLERANCE_MM:
raise ValueError("analytic_contours: two-point bspline parameter span is degenerate")
start_point, end_point = spline_points
for fraction in (0.25, 0.5, 0.75):
squared = fraction * fraction
cubed = squared * fraction
h00 = 2.0 * cubed - 3.0 * squared + 1.0
h10 = cubed - 2.0 * squared + fraction
h01 = -2.0 * cubed + 3.0 * squared
h11 = cubed - squared
points.append((
h00 * float(start_point[0]) + h10 * parameter_span * float(start_tangent[0])
+ h01 * float(end_point[0]) + h11 * parameter_span * float(end_tangent[0]),
h00 * float(start_point[1]) + h10 * parameter_span * float(start_tangent[1])
+ h01 * float(end_point[1]) + h11 * parameter_span * float(end_tangent[1]),
))
continue
points.extend((float(point[0]), float(point[1])) for point in spline_points[1:-1])
continue
if edge.get("type") == "ellipse":
center, axis = edge["center_mm"], edge["major_axis_mm"]
@@ -439,7 +509,38 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
return entities, []
CORE_SHAPE_GENERATORS: dict[str, Any] = {"circle": _gen_circle, "polygon": _gen_polygon, "analytic_contours": _gen_analytic_contours}
def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
"""Prepare source curves for an exact OCC planar-arrangement split.
Unlike ``analytic_contours``, these curves are intentionally not joined
into a guessed outer wire. FeatureScript's IMPRINT query identifies
regions in the arrangement of all source curves, including open curves
and split fragments, and the geometry adapter chooses those actual B-rep
regions after the split.
"""
source_entities: list[_Ctx] = []
for source in profile.get("source_entities") or []:
source_id = str(source.get("id") or "")
curve = source.get("curve") or {}
if not source_id:
raise ValueError("planar_imprint: source entity id is missing")
edges = _imprint_segment_edges(curve)
if not edges:
raise ValueError(f"planar_imprint: source entity {source_id!r} has no curve")
source_entities.append({"id": source_id, "edges": edges})
if len(source_entities) < 2:
raise ValueError("planar_imprint: at least two source entities are required")
meta["_imprint_entities"] = source_entities
meta["_imprint_selections"] = deepcopy(profile.get("selections") or [])
return [], []
CORE_SHAPE_GENERATORS: dict[str, Any] = {
"circle": _gen_circle,
"polygon": _gen_polygon,
"analytic_contours": _gen_analytic_contours,
"planar_imprint": _gen_planar_imprint,
}
SHAPE_GENERATORS = CORE_SHAPE_GENERATORS
SHAPE_CAPABILITIES: dict[str, _Ctx] = {
"circle": {"detectable": True, "arity": "circle", "description": "single circular contour"},
@@ -463,7 +564,10 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
generator = SHAPE_GENERATORS.get(profile.get("type"))
if generator is None:
raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {profile.get('type')!r}")
meta: _Ctx = {"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": [], "_has_open_contour": False}
meta: _Ctx = {
"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": [], "_has_open_contour": False,
"_imprint_entities": [], "_imprint_selections": [],
}
entities, contour = generator(profile, meta)
output = deepcopy(sketch)
original_circles = [entity for entity in sketch.get("entities") or [] if entity.get("type") == "circle" and not entity.get("construction")]
@@ -480,6 +584,15 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
]
if meta["_has_open_contour"]:
output["_open_contour"] = True
if meta["_imprint_entities"]:
output["imprint_entities_mm"] = [
{
"id": entity["id"],
"edges": _transform_contours(entity["edges"], workplane) if workplane else entity["edges"],
}
for entity in meta["_imprint_entities"]
]
output["imprint_selections"] = meta["_imprint_selections"]
return output
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,514 @@
# CADFS 全量能力闭环目标
## 目标
将 CADFS FeatureScript 的完整建模历史通用地转换为 CDSL,再由 CDSL engine
重建 STEP。目标不是让少数代表模型通过,也不是为某个样本拟合最终 STEP;目标是
补齐当前 CADFS 全量语料实际出现的所有建模语义,使每个可比较模型都能以完整
feature history 生成工程相似的 STEP。
工程验收使用 `comparison.json.rp.passed == true`:双向 surface max/p99 与 bbox
不超过 `0.02 mm`、体积和面积相对误差不超过 `0.005`、实体数相等。`strict`
`0.01 mm` / `1e-5` 阈值保留为高精度诊断,不能因严格失败而隐藏已工程相似的模型,
也不能因生成 STEP 就把几何错误的模型标为成功。
本文件的范围是
`output-history/20260907-235209` 的 9,347 条历史快照。后续重新扫描全量数据时,
必须以新快照的真实样本数和能力矩阵更新本文件及本地能力台账。
## 不可妥协的实现原则
这是绘图引擎和通用 converter,不是样本修复脚本。每项能力必须由 FeatureScript
语义、CDSL contract 和内核拓扑共同定义,并对同类输入普遍成立。
- 禁止以 `sample_id`、文件路径、特定尺寸、特定坐标、特定 feature ID 或 gold STEP
测量值驱动行为。不得为让单条样本通过而植入分支、偏移量、默认 profile 或 selector。
- 不得读取原 STEP 来补齐 FeatureScript 未提供的参数、反推 CDSL 尺寸或决定建模策略。
原 STEP 只用于最终比较、诊断和已验证的 source exception 证据。
- 不得把未知语义降级成任意盲拉伸、默认 union、任意当前 body、任意相近 face/edge
或静默跳过。无法唯一表达或绑定时必须保留可执行前缀、输出明确诊断,并记录能力
缺口。
- 优先扩展显式 CDSL schema、typed runtime state、body graph 和 kernel-level topology
delta;不得以 parsing/lowering 层的临时重写替代应由 runtime/adapter 承担的几何语义。
- 允许针对几何类别采用受限算法,但适用前提必须由通用的、可验证的几何条件表达,
并有正向、反向和边界测试。条件不满足时必须拒绝并诊断,不得猜测。
- 若已有实现反复依赖样本化补丁、不能表达已出现的通用语义或受内核 API 结构性限制,
必须评估替代方案,不得沿错误方向继续累积补丁。替换需要可复现根因、成熟实现或最小
原型的对照、contract/迁移影响评估和回归计划;单个样本或偶发内核失败不足以触发重写。
- 每项能力必须同时具备:FeatureScript lowering、CDSL schema/semantic validation、
runtime/adapter 实现、selector/body 语义、单元测试、多个语料回归和比较工件;缺少
任一层只能标记为“部分完成”。
## 全量基线
历史快照的主要操作和当前能力缺口如下。数量是出现次数或受该缺口阻塞的模型数,
只用于排序,不代表一个操作只有一种语义。
| FeatureScript 操作 | 历史出现次数 | 当前主要缺口数 |
| --- | ---: | ---: |
| `newSketch` | 20,857 | 草图/工作平面诊断 7,043 |
| `extrude` | 18,168 | 3,600 |
| `fillet` | 5,210 | 1,904 |
| `revolve` | 2,087 | 662 |
| `chamfer` | 1,818 | 588 |
| `cPlane` | 1,620 | 168 |
| `hole` | 1,203 | 582 |
| `shell` | 729 | 696 |
| `mirror` | 483 | 241 |
| `transform` | 468 | 252 |
| `sweep` | 378 | 326 |
| `loft` | 377 | 107 |
| `circularPattern` | 252 | 139 |
| `booleanBodies` | 228 | 187 |
已发现的派生 profile / topology 语义必须单独覆盖,不能合并为笼统的 `extrude`
`loft` 支持:
| 语义 | 历史缺口数 |
| --- | ---: |
| `extrude_profile_topology:intersect` | 383 |
| `extrude_profile_topology:cap_face` | 201 |
| `extrude_profile_topology:cap_edge` | 134 |
| `extrude_profile_topology:swept_face` | 99 |
| `extrude_extent:up_to_surface` | 100 |
| `loft_profile_topology:cap_face` | 30 |
| `extrude_profile_topology:offset_face` | 18 |
| `extrude_profile_topology:swept_edge` | 16 |
| `loft_profile_topology:swept_face` | 9 |
| `extrude_extent:up_to_vertex` | 7 |
| `extrude_surface_or_mixed` | 4 |
| `extrude_extent:up_to_body` | 2 |
| `extrude_profile_topology:mid_cap_edge` | 2 |
| `loft_profile_topology:cap_edge` / `cap_vertex` | 1 / 1 |
历史报告还记录了 1,335 个未支持操作诊断。当前已知的 P2 操作包括 `draft`
`thicken``split``moveFace``deleteFace``replaceFace`、完整 `transform`
`derive``import``bend_add`。重新扫描时必须从诊断原文生成完整操作清单;
未列出的新操作不能被静默归入已有能力。
## 覆盖模型
每一项能力的回归选择必须覆盖下面五个维度,而不是只覆盖操作名称:
1. 操作和参数:operation mode、实体/曲面模式、extent、方向、offset、draft、
角度、数量、终止条件和 result mode。
2. 几何输入:直线、圆弧、圆、椭圆、B-spline、开口/闭合 wire、多 region、孔洞、
退化和自交拒绝路径。
3. 拓扑来源:原草图、`CAP_FACE``CAP_EDGE``SWEPT_FACE``SWEPT_EDGE`
`OFFSET_FACE``INTERSECT``MID_CAP_EDGE``COPY` 和 boolean 后继。
4. body 生命周期:`NEW``ADD``REMOVE``INTERSECT`、keep tools、copy、
transform、delete、mirror、linear/circular/nested pattern、多实体输出。
5. 比较状态:strict、RP、实际几何拒绝、conversion defer、runtime ineligible、
rebuild failure、comparison timeout 与 source exception。
维护三个固定层级的回归集:
| 层级 | 用途 | 通过条件 |
| --- | --- | --- |
| 原子语义矩阵 | 每个 operation/参数/拓扑/body tuple 的最小合成模型 | CDSL contract、可执行性和精确几何断言。 |
| 核心 17 | 跨能力的稳定烟雾回归 | 完整 history、工程相似或有归因明确的 source/comparison 证据。 |
| 扩展 100+ | 每个已出现 tuple 至少多个真实语料样本 | RP 工程相似率、失败诊断和能力覆盖均可追溯。 |
| 全量 9,347 | 完整发布前的能力验证 | 每条都有完整分类;支持的完整 history 均工程相似,剩余项仅能是有证据的 source exception 或未实现 capability。 |
扩展集必须从能力矩阵自动选择并保留历史失败样本。修复能力后应扩大相应 tuple 的
样本数;不得因当前失败而从 manifest 中移除样本,也不得以新的贪心选择替换已有
反例。
## 实施路线
### 开源实现策略
每个新的内核级能力在自研前,先检查官方 OCCT/OCP API、项目现有依赖和成熟开源实现。
本地首选参考是 `/Users/lk/Downloads/SimpleCADAPI-master 4`:其
`topology/tracking.py` 保留 OCC builder 的 `Modified``Generated``IsDeleted`
section-edge 历史,`ocp_booleans.py` 展示了保留 history 的 boolean 与 same-domain
cleanup,其他 kernel/operators/tests 覆盖 transform、shell、sweep、loft、fillet 和
chamfer。这些实现对 P0 topology delta、body provenance 和 P2 feature adapter 具有
直接参考价值。
采用外部方案时必须确认许可证、OCP/OCCT 版本和异常语义,并将算法适配到本项目的
CDSL contract、body graph 和 selector resolver。SimpleCADAPI 强制单一 `Solid`
boolean helper 不能直接使用,因为 CADFS 需要独立 body、copy、keep-tools 和 pattern
instance 生命周期。每项采用或拒绝外部方案的决定、理由和回归证据都记录在本地能力
台账;不得不经评估地复制代码,或为复用外部 API 破坏现有 contract。
### P0:通用几何状态和拓扑基础
1. 建立显式 body graphfeature 输出 body ID、result mode、copy、transform、delete、
keep tools、pattern instance 和多 body 聚合语义。当前可用的受限增量是以 source
feature body 为输入的 rigid translation/rotation 与受限 non-rigid uniform-scale
`transform_bodies` / `delete_bodies` contract;它不能
代替 fused body 或 pattern instance 的完整 provenance,后两者在唯一性未证明时
必须诊断而不能退化为 current body。capability preflight 已镜像 runtime 的显式
body-member 生命周期:fused/dress-up 输出、boolean 消耗、delete 和 pattern replay
会撤销旧 member 的可选资格;只有 `new_body``keep_tools`、明确 transform 输出,或
经 sole-body lineage 已证明的 current successor,才能被后续 body query 选中。受限的
`pattern_circular` 现在可对所有 source 都是明确可选 member 的 `NEW` additive body,或
一个 direct `SWEPT_BODY` 经该 sole-body successor 保留的 current member,以
`pattern_feature_id` / `source_feature_id` / `instance_index` 记录 direct COPY
member;后续 `transform_bodies` 可精确引用该 COPY 而无需写入 runtime ID。CADFS 的
direct `SWEPT_BODY` transform 现可将 `TRANSLATION_3D`、rotation,以及单一线性
sketch/`CAP_EDGE` 或有 physical cap frame 的 direct `CAP_FACE` direction 的非负
`TRANSLATION_DISTANCE` lower 为同一刚体 contract。系统 datum plane、已显式 lower 的
reference plane,以及能由 direct planar source 唯一构成 frame 的 `SWEPT_FACE` 也可提供该
direction;圆柱/曲面 `SWEPT_FACE``SWEPT_EDGE`、OFFSET/COPY face 和没有可证明 frame 的
plane query 继续诊断而不猜测。`SCALE_UNIFORMLY` 在 factor 有限且为
正数、scale point 是 Origin、direct sketch vertex(含 circle center)或 direct
`CAP_VERTEX` 时 lower 为显式 `uniform_scale` body transform,不烘焙回草图。`TRANSLATION_ENTITY` 在 direct source
仍是可选独立 body 时接受一个线性
sketch/`CAP_EDGE` 的端点差或两个 sketch/`CAP_VERTEX` 的有序点差;move 的 bake path
另要求 source 是紧邻的 `NEW` body。对于 exact single-source `makeCopy` transform
后续 `COPY(instanceName=1)` body/line/vertex 可递归验证其 `derivedFrom` 链并引用已生成
body member;对于前序 direct multi-source `makeCopy`CDSL 以
`transform_copy_refs(transform_feature_id, source_feature_id)` 表示被查询的唯一直接 source
runtime 分别物化各 source 的 transformed memberaggregate 只用于 STEP 导出且不伪造
topology owner/delta。schema、semantic validation、capability preflight 和 runtime 都要求
owner 是 preceding multi-source `make_copy:true` transform,且 source 是该 transform 的
直接选择项;single-source、source 外、fused、pattern 和无源限定的 aggregate reference
一律拒绝。无位移的 direct `TransformType.COPY` 以显式 zero translation 与
`make_copy:true` 产生一个独立 identity body member,绝不将其写成 non-copy successor
alias。CAP edge/vertex 的 source-local in-plane coordinates 保持 profile frame
仅映射到 selected physical cap origin。face、
generic face、swept/offset、pattern 或未证明 COPY/curve direction、多个 direction、已吸收 body 与其它非刚体
transform 仍必须明确诊断。对于已证明只有一个 CADFS body 的受限 lineagedirect
`SWEPT_BODY` 可从初始 `new_body` 经 ordinary fusing add、单 body fillet/chamfer/shell
与 exact non-copy transform 续接到当前 CDSL membercut 保留所选 member,因而不虚构
一个 cut successor。直接 circular ADD 且 source 唯一等于该 current member 时保留这份
证明,并以该成员的实际 B-rep 物化每个 COPY instance;第二个 `new_body``make_copy`
transform、boolean、其它 pattern、delete 或任何未列出的 body atomic 都清空它。它绝不以
aggregate/current body 兜底,
也不为 linear/nested pattern、被排除 instance 或无明确 source member 建立 ownership。
唯一新增的
mirror 例外是:直接 `pattern_mirror` 的每个 source 都仍为可选的
`result_mode: new_body` member 时,runtime 以实际 B-rep mirror 物化其唯一的
`instance_index: 1` COPY memberCDSL semantic validation、capability preflight 与
runtime 都要求这一完整条件。普通 additive/cut/dress-up 后继、已吸收 source、linear
或 nested mirror 仍走 feature replay,不能借该例外暴露 aggregate member。
本轮 327 条 transform shard 中 `00120430``00496729``00749755` 因该 provenance
rule 完整 lower;只有 source 在 mirror 前未被后续 mutation 吸收的 `00749755` 可执行到
RP `rebuilt_approximate`,另两条保留 `runtime_ineligible` 和已有前缀/诊断,绝不以
converter 完整状态冒充 runtime 或相似通过。新增的 multi-source COPY path 令 `00699847`
的 F3 两源 copy 和 F4 的 source-qualified F3/F1、F3/F2 queries 完整 lower,强制
pipeline 为 `rebuilt_strict``00950564` 证明 F1 的 direct `SWEPT_BODY` 在 F3/F5/F7/F9
ordinary add 和 F10/F11 chamfer 后精确 lower 为 F12 的 `source_feature_ids:["f_F11"]`
不引用 current aggregate。`output/single-body-successor-20260909` 的完整 history 为
`converted_complete`、runtime eligible、8/8 feature executed、`rebuilt_approximate`
RP 通过(bbox delta `0`,体积/面积相对误差 `0.00011913` / `0.00016132`,实体数 `1`)。
strict 仍因 max/p99 surface `0.01 mm` 与体积/面积阈值失败而保留为诊断。单元测试还覆盖
copy、boolean、pattern 与 delete 均清空 lineage;没有能唯一表示的 nested source-member
chain 仍属未完成 contract,继续保留明确诊断。
2. 在 adapter 层记录每次建模操作的 `preserved``modified``generated``deleted`
topology delta 及 CAP/SWEPT/boolean 等输出角色;runtime 以该记录完成后继 selector
绑定,不以“当前形状中最相近元素”猜测。当前增量已覆盖 OCC 刚体
`transform_bodies`(含 OCP `gp_Trsf.SetScale` 的 uniform scale)、单实体
`boolean_bodies` 的 union/subtract/intersect 以及 direct
single-body fillet/chamfer,以及单一 selected solid 的 `shell`adapter 返回 opaque
kernel relationregistry 只在 source/result snapshot 的 exact relation 唯一时传递
owner 和 successor,并在 rebuild 输出 `topology_deltas`。shell 沿既有
`BRepOffsetAPI_MakeThickSolidByJoin` builder 的 `Modified``Generated``IsDeleted`
历史记录 delta;未返回 `IsDeleted` 的内核输入不会被虚构为 deleted。该受限 shell path
还记录可由 source subshape 和 kernel event 证明的 `shell.body_face`
`shell.offset_face``shell.closing_descendant``shell.wall` evidence role。当前
`shell.offset_face` 只有一个受限 lowering consumer:紧邻 shell 后续的 shell face query
且 FeatureScript 以 `TDD`/`trueDependencyDisambiguation` 明确指向前序 direct `new_body`
blind-prism 的 `extrude.start``extrude.end`。CDSL 以
`output_role_source(owner_feature_id, output_role)` 保留此关系;registry 只接受 exact
kernel-history relation 的唯一 active snapshot,不能以相同 geometry、bare offset role 或
任意嵌套 CAP query 替代。它仍不是可 lower 的通用 CAP/SWEPT/OFFSET query。多个 Compound 成员时只有被选中实体的同一
builder 可以产生 delta,其他未改成员继续依赖 body graph 的精确等价。多实体 boolean、
非 direct-builder dress-up fallback、复杂 sweep/loft history 及 CAP/SWEPT/section
query roles 仍属未完成范围。受限的单闭合无内环 profile、无 guide/transition 的 direct
solid sweep 在 initial 或 `new_body` final member 上,会记录
`BRepOffsetAPI_MakePipeShell.FirstShape/LastShape``sweep.start`/`sweep.end`
evidence;受限的 initial closed-wire solid loft 会记录
`BRepOffsetAPI_ThruSections.FirstShape/LastShape``loft.start`/`loft.end` evidence。
后续 fuse 会重建 topology,因此不复用已经失效的 sweep/loft builder history。当前
CDSL `selectorRef.output_role` 现公开 direct-prism `extrude.start`/`extrude.end` 与四个
sweep/loft direct-builder cap roleregistry 只会在
relation 的唯一 result snapshot 上写入 `TopologyRecord.output_roles`resolver 要求
owner、active body snapshot 与唯一 role 命中,并拒绝 stable ID、geometry、snapshot ID
混用。CADFS lowering 现对独立 `new_body` blind prism 的 CAP_FACE,以及满足全部条件的
`LocOpe_DPrism` drafted extrusion 生成该 contract:单一闭合外环、无内环、单向 blind
extent,且拉伸方向与实际端盖法向同向。`extrude_from_face` 直接拉伸该 B-rep 面,不能把
CAP_FACE 还原为原始草图。registry 只会让唯一 OCC `modified`/`preserved` relation 跨
boolean 删除成员后的 Compound member-index 改变继续传递 owner/output role;几何回退仍按
member index 隔离,绝不以相似性跨成员继承。该路径不覆盖 drafted 内环、fused/multi-region/
multi-extent/loft/sweep/pattern 或一般 CAP_FACE,故 `CAP_FACE`/`SWEPT_FACE` lowering 仍为
部分完成。`00016195` 的 F1 -> fillet -> F3 和 `00835610` 的 drafted F3 -> F4 subtract ->
F5 都是正例;后者为 `converted_complete` / `rebuilt_approximate`9 个 feature、3 个实体,
RP 通过(bbox delta `0.0008 mm`、体积相对误差 `0.00020848`、面积相对误差
`0.00021742`)。`00268467` 的 drafted 内环则明确保留 capability diagnostic 和可执行
前缀,不伪造可绑定 selector。
3. 建立统一 selector resolverowner body、source feature、output role、几何签名、
snapshot 和唯一性证明。找不到或多解必须稳定诊断。
4. 完成草图 region/wire 模型:多轮廓、内环、开口 reference geometry、B-spline/ellipse、
profile query、显式 construction 和退化检测。
非周期 `skFitSpline` 的两点受限变体现已贯通:仅当端点不同、同时给出两端导数,且
CDSL 显式携带严格递增的参数域时,lowering 才以真实的 two-point B-spline 输出;它
绝不退化为直线。schema、semantic validation、sketch solver 与 adapter 共同拒绝缺失
导数/参数、周期两点和重合端点。solver 以参数域上的 cubic Hermite 采样检查闭环面积,
adapter 仍把端点切线与参数交给 OCP 插值,而非近似多段直线。历史快照中 453 个此类
entity 都带两端导数;当前 10 条历史反例矩阵的 9 个可生成候选保留了 18 条该曲线。
该增量只完成此一 profile contract,不覆盖由 `INTERSECT``CAP/SWEPT` 等拓扑查询
派生的 profile,也不把后续 dress-up 或内核失败误记为 spline 成功。
### P1:派生 profile 与拉伸终止
1. 用 P0 topology registry 通用重放 `CAP_FACE``CAP_EDGE``SWEPT_FACE`
`SWEPT_EDGE``OFFSET_FACE``INTERSECT``MID_CAP_EDGE`,将其物化为可追溯的
profile/wire,而不是复用原草图或写入样本特例。现有 direct-prism,以及单闭合无内环、
法向同向的 `LocOpe_DPrism` draft CAP_FACE,是受限例外:它们以 builder-proven output
role 直接消费物理 B-rep 面,未试图物化或重建 profile。
`OFFSET_FACE` 现有一个独立的受限 profile 物化路径:只有 inward shell 直接消费一个
`new_body` blind additive prismshell 恰移除该 prism 的一个 CAP,且 OFFSET query 恰指向
其未修改、单闭合凸线性 profile 中的一条非 construction linelowering 才根据 source
profile 的相邻边 offset 交点、shell thickness、实际 start/end cap frame 构造四边内壁。
它不复用整张 source sketch,也不从 STEP 反推 trimdraft、cut/fused/multi-body source、
多 removal cap、曲线/凹/多 contour profile、多个 source edge 和非正交 span 都保留
`extrude_profile_topology:offset_face` 诊断。`00789939` 的 F15 是严格通过的完整 history
正例;`00588094` 的非直接 provenance 则保持 partial conversion 和五 feature executable
prefix,后续 F15 的 output-role binding failure 仍单独报告。`output/offset-face-linear-wall-20260909-r3`
记录前者 strict/RP 通过;当前 26 条含 `OFFSET_FACE` history 的受控 shard
`output/offset-face-profile-shard-20260909-r1` 为 1 strict、17 rejected、7 rebuild failure、
1 deferred-no-executable-feature,仍有 17 条明确的 `extrude_profile_topology:offset_face`
gap。`output/core-17-offset-face-linear-wall-20260909-r2` 为 4 strict、3 RP、9 rejected、1
comparison timeout,故这仍是派生 profile 的部分完成项而非 general OFFSET replay。
对 CAP_EDGE,现有的受限 outer-profile 规则还会递归展开仅由 `qUnion` 构成的关联嵌套,
再要求恰有一个原始闭合 IMPRINT 外 region 和一个唯一 CAP_EDGE-derived IMPRINT region
并由两个明确 face-side 判断完整外 region 或外环加孔。该展开只消除 FeatureScript 局部
alias 引入的集合包装,不改变 query provenance,也不放宽 owner、曲线或 side 的唯一性。
`00950564` 的 F3/F5 是这一语义的真实证据:两个 CAP_EDGE profile 都完整 lowerF1--F12
history 也因上述单 body successor rule 完整执行并 RP 通过。它不覆盖一般 CAP_EDGE、CAP
edge 物理重放、COPY/boolean/pattern 后继或未证明的 region 选择。双向 blind extrusion
同样沿用单向 CAP 顺序:`isStart:true` 是主方向的反侧端盖,`isStart:false` 是主方向端盖;
因而 second depth 趋近于零时保持同一 contract,不按 primary/reverse 字段名称猜测。
`00247322` 的 F4 以 `z=40.3 mm` 的反侧 cap 附着 F6F5 后该 F4 owner 仍有唯一
continuationF7 的 CAP_EDGE hole profile 可执行。后续 F9 选择已被 F5 消耗的 F4 member
故保持 `body_source_unavailable`,不烘焙 transform 回 F4 草图或替换为 aggregate body。
刷新的 `output/cap-edge-two-sided-frame-matrix-20260909` 三样本矩阵为 1 个
`rebuilt_approximate``00950564`)、1 个 `rebuilt_rejected``00803284`)和 1 个
`runtime_ineligible``00247322`,但 F1--F7 prefix 可执行)。共享 lowering 的固定核心
17 工件 `output/core-17-two-sided-cap-frame-final-20260909` 分类为 3 strict、3 RP、9 rejected、
1 rebuild failure、1 comparison timeout,与此前共享基线一致。受控全量 24 条 shard
`output/full-shard-two-sided-cap-frame-20260909` 记录 1 strict、2 RP、10 rejected、6 rebuild
failure、3 runtime-ineligible、1 timeout 和 1 无可执行 feature;其 conversion diagnostics
仍归因为已有的 `cPlane`、CAP_EDGE、fillet/chamfer、hole、revolve 与 shell selector 缺口,
没有将它们重分类为双向 CAP frame 成功或失败。
`INTERSECT` 的第一条通用受限路径现以 CDSL `planar_imprint` 保存同一草图的原始
analytic entities、IMPRINT 面侧、可选 INTERSECT vertex order 与 fragment sidesolver
仅转换这些精确曲线,adapter 用 OCP `BOPAlgo_Splitter` 切分有界 support face 后选取实际
B-rep region。没有唯一 fragment、选中无界 support-boundary region、construction/source
alias、split failure 或不完整 query 都稳定拒绝,不把 B-spline/arc 采样成多边形,也不复用
整张草图。该路径覆盖 bounded line/arc/circle/ellipse/B-spline arrangement 的 shared
contract,但尚未覆盖不同 sketch、surface/topology producer、trim/copy/pattern 后继或完整
FeatureScript IMPRINT query grammar,因此仍是 P1 部分完成项。工件
`output/planar-imprint-intersect-20260909-runtime` 是 CAP_FACE 扩展前的历史工件:
`00082324` 与当时的 `00835610` 都保留可执行 STEP 但 `rp.passed == false``00071859`
`00093912` 分别保留既有 `TopoDS::Solid` 和 revolution-segmentation runtime failure。
当前 `output/drafted-cap-face-20260909` 已证明 `00835610` 在 drafted CAP_FACE continuation
与该 IMPRINT 后续均执行时 RP 通过;这不改变 `planar_imprint` 的其它失败分类,也不构成
该能力的完整覆盖。共享核心 17 回归
`output/core-17-planar-imprint-20260909` 分类保持 3 strict、2 RP、10 rejected、1
rebuild failure、1 comparison timeout。
对同一逻辑 circle sourcesolver 现在保留一条带 workplane frame 的 OCC circle edge,而不将
它预拆为四条 contour arcfragment 的 directed successor/predecessor 由 splitter image 在
精确交点处的端点和原 curve 同向切向解析,避免周期参数接缝的数值排序歧义。无 fragment
的 source 代表全体 splitter descendants:只有其给定 face side 的每一个 B-rep face 都有界时,
adapter 才保留完整 face 集;任一 support-boundary face、非唯一 side 或 fragment 都拒绝,
不按 image 顺序/长度选择或静默省略。`output/intersect-circle-imprint-matrix-20260909-r2`
记录 `00001313` F1/F3 完整执行但 RP 拒绝(bbox delta `0.023571 mm`)、`00004246`
的 F1/F3 executable prefix 和三个仍有归因的失败。`00040869` 的 line/ellipse
`OD(0)` pairs 以 OCP exact distance 均为 `0.004240908 mm`,没有 splitter image 或
section edge,故 `output/intersect-circle-imprint-source-gap-20260909-r1` 保留 F1 prefix
并报告 source/anchor 无实际交点,绝不以容差补点;核心 17
`output/core-17-intersect-circle-imprint-20260909-r2` 为 4 strict、3 RP、9 rejected、1
comparison timeout。故这仍只是同草图有界 exact-curve arrangement 的 P1 扩展,不能宣称
general INTERSECT replay 已完成。
2. `UP_TO_NEXT` 已 lower 为 CDSL `through_next`,并有一个受限的 current-body
contract:所有 profile sample ray 都在当前 body 命中时,保留 profile 到首个正向
命中面之前的外部材料;只有部分 ray 命中时,以实际命中 face 做有限体层裁剪;没有
命中时明确拒绝,不退化为盲拉伸。`00192744` 的 F5/F10/F14 已由该 contract 完整
执行,但其独立 body 与 source STEP 的融合差异、后续 multi-body transform 和 source
history/STEP 不一致仍单独分类。这是部分完成,不代表复杂 profile、多 body target、
source body lifecycle 或第二方向终止已支持。
3. 在上述受限路径之外,完成 `up_to_surface``up_to_next``up_to_body`
`up_to_vertex`、two-sided、through-all、surface/mixed 的有限交集语义和多 body
target 选择。
4. 仅在所有 profile rays、target body 和 offset 均能由通用几何验证时执行;否则保留
前缀并说明哪一个终止条件无法证明。
### P2:实体特征的完整语义
1. `extrude``revolve``loft``sweep`:补齐实体/曲面模式、复杂 profile、导轨、
section correspondence、twist/transition、path/axis selector 和结果 body。现有 direct
initial/new-body simple sweep 与 initial simple loft 现可将 builder-proven cap evidence
作为受限 CDSL output-role selector 供 downstream feature 消费;同一 contract 还支持
independent blind prism CAP_FACE 的 `extrude_from_face`。这些路径仍不能代替完整的
derived-topology selector 语义,CADFS lowering 也尚未以此表示一般 CAP/SWEPT query。
2. `shell`:单一 selected solid 的 direct-builder topology delta 与有限 output-role
evidence 已覆盖。CADFS lowering 现额外接受一个受限的 removal selector:直接 blind 或
two-sided linear extrusion 的立即后继 shell,可从同一未修改 source profile 中唯一的、非
construction `line` 原始实体生成平面 `SWEPT_FACE` placeholder,再由 prefix snapshot
唯一绑定。该规则要求 owner、profile、start/end frame、blind extent 和单 body
lifecycle 都可证明;revolve/sweep/loft、圆或其它非线性 source、changed profile、copy/
pattern/boolean/dress-up continuation 和多 target body 均继续诊断,不能回退到当前 body
或相近面。CADFS `oppositeDirection` 已显式 lower 为 `inward:false`,由既有 OCC
`MakeThickSolidByJoin` 的有符号 offset 执行;这只复用已验证的 removal selector 和单一
selected solid contract,不会将 OCC feasibility failure 伪装成 inward shell 成功。仍需任意
稳定 removal face、多 body target、其它 join/offset 参数语义和更广的 kernel failure
诊断回归。作为进一步的窄例外,紧邻 shell 的 `OFFSET_FACE` removal query 可在其
`disambiguationData` 中只有一个 direct `TDD` CAP source,且 source 是前序 direct
`new_body` blind-prism cap 时 lower 为 `shell.offset_face` 加 source-qualified output role。
没有 `TDD`、多个 source、draft/fused/COPY/pattern/boolean source、非邻接 owner 或无
active unique snapshot 都稳定诊断。`shell.parts` 现有一个独立且同样受限的 body-source
lifecycle contract:只有 parts query 可唯一解析为仍存活的 direct `SWEPT_BODY` independent
member 时,lowering 才写入 CDSL `target_feature_id`capability preflight 和 runtime 都要求
该 ID 的 exact B-rep solid 仍在 active member graph 中,不能以 `session.body`、current
aggregate 或几何近似替代。`00107631` F3 中 F1 可证明地继续为 `f_F2`,因而是正例;
`00542223` F7 在 fuse/pattern 后不再有独立 memberruntime 仅在 pattern 前后都是一个
可执行实体且 source 唯一时保留 direct source 的 sole-body alias,因此 parts-scoped shell
可以验证为当前实体,而不把 aggregate 伪装成一个独立 member。此路径尚未覆盖 multi-part /
multi-body parts query、COPY、boolean 或 pattern instance 的通用 successor;这些情形必须
保留可执行前缀或既有运行路径,不能宣称 explicit target-body semantics。实现参考
SimpleCADAPIApache-2.0`IsSame` member identity 语义,未复用代码;其单 Solid boolean
假设未被采用,因为它与 CADFS 的独立 body 生命周期不兼容。当前验证为:受影响
lowering/integration/selector/runtime suite `218 passed`3 条既有 workplane warning);
`output/core-17-shell-parts-20260909` 为 3 strict、3 RP、9 rejected、1 rebuild failure、1
comparison timeout。`output/shell-parts-matrix-20260909` 的 21 实样本中,10 个 shell 写入
explicit `target_feature_id`,结果为 13 rebuilt-rejected、7 rebuild-failed、1 runtime-ineligible。
`00107631` 的 F3 在这项 target contract 下通过 converter/preflight。其普通、非 instance-locked
`CAP_FACE` selector 只有在 active owner candidates 均不满足完整 geometry signature 时,才可在
当前 active member 中选择唯一的 threshold-qualified face;这不能替代 `output_role` relation
也不适用于 `owner_match_required` COPY provenance。因而 F3 的两张 removal faces 分别严格绑定
`f_F2:face:1` 和由 `(f_F1, extrude.start)` 证明的 `f_F2:face:5`。该 feature 随后由 OCC
报告 invalid thick-solid,而不是 selector-not-found;系统保留 F2 executable STEP prefix,不会
修改厚度、重选面或伪造 shell 成功。`00542223` 的完整 history 现可 rebuild,但仍因几何比较
rejected;这不是 RP 通过声明。`output/core-17-fused-sole-body-pattern-provenance-20260909-r2`
的新代码工件为 3 strict、3 RP、10 rejected、1 comparison timeout;五样本
`output/fused-sole-body-pattern-provenance-20260909-r2``00542223``00423838` 均为
rebuilt/rejected`00293508` 保持 RP`00253824` 仍在无关 `planar_imprint` 路径失败并保留
F3 prefix。这些结果不能作为 complete shell 或 RP 相似通过的证据。
无 geometry 的普通 context selector 同时保留既有的唯一 active same-kind record 回退;它只在
source owner 没有 active record、context 内候选唯一时成立。`owner_match_required`
COPY/instance selector 没有此路径,零 owner candidates 仍是 `selector_not_found`。该边界的
原子正反测试与完整 CADFS 测试均通过(11 个 selector test、118 个 CADFS test)。刷新后的
`output/core-17-selector-context-full-20260909` 仍为 3 strict、3 RP、9 rejected、1 rebuild
failure、1 comparison timeout`output/shell-parts-selector-context-fallback-matrix-20260909`
21 条分类仍为 13 rebuilt-rejected、7 rebuild-failed、1 runtime-ineligible。这是 selector
绑定范围修正,不增加 shell 的已完成语义,也不改变 OCC invalid-shape 的保留前缀诊断。
3. `fillet``chamfer``hole`:完整 selector、tangent propagation、参数变体、
feasibility 检查和 dress-up 后继。当前 fillet/chamfer lowering 对 direct blind/
mid-plane extrusion 的唯一源端点,以及 direct two-section loft 的每 section 一对唯一
源端点,可生成可绑定的 endpoint-bbox `SWEPT_EDGE` selector;前者额外证明为直线,
后者不声称曲线类型,因为内核可将其物化为 B-spline。独立 `NEW` 的 full solid
revolve 也可在 profile 与 axis 都是同一原始草图的直接来源、original-set 唯一指向一
个非轴端点时,按 axis projection 生成 `circle_center_mm` / `radius_mm` 圆边签名;
adapter 只从实际 OCC circle edge 记录该签名。一个 `IMPRINT` profile 仅在 materialized
workplane 和完整 profile 与原草图完全相等时可使用同一来源证明;任何实际 region 选择
都不继承该 contract。该受限路径不覆盖 sweep、surface/partial/fused revolve、
multi-section loft、generated/trimmed/transformed profile 或 boolean/pattern 后继;任一
端点、axis 或 owner 不唯一时必须保留前缀并诊断。内核不能完成时不能伪造较小半径或不同
孔型。
4. `booleanBodies``mirror``circularPattern`:显式 source/target bodies、实例输出、
nested pattern、remove/intersect/keep-tools 和 transform 后继。当前 direct circular
`NEW` COPY -> rigid transform,以及 direct mirror 的 `NEW` source -> `instance 1`
COPY -> transform 是受限增量;不代表 linear/nested pattern、镜像 aggregate source 或
fused instance 已有完整 output body lifecycle。mirror 的 `mirrorPlane` 另有一个受限
`SWEPT_FACE` contract:唯一 direct full solid `revolve_add` 的原始 source line 与 revolve
axis 可由既有 `_query_plane` 证明为同一物理平面时,lowering 物化显式
`reference_plane` 后交给现有 `pattern_mirror` 执行。它不从 STEP 或 current body 推断
planepartial revolve、非 revolve、curve/derived face 或任一未解析 frame 都保留原有
mirror-plane diagnostic。`00000385` 的 F5 是正例(XY planeF5 executed);
`00023074` 保留拒绝边界。五样本
`output/mirror-revolve-plane-matrix-20260909` 为 2 RP、3 rejected,且只移除了
`00000385` F5 的 plane deferF2 chamfer 与 F7 fillet 仍如实诊断。`booleanBodies`
现在也有一个独立、受限的 COPY body contractdirect `SWEPT_BODY` 和已存活的
mirror/circular `COPY(SWEPT_BODY)` 分别 lower 为 feature ID 和
`{pattern_feature_id, source_feature_id, instance_index}`runtime/body graph 仅从该
tuple 解析对应独立 body member,绝不以 producer aggregate 或 current body 代替。
direct `SWEPT_BODY` 若唯一解析到上述 fused sole-body successor,也会以 successor 而非
creator 作为 circular source`00253824` 的 F1 -> F3 cut -> F4 add -> F5 circular 的
source 因而是 `f_F4`,其 F6 targetless UNION 保留 `f_F4` 与 F5/F4/instance-1,2 的
明确 ownership。该完整样本目前在无关的 F4 `planar_imprint` runtime selection 失败时
保留 F3 executable STEP,不能作为 pattern/boolean RP 通过证据。合成 fused-body
circular COPY -> boolean runtime 回归完整执行。多 body、boolean 后继、nested/linear
pattern、被排除 instance 和 CAP/face topology ownership 仍未覆盖。
targetless FeatureScript `UNION` 只在 `keepTools:false` 且至少有两个 explicit members
时按 query 顺序确定一个 target`00293508` 的 direct F1/F3 UNION 以 F1 target、F3 tool
完整执行并 RP 通过。targetless subtract/intersect、keep-tools、无效或已经吸收的 instance、
以及 multi-source transform COPY 仍保持拒绝。`00000385` 的 F6 因而以 `f_F1` 为 target、
F5/F1/instance-1 为 tool 完整 lower 并执行,F2/F7 保持其原有 diagnostics。四样本
`output/boolean-pattern-copy-provenance-matrix-20260909` 为 3 rejected、
1 runtime-ineligible;其中其它 sweep、keep-tools/subtract、shell/hole 和 selector 缺口均
未被重分类。新的 `output/core-17-boolean-pattern-copy-provenance-20260909` 仍为 3 strict、
3 RP、9 rejected、1 rebuild failure、1 comparison timeout。direct circular ADD 现将每个
transformed COPY 的面、边、顶点沿 exact OCC transform/fuse history 传入 final active snapshot
只有一个 source 和一个 final result 的 relation 才传递 `f_pattern.cN.f_source` owner
split/merge/no-history 仍不声明 owner。`00542223` 的 F7 removal faces 和 `00423838`
`COPY(CAP_FACE)` 因而都在 active snapshot 中绑定为真实 instance owner;后二者的最终 STEP
仍分别 rejected,不能将 selector 可执行性描述为几何相似通过。direct-new wedge 的原子回归同时
验证三个 copy owner 都出现在最终 fused body。nested/linear pattern、boolean 后继、被排除
instance 和没有 exact history 的 CAP/face ownership 仍未覆盖。
CADFS 的静态数值表达式还允许无歧义的 `round(N)`,但仅当 `N` 已是有限整数,因而该
表达式可被证明为恒等;`00003011``round(8)` pattern 由此完整 lower。非整数
`round`、变量和其它函数不会在 converter 中猜测 FeatureScript 的数值语义。这只是
lowering 前置条件,不能作为 COPY owner、pattern instance lifecycle 或 RP 相似通过的
证据。
5. `cPlane`:所有历史出现的 plane constructor、曲线/曲面 attachment、方向手性和
退化输入。`CPlaneType.OFFSET` 必须将 CADFS `oppositeDirection` 编译为 source plane
normal 上的负 signed offset,而不是忽略该 flag 或翻转 plane frame;后者会镜像同一草图的
local coordinates。该 shared lowering contract 已覆盖 272 条实际带该 flag 的 history。
`00777619` 将 Right plane 的 `76.2 mm` offset 修正为 `x=-76.2 mm`;因其 profile 只有部分
射线到达 selected `SWEPT_BODY``up_to_body` 仍以非均匀 target 诊断保留 F2 prefix,不能
被这一 plane 修复伪装成成功。Right/Front/Top 的三个真实 matrix 例与 24 条分层 shard
`output/cplane-offset-opposite-shard-20260909` 分别验证 frame 和独立分类:3 strict、1 RP、
13 rejected、7 rebuild failure。共享 core-17
`output/core-17-cplane-offset-opposite-20260909` 分类保持 3 strict、3 RP、9 rejected、1
rebuild failure、1 comparison timeout。它仍只是 cPlane 的一项通用 frame contract,不覆盖
曲面/曲线 attachment、退化输入或完整 derived-topology plane semantics。
### P3:尚未支持的 FeatureScript 操作
按全量诊断计数建立 operation registry,逐项为 `draft``thicken``split`
`moveFace``deleteFace``replaceFace`、完整 `transform``derive``import`
`bend_add` 建立 schema、lowering、runtime、adapter 和回归。外部资产或生成器缺失时,
必须将 capability 标为不可执行并提供部署诊断,不能在 import 时影响无关模型。
### P4:比较、来源和性能
1. 保留 strict 与 RP 两层几何比较,并为被精确量快速拒绝的边缘模型提供可控的
离线 surface 诊断预算;comparison timeout 是基础设施问题,不能混入几何失败。
2. 输出 source/rebuild 多视图、局部差异、body 体积/面积和最近 feature 的关联证据。
截图只用于人工复核,不能替代多视角 B-rep 比较。
3. 识别 source STEP 非闭合、FeatureScript literal 与 export 精度不一致、缺失资产等
source exception。它们必须保留原始导入/检查证据,不能修饰 CDSL 以伪造通过。
4. 使用 capability shard、缓存中间工件和可恢复 worker 隔离完成全量回归;任何 failure
都必须保留最后一个重新绑定 selector 并以 strict 模式实际执行成功的前缀 STEP、GLB
和诊断。不得把未绑定候选或当前 body fallback 伪装成可执行工件。
## 每项能力的完成定义
一项能力仅在满足以下条件时可标记完成:
1. 有版本化的 CDSL 表达,且 schema/semantic validation 拒绝不完整或歧义输入。
2. lowering 仅根据 FeatureScript source 产生该表达,并保留 source feature、selector
和 body provenance。仅有 runtime CDSL contract 而未由 lowering 产生的能力必须标为
部分完成。
3. runtime/adapter 按通用算法执行,记录结果 body 和 topology delta,不依赖样本信息。
4. 原子语义矩阵包含正向、边界和拒绝测试;至少多个真实语料样本覆盖不同几何和
生命周期组合。
5. 受影响核心集、扩展集和全量 shard 有可复现结果,工程相似通过率、失败数和剩余
exception 均更新到本地台账。
6. 代码审查确认没有 sample-specific 分支、gold STEP 参数回填、隐式默认尺寸或为
通过比较而降低 RP 阈值的行为。
## 执行节奏
每次改动依次执行:受影响原子测试 -> 受影响真实样本 -> 核心 17 -> 扩展能力矩阵 ->
对应全量 shard。完成一个 capability family 后,更新
`ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`,记录新增的通用 contract、剩余边界、
样本列表、比较证据和下一优先级;该本地台账不提交 Git。
全量目标完成时,报告必须按“工程相似”“严格一致”“source exception”“未实现能力”
和“基础设施失败”分别给出分母、样本 ID、工件和原因。任何仍可执行的模型都必须
保留输出,不能因未满足最终几何目标而丢弃。
+29 -24
View File
@@ -3,11 +3,12 @@
## 目标
将 CADFS FeatureScript 历史稳定转换为 CDSL,由 engine 重建 STEP,并与
原始 STEP 做严格几何比较。近期交付门槛是 17 个代表性样本全部严格通过
原始 STEP 做工程几何比较。近期交付门槛是 17 个代表性样本全部达到工程相似
最终交付门槛是当前 CADFS 全量语料中的每个可比较模型都完成同一条链路。
这里的“重建成功”`comparison.json``strict.passed == true`,不是
仅生成 `rebuild.step`,也不是只通过 RP 宽松阈值。
这里的“重建成功”指 `comparison.json``rp.passed == true`,不是仅生成
`rebuild.step``strict.passed` 保留为高精度诊断指标:它用于发现源历史精度、
内核拓扑和局部几何差异,不再作为工程可用模型的唯一验收门槛。
本文件记录目标、阶段门槛和验收口径。实现进度、能力边界和每次代码变更
后的证据记录在本地台账
@@ -22,33 +23,34 @@
FeatureScript 操作、草图实体、已 lower 的 engine atomic 和未支持能力变体。
- 本地能力台账记录该池已有 `17/17 rebuilt` 的可执行 STEP 证据;当前 checked-in
manifest 保留的是历史选择时的 `3` 个 engine baseline / `17` 个 conversion
样本分类。因此,开始严格回归前必须重新生成并核对 manifest,不能将旧分类
样本分类。因此,开始工程回归前必须重新生成并核对 manifest,不能将旧分类
当作当前通过结果。
- 当前全量报告中仍有 `rebuild_failed``rebuild_timeout``comparison_timeout`
`rebuilt_rejected`。这些都是待消除的问题,不可因保留了可执行 STEP 而视为完成。
`comparison.json.decision = rejected`。这些都是待消除的问题;`approximate_pass`
是已重建且工程相似的模型,仍应保留 strict 差异证据供后续能力完善。
## 验收口径
一个样本必须同时具备以下工件和结果,才计入严格重建成功:
一个样本必须同时具备以下工件和结果,才计入工程重建成功:
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`,以及实体数相等。
4. `comparison.json``decision``strict_pass``approximate_pass`,并满足
RP 工程阈值:`surface_max <= 0.02 mm``surface_p99 <= 0.02 mm`
`bbox_max_delta <= 0.02 mm`、体积和表面积相对误差均不超过 `0.005`,以及实体数相等。
5. `status.json`、转换诊断、重建报告和比较报告保存于该样本目录;可执行但不相似
的模型必须保留,而不能在失败时删除。
`approximate_pass` 仅用于定位接近结果,不能通过本计划的 17 样本或全量目标。
缺失原始 STEP、源数据损坏或外部资产不可取得时,必须单独列为 source exception
附原始证据;不得伪装成 engine 或 converter 已完成。
严格比较失败不自动表示 engine 或 converter 失败。缺失原始 STEP、源数据损坏或
外部资产不可取得时,必须单独列为 source exception附原始证据;不得伪装成
engine 或 converter 已完成。
## 第一阶段:17 个代表样本
阶段完成条件:下面 17 个样本全部以 `--compare-mode strict` 重新执行并严格通过
阶段完成条件:下面 17 个样本全部以 `--compare-mode rp` 重新执行并达到工程相似
同时 manifest 的覆盖标签与当前输出一致。每次修复只针对实际失败根因扩展能力;
一个样本可能由多个能力共同阻塞。
@@ -60,7 +62,7 @@
| `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 一致性证据。 |
| `00192744` | circularPattern、extrude、transform | 已执行 `curve_point` 附着 frame、`up_to_next`pattern copy deletion,以及 direct circular `COPY(BODY)` 的 multi-body transform;仍缺完整 pattern/body lifecycle 与 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 的非盲终止。 |
@@ -76,7 +78,7 @@
```bash
PYTHONPATH=backend:. python -m cadfs_to_cdsl regression \
--tier all --stage pipeline --compare-mode strict --timeout-seconds 60
--tier all --stage pipeline --compare-mode rp --timeout-seconds 60
```
命令结束后应检查 17 个 `comparison.json`,而不是只检查 CLI 的进程退出码。
@@ -84,7 +86,7 @@ PYTHONPATH=backend:. python -m cadfs_to_cdsl regression \
## 能力完成目标
能力的完成定义为同一能力的 CADFS lowering、CDSL contract、runtime/adapter、
selector 或 body 语义(需要时)、严格比较回归均具备。仅实现其中一层时只能记为
selector 或 body 语义(需要时)、工程比较回归均具备。仅实现其中一层时只能记为
“部分完成”。优先级依据全量影响数和 17 样本依赖关系确定。
### P0:使特征历史成为完整实体
@@ -139,7 +141,7 @@ SimpleCADAPI 强制将 boolean 结果收敛为单个 `Solid`,不满足 CADFS
## Source Exception Evidence
严格重建失败必须先按下表归类。只有“已验证 source exception”能够从全量
工程重建失败必须先按下表归类。只有“已验证 source exception”能够从全量
成功分母移除;候选项仍是未完成样本,必须保留 source、CDSL、STEP 和比较工件。
| 分类 | 样本 | 当前证据 | 处理要求 |
@@ -148,13 +150,15 @@ SimpleCADAPI 强制将 boolean 结果收敛为单个 `Solid`,不满足 CADFS
| 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 差异候选 | `00192744` | F3/F5 frame`UP_TO_NEXT`F7/F12 `COPY(BODY)` delete 及 F15 的 direct circular COPY multi-body transform 均已完整执行,零 runtime diagnostic。F15 将 F1/F5/F6 COPY 移动约 `-10000 mm` 后,未被选择的 source `NEW` members 留在原处;当前 rebuild 有 22 个实体gold STEP 只有 1 个,面积误差约 `3.3%`RP 失败。 | 保留 executable STEP 和 comparison;继续完成 fused/add、nested、linear/mirror COPY 与 boolean/dress-up 后继的完整 body lifecycle。不得从 gold STEP 回推 union、transform 参数或把 source `NEW` `ADD`。 |
| source / STEP 差异候选 | `00212904` | 原 STEP 出现 source FeatureScript history 未表达的正交 cut strip。 | 保留对比工件;除非 source 能提供缺失 feature,不得在 converter 伪造几何。 |
| source / STEP 精度候选 | `00287955` | 完整 history 已执行并达到 RP。source STEP 的 F1 顶面为 `z=8.128 mm`FeatureScript 只有 `8.13 mm`F3、F5/F6 和 F14 的后续面偏差累计到 `0.002-0.008 mm`,最终体积相对误差为 `1.793e-4`。 | 保留 CDSL 与比较工件;不得根据 source STEP 回填未在 FeatureScript 中出现的精确尺寸。 |
| source / STEP 拓扑候选 | `00542223` | 开放 B-spline F2 已作为 reference 草图完整 lower,完整 history 无诊断并重建为 `1` 个实体、`49775.5119 mm3`。source STEP 虽声明 `MANIFOLD_SOLID_BREP` / `CLOSED_SHELL`OCC 却导入为 `TopAbs_SHELL``0 solids`,并报告 `BRepCheck_NotClosed`。 | 保留严格失败;需要可信 source STEP 或中间 B-rep 后,才能验证完整 shell/mixed-body 语义。 |
| source / STEP 精度候选 | `00835610` | 完整 history 可执行且 RP 通过,严格最大表面差约 `0.014 mm`FeatureScript 值与 STEP 存在约 `0.014 mm` 的 literal 差异。 | 不能从 gold STEP 回填尺寸;继续标为严格失败,等待 source 版本证据。 |
## 全量完成门槛
在 17 样本严格通过后,按 capability family 对全量语料滚动运行,并按失败原因选择
在 17 样本工程通过后,按 capability family 对全量语料滚动运行,并按失败原因选择
新的最小代表样本。最终报告必须以扫描快照中的全部样本为分母,同时满足:
1. 每个有效源样本生成语义有效、可执行的 CDSL;`parse_failed`
@@ -162,8 +166,8 @@ SimpleCADAPI 强制将 boolean 结果收敛为单个 `Solid`,不满足 CADFS
feature 而导致的 `converted_partial` 均为零。
2. 每个拥有原始 STEP 的样本生成完整 `rebuild.step``rebuild_failed`
`rebuild_timeout` 均为零。
3. 每个可比较样本严格通过比较;`comparison_timeout``rebuilt_rejected` 和仅
`rebuilt_approximate` 均不计入完成。
3. 每个可比较样本以 RP 工程阈值通过比较;`comparison_timeout`
`comparison.json.decision = rejected` 和仅有可执行 STEP 的样本均不计入完成。
4. 任何合法 source exception 都有单独清单、原始证据和可复现原因;它不进入成功
分母,也不得吞并为“未分类失败”。
5. 生成全量 capability-gap、比较汇总和逐样本证据,确保任一退化都可定位到
@@ -181,15 +185,16 @@ SimpleCADAPI 强制将 boolean 结果收敛为单个 `Solid`,不满足 CADFS
问题。证据不足时不得猜测分类。
3. 只在责任层修复:转换问题修改 CADFS lowering;执行问题扩展 engine;两者都涉及
时分开提交证据和测试。保留最新可执行 STEP/GLB 和失败诊断。
4. 每项代码改动至少重跑受影响样本、17 样本严格回归和相应单元测试;能力状态与
4. 每项代码改动至少重跑受影响样本、17 样本工程回归和相应单元测试;能力状态与
证据同步到本地能力台账。全量统计变化后重新生成 `regression/manifest.json`
5. 不以关闭诊断、跳过 feature、降低 strict 阈值或用未说明的默认尺寸换取通过
5. 不以关闭诊断、跳过 feature、用未说明的默认尺寸或放宽 RP 阈值换取通过;严格
指标的变化必须作为诊断证据单独说明。
## 阶段交付物
| 阶段 | 必须交付 | 通过条件 |
| --- | --- | --- |
| R0:证据稳定 | 刷新的 regression manifest、17 份完整工件 | 每个样本可重复运行并可定位当前失败。 |
| R1:代表样本 | 17 个严格比较报告、能力台账更新 | `17/17 strict_pass`。 |
| R1:代表样本 | 17 个工程比较报告、能力台账更新 | `17/17 rp.passed`。 |
| R2:能力扩展 | 按能力 family 的 converter/engine/selector 实现及回归 | 对应缺口不再造成该 family 的失败。 |
| R3:全量验证 | 全量 CDSL、STEP、比较结果和汇总报告 | 满足“全量完成门槛”的五项条件。 |
+9 -1
View File
@@ -24,7 +24,15 @@ 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.compare import strict_compare
# ``onshape_to_cdsl`` is a sibling src-layout package. CADFS commands
# intentionally require only ``PYTHONPATH=backend:.`` so they must work
# from a source checkout as well as an installed package.
try:
from onshape_to_cdsl.compare import strict_compare
except ModuleNotFoundError as error:
if error.name != "onshape_to_cdsl.compare":
raise
from onshape_to_cdsl.src.onshape_to_cdsl.compare import strict_compare
raw = strict_compare(
gold_step,
rebuilt_step,
+2205 -171
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -117,6 +117,12 @@ def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_se
if outcome == "completed":
result = read_json(worker_result); worker_result.unlink(missing_ok=True)
bound_cdsl = result.pop("bound_cdsl", None)
prefix = result.get("last_executable_prefix")
if isinstance(prefix, dict):
prefix_bound_cdsl = prefix.pop("bound_cdsl", None)
if bound_cdsl is None and prefix_bound_cdsl is not None:
bound_cdsl = prefix_bound_cdsl
prefix["bound_feature_count"] = prefix.get("feature_count")
if bound_cdsl is not None: write_json(directory / "bound.cdsl.json", bound_cdsl)
else:
step_path.unlink(missing_ok=True)
@@ -144,6 +150,10 @@ def compare_one(sample: Sample, output: Path, *, force: bool = False, compare_mo
message = f"comparison exceeded {timeout_seconds:g} seconds" if outcome == "timeout" else "comparison worker exited without a result"
status["comparison_error"] = {"type": error_type, "message": message}; write_json(status_path, status); return status
else: comparison = read_json(comparison_path)
# A forced retry can recover from a transient worker timeout. The final
# status must describe the successful comparison rather than retaining a
# stale infrastructure failure beside an accepted result.
status.pop("comparison_error", None)
status["comparison_decision"] = comparison["decision"]
accepted = comparison[compare_mode]["passed"]
status["status"] = "rebuilt_strict" if comparison["strict"]["passed"] else "rebuilt_approximate" if accepted else "rebuilt_rejected"
+49 -1
View File
@@ -1,9 +1,52 @@
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
import re
from typing import Any
def _last_executable_prefix(
cdsl: dict[str, Any],
failed_feature_id: str | None,
output: Path,
) -> dict[str, Any] | None:
"""Bind and export the longest verified prefix before a failed feature."""
from .selector_binding import bind_candidate_selectors
from engine.cdsl_engine.runtime import rebuild_cdsl
features = list(cdsl.get("features") or [])
failed_index = next(
(index for index, feature in enumerate(features) if feature.get("id") == failed_feature_id),
len(features),
)
for feature_count in range(failed_index, 0, -1):
prefix = deepcopy(cdsl)
prefix["features"] = features[:feature_count]
try:
bound_prefix, _binding = bind_candidate_selectors(prefix)
result = rebuild_cdsl(bound_prefix, output, strict=True)
except Exception:
continue
return {
"failed_feature_id": failed_feature_id,
"feature_count": feature_count,
"last_feature_id": str(features[feature_count - 1].get("id") or ""),
"bound_cdsl": bound_prefix,
"result": result,
}
return None
def _failed_feature_id(error: Exception) -> str | None:
diagnostic = getattr(error, "diagnostic", None)
feature_id = getattr(diagnostic, "feature_id", None)
if isinstance(feature_id, str) and feature_id:
return feature_id
match = re.match(r"([^:\s]+): ", str(error))
return match.group(1) if match else None
def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]:
from engine.cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl
from .selector_binding import bind_candidate_selectors
@@ -11,6 +54,7 @@ def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]:
analysis_dict = analysis.as_dict() if hasattr(analysis, "as_dict") else {"runtime_eligible": analysis.runtime_eligible}
if not analysis.runtime_eligible:
return {"status": "runtime_ineligible", "analysis": analysis_dict}
bound: dict[str, Any] | None = None
try:
bound, binding = bind_candidate_selectors(cdsl)
result = rebuild_cdsl(bound, output, strict=True)
@@ -18,4 +62,8 @@ def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]:
except Exception as exc:
detail = {"type": type(exc).__name__, "message": str(exc)}
if hasattr(exc, "selector_resolutions"): detail["selector_resolutions"] = exc.selector_resolutions
return {"status": "rebuild_failed", "analysis": analysis_dict, "error": detail}
prefix = _last_executable_prefix(cdsl, _failed_feature_id(exc), output)
result = {"status": "rebuild_failed", "analysis": analysis_dict, "error": detail}
if bound is not None: result["bound_cdsl"] = bound
if prefix is not None: result["last_executable_prefix"] = prefix
return result
+73 -4
View File
@@ -10,7 +10,7 @@ from typing import Any
def _score(expected: dict[str, Any], actual: dict[str, Any]) -> float | None:
scores: list[float] = []
reversed_plane_normal = False
for key in ("center_mm", "start_mm", "end_mm", "normal", "axis_origin_mm", "axis_direction"):
for key in ("center_mm", "circle_center_mm", "start_mm", "end_mm", "normal", "axis_origin_mm", "axis_direction"):
if key in expected:
# plane_offset_mm 与平面方程绑定,必须使用记录平面方程时采用的
# plane_normal。face 的局部采样 normal 在 OCC 中可能与其相反。
@@ -101,6 +101,7 @@ def _bound_selector(placeholder: dict[str, Any], records: list[dict[str, Any]])
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"]
if placeholder.get("owner_match_required"): selector["owner_match_required"] = True
bound.append(selector)
if placeholder.get("match_mode") == "all":
selector = dict(placeholder); selector["matched_selectors"] = bound
@@ -150,23 +151,76 @@ def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list
resolved = []
for placeholder in targets:
records = prefix_records(placeholder.get("binding_feature_id"), placeholder.get("owner_feature_id"))
output_role = str(placeholder.get("output_role") or "").strip()
if output_role:
# Builder output roles are not geometry placeholders. They
# remain in the bound CDSL so runtime can resolve the
# current active B-rep face through exact kernel history.
# Replacing one with stable_id/geometry would mix evidence
# and make a stale snapshot appear durable.
owner = placeholder.get("owner_feature_id")
role_source = placeholder.get("output_role_source")
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
candidates = [
record for record in records
if record.get("kind") == placeholder.get("kind")
and owner in (record.get("owner_feature_ids") or [record.get("feature_id")])
and output_role in (record.get("output_roles") or [])
and (
role_source is None
or any(
item.get("output_role") == output_role
and item.get("owner_feature_id") == source_owner
and item.get("source_output_role") == source_role
for item in record.get("output_role_sources") or []
)
)
]
if len(candidates) != 1:
status = "not_found" if not candidates else "ambiguous"
raise ValueError(
f"{feature['id']}: selector_output_role_{status} after prefix rebuild"
)
resolved.append(candidates[0])
continue
geometry = placeholder.get("geometry") or {}
candidates = _circle_records(geometry, records) if geometry.get("source_circle_radius_mm") else []
if not candidates:
same_kind = [record for record in records if record.get("kind") == placeholder.get("kind")]
owner = placeholder.get("owner_feature_id")
owner_matches = [record for record in same_kind if owner in (record.get("owner_feature_ids") or [record.get("feature_id")])]
pool = owner_matches or same_kind
# Pattern instance provenance is stronger than an ordinary
# feature owner: an unresolved instance cannot fall back to
# an aggregate face with matching geometry. Ordinary source
# selectors retain their established geometry-binding path
# across topology-changing dress-up operations.
pool = owner_matches if placeholder.get("owner_match_required") else (owner_matches or same_kind)
if not geometry:
# Context selectors (notably a generated mirror plane)
# may have no geometric snapshot. Their owner-qualified
# singleton identity is sufficient and must not be scored
# as a zero-information geometric match.
if len(pool) != 1:
raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild")
status = "not_found" if not pool else "ambiguous"
raise ValueError(f"{feature['id']}: selector_{status} after prefix rebuild")
candidates = [pool[0]]
else:
scored = [(score, record) for record in pool if (score := _score(geometry, record.get("geometry") or {})) is not None and score >= 0.8]
# A source owner is preferred for ordinary geometry
# selectors, but can be retained by an unrelated exact
# continuation after a dress-up. If none of that
# owner's active records satisfies the full geometry
# signature, bind against the current active body and
# still require a unique threshold-qualified match.
# Instance-qualified selectors never take this path.
if not scored and not placeholder.get("owner_match_required"):
scored = [
(score, record)
for record in same_kind
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 placeholder.get("match_mode") != "all" and len(scored) > 1 and abs(scored[0][0] - scored[1][0]) <= 1e-9:
@@ -177,7 +231,22 @@ def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list
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())
def selector_key(selector: dict[str, Any]) -> tuple[Any, ...]:
stable_id = selector.get("stable_id")
if stable_id is not None:
return ("stable_id", str(stable_id))
source = selector.get("output_role_source")
return (
"output_role",
selector.get("owner_feature_id"),
selector.get("kind"),
selector.get("output_role"),
source.get("owner_feature_id") if isinstance(source, dict) else None,
source.get("output_role") if isinstance(source, dict) else None,
)
unique = {selector_key(selector): 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:
+71
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from copy import deepcopy
import tempfile, unittest
from pathlib import Path
from cadfs_to_cdsl.compare import compare_steps
@@ -130,6 +131,32 @@ class IntegrationTests(unittest.TestCase):
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual(outcome["result"]["solid_count"], 2)
def test_outward_cap_shells_lower_and_rebuild_prefixes(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
cases = (("00090436", 2.5), ("00107631", 3.8))
for sample_id, thickness in cases:
with self.subTest(sample_id=sample_id):
feature = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), sample_id), {})
shell_index, shell = next(
(index, item)
for index, item in enumerate(result.cdsl["features"])
if item["id"] == "f_F2"
)
self.assertEqual(shell["atomic_id"], "shell")
self.assertEqual(shell["params"], {"thickness_mm": thickness, "inward": False})
self.assertNotIn("shell_outward", {item.get("capability") for item in result.diagnostics})
prefix = deepcopy(result.cdsl)
prefix["features"] = prefix["features"][:shell_index + 1]
with tempfile.TemporaryDirectory() as tmp:
outcome = rebuild_candidate(prefix, Path(tmp) / "outward-shell.step")
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual(outcome["result"]["solid_count"], 1)
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"
@@ -137,13 +164,57 @@ class IntegrationTests(unittest.TestCase):
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(result.status, "converted_complete")
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("F2", [item.get("feature_id") for item in result.diagnostics])
self.assertNotIn("F5", [item.get("feature_id") for item in result.diagnostics])
def test_circular_pattern_00542223_preserves_all_sweep_arms(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"), {})
candidate = dict(result.cdsl)
candidate["features"] = result.cdsl["features"][:4]
from engine.cdsl_engine.runtime import rebuild_cdsl
with tempfile.TemporaryDirectory() as tmp:
rebuilt = rebuild_cdsl(candidate, Path(tmp) / "pattern.step")
self.assertEqual(rebuilt["solid_count"], 1)
bbox = rebuilt["bbox_mm"]
self.assertAlmostEqual(bbox["min"][0], -52.14101625137758)
self.assertAlmostEqual(bbox["min"][1], -57.500000100000065)
self.assertAlmostEqual(bbox["max"][0], 52.14101625137762)
self.assertAlmostEqual(bbox["max"][1], 37.5000001000001)
def test_fused_body_circular_copy_faces_bind_and_shell_00542223(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")
candidate = lower_model(parse_featurescript(feature.read_text(), "00542223"), {}).cdsl
with tempfile.TemporaryDirectory() as tmp:
outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step")
self.assertEqual(outcome["status"], "rebuilt")
result = outcome["result"]
self.assertIn("f_F7", [item["feature_id"] for item in result["feature_results"]])
shell_selectors = [
item for item in result["selector_resolution"]
if item["feature_id"] == "f_F7"
]
self.assertEqual(
{item["selector"]["owner_feature_id"] for item in shell_selectors},
{"f_F5", "f_F6.c1.f_F5", "f_F6.c2.f_F5", "f_F1"},
)
self.assertTrue(all(item["status"] == "resolved" for item in shell_selectors))
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"
File diff suppressed because it is too large Load Diff
+135 -20
View File
@@ -1,10 +1,14 @@
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
from cadfs_to_cdsl.lowering import lower_model
from cadfs_to_cdsl.rebuild import rebuild_candidate
from cadfs_to_cdsl.selector_binding import _score, bind_candidate_selectors
@@ -33,6 +37,66 @@ class SelectorBindingTests(unittest.TestCase):
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_geometry_free_context_selector_uses_unique_active_record_when_owner_is_stale(self) -> None:
cdsl = {
"features": [
{"id": "f_source", "atomic_id": "reference_plane"},
{
"id": "f_mirror",
"atomic_id": "pattern_mirror",
"params": {},
"selectors": [{"kind": "plane", "owner_feature_id": "f_source"}],
},
],
}
report = {
"feature_results": [],
"topology_records": [{
"kind": "plane",
"record_id": "context:plane:1",
"owner_feature_ids": ["f_live_plane"],
"geometry": {},
}],
}
with patch("engine.cdsl_engine.runtime.rebuild_cdsl", return_value=report):
bound, _ = bind_candidate_selectors(cdsl)
selector = bound["features"][1]["selectors"][0]
self.assertEqual(selector["owner_feature_id"], "f_live_plane")
self.assertEqual(selector["stable_id"], "context:plane:1")
self.assertEqual(bound["features"][1]["params"]["mirror_plane"], selector)
def test_geometry_free_instance_selector_does_not_fall_back_to_active_record(self) -> None:
cdsl = {
"features": [
{"id": "f_source", "atomic_id": "reference_plane"},
{
"id": "f_mirror",
"atomic_id": "pattern_mirror",
"params": {},
"selectors": [{
"kind": "plane",
"owner_feature_id": "f_source",
"owner_match_required": True,
}],
},
],
}
report = {
"feature_results": [],
"topology_records": [{
"kind": "plane",
"record_id": "context:plane:1",
"owner_feature_ids": ["f_live_plane"],
"geometry": {},
}],
}
with patch("engine.cdsl_engine.runtime.rebuild_cdsl", return_value=report):
with self.assertRaisesRegex(ValueError, "f_mirror: selector_not_found after prefix rebuild"):
bind_candidate_selectors(cdsl)
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}))
@@ -50,28 +114,79 @@ class SelectorBindingTests(unittest.TestCase):
self.assertEqual(score, 1.0)
def test_intersection_vertex_binds_pattern_and_current_body_prefixes(self) -> None:
def test_cap_face_output_role_is_validated_without_snapshot_rebinding(self) -> None:
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0001/00016195.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00016195"), {})
bound, evidence = bind_candidate_selectors(candidate.cdsl)
selector = next(item for item in bound["features"] if item["id"] == "f_F3")["selectors"][0]
self.assertEqual(selector, {
"kind": "face", "owner_feature_id": "f_F1", "output_role": "extrude.end",
"source": "runtime_snapshot", "confidence": 1.0,
})
self.assertNotIn("stable_id", selector)
self.assertNotIn("snapshot_id", selector)
self.assertNotIn("geometry", selector)
binding = next(item for item in evidence if item["feature_id"] == "f_F3")
self.assertEqual(len(binding["resolved"]), 1)
self.assertEqual(binding["resolved"][0]["output_roles"], ["extrude.end"])
def test_shell_offset_face_role_binds_only_its_true_dependency_source(self) -> None:
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0010/00107631.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00107631"), {})
self.assertEqual(candidate.status, "converted_complete")
# F2 retains F1's end-cap provenance, but F3 also selects the distinct
# start-cap plane. Its direct owner has no geometry-qualified active
# candidate, so binding must consider the unique active F2 face without
# weakening the exact qualified OFFSET_FACE role.
prefix = deepcopy(candidate.cdsl)
prefix["features"] = prefix["features"][:3]
bound, evidence = bind_candidate_selectors(prefix)
first, offset = bound["features"][-1]["selectors"]
self.assertEqual(first["owner_feature_id"], "f_F2")
self.assertEqual(first["stable_id"], "body:f_F2:face:1")
self.assertEqual(first["snapshot_id"], "body:f_F2:face:1")
self.assertEqual(first["source"], "runtime_snapshot")
self.assertEqual(first["confidence"], 1.0)
self.assertEqual(offset, {
"kind": "face", "owner_feature_id": "f_F2", "output_role": "shell.offset_face",
"output_role_source": {"owner_feature_id": "f_F1", "output_role": "extrude.start"},
"source": "runtime_snapshot", "confidence": 1.0,
})
binding = next(item for item in evidence if item["feature_id"] == "f_F3")
self.assertEqual(binding["resolved"][0]["snapshot_id"], "body:f_F2:face:1")
self.assertEqual(binding["resolved"][1]["record_id"], "body:f_F2:face:5")
# Binding must not hide the OCC feasibility boundary. The requested
# second shell currently produces an invalid shape, so preserve the
# F2 prefix instead of changing thickness or removal faces.
with tempfile.TemporaryDirectory() as directory:
outcome = rebuild_candidate(candidate.cdsl, Path(directory) / "00107631.step")
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["type"], "RuntimeExecutionError")
self.assertEqual(outcome["error"]["message"], "OCC shell operation produced an invalid shape")
self.assertEqual(outcome["last_executable_prefix"]["failed_feature_id"], "f_F3")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F2")
def test_intersection_vertex_binds_a_pattern_copy_with_exact_instance_owner_evidence(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)
reference = next(item for item in candidate.cdsl["features"] if item["id"] == "f_F7")["params"]["end_condition"]["reference"]
self.assertTrue(all(item["owner_match_required"] for item in reference["intersection_of"][:2]))
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)
bound, evidence = bind_candidate_selectors(candidate.cdsl)
reference = next(item for item in bound["features"] if item["id"] == "f_F7")["params"]["end_condition"]["reference"]
copy_components = reference["intersection_of"][:2]
self.assertTrue(all(item["owner_feature_id"] == "f_F4.c4.f_F1" for item in copy_components))
self.assertTrue(all(item.get("snapshot_id") for item in copy_components))
binding = next(item for item in evidence if item["feature_id"] == "f_F7")
self.assertGreaterEqual(len(binding["resolved"]), 2)
+5 -5
View File
@@ -1,20 +1,20 @@
{
"schema": "cdsl.corpus-manifest.v1",
"corpus_version": "2026-09-07",
"corpus_version": "2026-08-31",
"document_count": 2881,
"document_stems_sha256": "4c8660ed96f3b5427ad26adadd9b82df447951913920542501ef0fc479c02773",
"document_stems_sha256": "ba5b905191817cd0f7b9ad07d4002ad27730e200a395f71f9c4e31ebb1867cd6",
"phase_pools": {
"p3": {
"selector_count": 771,
"selectors_sha256": "2c0f76833cd08592b71ef3a5b5b1548b649ac485e2b2388358426f815c21a047"
"selectors_sha256": "72093701357cd1858f2dbcc0f1ab9e6c7baea7db26357154e0586e5e1be6f801"
},
"p4": {
"selector_count": 826,
"selectors_sha256": "c031552f9ae29f03ca58545d90dc8b16e2b5963f0923ff075e3724f066a2bf3d"
"selectors_sha256": "ab9ea6edd8532ce7a0110d0725b5303c63a53617a0af502101b12f657285d371"
},
"p6": {
"selector_count": 893,
"selectors_sha256": "de44a06e55aa21940e9ccb02d0deaba90bb7669d8fb93a62bf976c903a18c42f"
"selectors_sha256": "ac9f91b690bc533b1fdd1772d65ee77b248d108f39a429615d909b78332d9a08"
}
}
}