Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f79c28759d | |||
| a5c3ec1f36 | |||
| 2f89d7930c |
@@ -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 明确支持。
|
||||
|
||||
@@ -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.sweep:make_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])
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 graph:feature 输出 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 member,aggregate 只用于 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 的受限 lineage,direct
|
||||
`SWEPT_BODY` 可从初始 `new_body` 经 ordinary fusing add、单 body fillet/chamfer/shell
|
||||
与 exact non-copy transform 续接到当前 CDSL member;cut 保留所选 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 member;CDSL 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 relation,registry 只在 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 role;registry 只会在
|
||||
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 resolver:owner 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 prism,shell 恰移除该 prism 的一个 CAP,且 OFFSET query 恰指向
|
||||
其未修改、单闭合凸线性 profile 中的一条非 construction line,lowering 才根据 source
|
||||
profile 的相邻边 offset 交点、shell thickness、实际 start/end cap frame 构造四边内壁。
|
||||
它不复用整张 source sketch,也不从 STEP 反推 trim;draft、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 都完整 lower,F1--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 附着 F6,F5 后该 F4 owner 仍有唯一
|
||||
continuation,F7 的 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 side;solver
|
||||
仅转换这些精确曲线,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 source,solver 现在保留一条带 workplane frame 的 OCC circle edge,而不将
|
||||
它预拆为四条 contour arc;fragment 的 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 后不再有独立 member;runtime 仅在 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。实现参考
|
||||
SimpleCADAPI(Apache-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 推断
|
||||
plane;partial revolve、非 revolve、curve/derived face 或任一未解析 frame 都保留原有
|
||||
mirror-plane diagnostic。`00000385` 的 F5 是正例(XY plane,F5 executed);
|
||||
`00023074` 保留拒绝边界。五样本
|
||||
`output/mirror-revolve-plane-matrix-20260909` 为 2 RP、3 rejected,且只移除了
|
||||
`00000385` F5 的 plane defer,F2 chamfer 与 F7 fillet 仍如实诊断。`booleanBodies`
|
||||
现在也有一个独立、受限的 COPY body contract:direct `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、工件和原因。任何仍可执行的模型都必须
|
||||
保留输出,不能因未满足最终几何目标而丢弃。
|
||||
@@ -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` 环。对于 F10,runtime 仅在显式同轴 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、比较结果和汇总报告 | 满足“全量完成门槛”的五项条件。 |
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user