Merge pull request 'fix(engine): 支持显式坐标选择器的阵列变换' (#4) from ganjihong into main

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-08-27 19:00:02 +08:00
18 changed files with 2673 additions and 78 deletions
@@ -5,7 +5,7 @@ from __future__ import annotations
import math
from typing import Any, Iterable
from build123d import Axis, Edge, Face, Plane, Solid, Vector, Wire, export_step
from build123d import Axis, Compound, Edge, Face, Plane, ShapeList, Solid, Vector, Wire, export_step
from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, TopologyRecord, Vector3, canonical_plane_signature
@@ -151,6 +151,43 @@ class Build123dGeometryAdapter:
# 沿给定方向向量拉伸一个面,生成实体。
return Solid.extrude(face, _vector(direction))
@staticmethod
def extrude_trimmed(face: Face, target: Any, direction: Vector3) -> Any:
"""Extrude the profile to the target face, trimming unreached regions.
Issue #5: when a profile intersects the up_to_surface target
non-uniformly (part of the profile reaches the face, part hangs
outside it), a plain vector extrusion is wrong. The CAD semantics is
to keep only the material between the profile and the target. We
pierce the profile through the target, push the target face backward
by the same margin to build a slab, and keep their boolean common
(intersection) as the trimmed solid.
"""
# 1. 采样点到目标的最远命中距离决定穿透余量;没有任何采样点命中
# 说明 profile 与目标面无交叠,无法裁剪(保留 extent_target_not_reached)。
unit = _vector(direction).normalized()
hits = [
Build123dGeometryAdapter._forward_intersection_distance(target, point, unit)
for point in Build123dGeometryAdapter.profile_sample_points(face)
]
distances = [value for value in hits if value is not None]
if not distances:
raise ValueError("extent target is not reached by the profile")
margin = max(distances) + 2.0
# 2. 穿透拉伸 profile,同时把目标面向回推生成体层,二者求交即裁剪体。
# build123d 的布尔交方法名是 intersect(不是 OCC 的 common),
# 且多实体结果返回 ShapeList,需要规整为单个 Solid / Compound。
pierced = Solid.extrude(face, unit * margin)
slab = Solid.extrude(target, -unit * margin)
trimmed = pierced.intersect(slab)
if isinstance(trimmed, ShapeList):
members = list(trimmed)
# build123d 类型桩未声明 make_compound,但运行时存在(宽泛类型桩噪音)。
trimmed = members[0] if len(members) == 1 else Compound.make_compound(members) # pyright: ignore[reportAttributeAccessIssue]
if trimmed is None or (hasattr(trimmed, "is_empty") and trimmed.is_empty()):
raise ValueError("extent target produced an empty trimmed solid")
return trimmed
@staticmethod
def body_center(body: Any) -> Vector3:
# 取主体包围盒的中心坐标,作为体心的近似。
@@ -236,8 +273,9 @@ class Build123dGeometryAdapter:
return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis))
@staticmethod
def fuse(body: Any | None, solid: Solid) -> Any:
def fuse(body: Any | None, solid: Any) -> Any:
# 布尔并:没有既有主体时,直接以该实体作为新主体。
# 实参类型放宽为 Any:build123d 的布尔结果可能是 Solid 或 Compound。
return solid if body is None else body.fuse(solid)
@staticmethod
@@ -347,6 +385,16 @@ class Build123dGeometryAdapter:
# 将主体导出为 STEP 文件。
export_step(body, path)
@staticmethod
def body_solids(body: Any) -> list[Any]:
# 提取主体内的全部独立 Solid:Compound 返回成员,单个 Solid 返回自身。
# build123d 对部分退化布尔结果可能抛异常,退化为把主体整体视为一个实体。
try:
solids = list(body.solids())
except Exception:
return [body] if body is not None else []
return solids or ([body] if body is not None else [])
@staticmethod
def body_geometry(body: Any) -> dict[str, Any]:
# 汇总主体基本几何信息:包围盒与体积。
+60 -8
View File
@@ -65,21 +65,58 @@ def _has_explicit_host_frame(params: dict[str, Any]) -> bool:
return isinstance(frame, dict) and all(frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal"))
def _is_host_face_source(source: FeaturePlanNode) -> bool:
"""Whether a ``face`` selector on the source is a fixed host face.
A patterned hole keeps its host face: the instance positions travel with
the pattern (``_translated_node`` shifts ``positions``) while the face
itself is resolved unchanged, so the face selector can stay untouched
(issue #6). A face selector on anything else (for example a fillet
selecting a face) would need per-instance edge geometry and must stay
blocked.
"""
return source.atomic_id in _HOLE_ATOMICS or (
isinstance(source.params.get("host_face"), dict)
and source.params.get("host_face", {}).get("kind") == "face"
)
def pattern_transform_blocker(source: FeaturePlanNode) -> str | None:
"""Return the selector dependency that cannot be transformed exactly.
An explicit host frame is coordinate data, not a topology guess. It can
be transformed with a patterned instance while preserving local hole
positions. All topology selectors and selector-dependent extents remain
blocked until their geometry transform contract is implemented.
Selectors fully captured by explicit coordinate data (a revolve axis with
origin/direction, or a host face with a complete world frame) are
transformed together with each patterned instance and are not blockers.
A patterned hole's host face and an up_to_surface extrusion's target face
are *fixed* body faces rather than instance geometry: positions and
profiles travel with the instance while the face itself is resolved
unchanged (issue #6). Selectors that must follow the instance but cannot
be translated (edges, vertices, …) stay blocked until their geometry
transform contract is implemented.
"""
if source.selectors:
for selector in source.selectors or ():
if selector.get("kind") == "axis" and _has_explicit_axis(source.params.get("axis")):
# The axis is coordinate data; _translated_node shifts its origin.
continue
if selector.get("kind") == "face" and _is_host_face_source(source):
# 孔宿主面:主体上的固定面,不随实例平移;实例位置由 positions
# 平移决定(_translated_node),selector 原样保留即可正确 resolve。
continue
return "feature selector"
host = source.params.get("host_face")
if host is not None and not _has_explicit_host_frame(source.params):
return "host face selector"
# 无 frame 的孔:宿主面以 face selector 形式给出(host_face 自身或
# selectors 列表)→ 上面的 face 分支已放行;其它形态(无 frame 也
# 非 face selector)仍阻塞。
if not (isinstance(host, dict) and host.get("kind") == "face"):
return "host face selector"
end_condition = source.params.get("end_condition") or {}
if isinstance(end_condition, dict) and isinstance(end_condition.get("reference"), dict):
# up_to_surface / offset_from_surface 的终止面是主体上的固定面:
# 不随实例平移,reference 原样保留即可正确 resolve(#5 裁剪已支持
# 非均匀相交)。顶点/主体目标无法构造"固定终止面",仍显式阻塞。
if end_condition["reference"].get("kind") == "face":
return None
return "extent target selector"
return None
@@ -227,6 +264,17 @@ class CapabilityAnalyzer:
sketch_id=node.sketch_id,
))
if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES):
# #2 draftextrudeParams.draft 在 cdsl_schema.json 中被允许,
# 但 runtime 的拉伸执行器(build123d Solid.extrude)没有锥形
# 拉伸能力,人读契约 profile_schema.json 也未声明该参数。
# 若 importer 把 SolidWorks 的 draft_angle_rad 写进 CDSL
# 当前 runtime 会静默产出无拔模角的直壁实体。这里把它从
# "静默忽略"改为"显式拒绝"(与 unsupported_extent 同模式)。
if params.get("draft"):
blockers.append(self._blocker(
node.feature_id, "unsupported_draft",
"Extrude draft/taper is not implemented; the runtime would silently ignore it",
))
end_condition = params.get("end_condition") or {"type": "blind"}
end_type = end_condition.get("type")
required.append(f"extent:{end_type}")
@@ -296,8 +344,12 @@ class CapabilityAnalyzer:
except ValueError as error:
blockers.append(self._blocker(node.feature_id, "invalid_hole_spec", str(error)))
if node.atomic_id == "hole_wizard":
if params.get("thread"):
blockers.append(self._blocker(node.feature_id, "unsupported_hole_subtype", "Threaded Hole Wizard geometry is not represented by the current CDSL runtime"))
# #9 hole threadSolidWorks 螺纹孔的 thread 是装饰信息(无螺距、
# 不进实体几何,STEP 导出即光滑孔)。HoleSpec.from_feature 只读
# 直径/深度/位置/沉头/沉孔,thread 天然不参与几何计算 → 孔特征
# 直接按光滑圆柱孔执行,runtime 侧记录 thread_decoration_ignored
# info 诊断便于追溯(见 _execute_hole)。不再报
# unsupported_hole_subtype,使 ≈712 个带 thread 的孔恢复可执行。
hole_extent = (params.get("end_condition") or {"type": "blind"}).get("type")
if hole_extent not in {"blind", "through_all", "through_all_both"}:
blockers.append(self._blocker(
+18 -13
View File
@@ -46,22 +46,27 @@
"additionalProperties": false
},
"hostFace": {
"type": "object",
"properties": {
"frame": {
"oneOf": [
{
"type": "object",
"properties": {
"origin_mm": {"$ref": "#/$defs/point3"},
"x_dir": {"$ref": "#/$defs/point3"},
"y_dir": {"$ref": "#/$defs/point3"},
"normal": {"$ref": "#/$defs/point3"}
"frame": {
"type": "object",
"properties": {
"origin_mm": {"$ref": "#/$defs/point3"},
"x_dir": {"$ref": "#/$defs/point3"},
"y_dir": {"$ref": "#/$defs/point3"},
"normal": {"$ref": "#/$defs/point3"}
},
"required": ["origin_mm", "x_dir", "y_dir", "normal"],
"additionalProperties": false
}
},
"required": ["origin_mm", "x_dir", "y_dir", "normal"],
"required": ["frame"],
"additionalProperties": false
}
},
"required": ["frame"],
"additionalProperties": false
},
{"$ref": "#/$defs/selectorRef"}
]
},
"holePosition": {
"type": "object",
@@ -234,7 +239,7 @@
"depth_mm": {"type": "number", "minimum": 0},
"end_condition": {"$ref": "#/$defs/endCondition"},
"positions": {"type": "array", "items": {"$ref": "#/$defs/holePosition"}},
"host_face": {"$ref": "#/$defs/selectorRef"},
"host_face": {"$ref": "#/$defs/hostFace"},
"thread": {"type": "object"},
"countersink": {"type": "object"},
"counterbore": {"type": "object"}
+122 -23
View File
@@ -44,6 +44,21 @@ class FeatureExecutionError(RuntimeError):
self.detail = detail
@dataclass(frozen=True)
class ExtentVector:
"""Single-directional extrusion displacement for one profile face.
``trim_to`` stays ``None`` for an exact vector extrusion. When set, the
extent means "extrude until the target face, trimming any profile region
that does not reach it" (up_to_surface trim semantics, issue #5). The
piercing distance is computed inside the adapter, so ``vector`` only
supplies the direction.
"""
vector: Vector3
trim_to: Any | None = None
class AtomicExecutor(Protocol):
atomic_id: str
@@ -59,9 +74,11 @@ class GeometryAdapter(Protocol):
"""
def topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ...
def body_solids(self, body: Any) -> list[Any]: ...
def body_geometry(self, body: Any) -> dict[str, Any]: ...
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
def extrude(self, face: Any, direction: Vector3) -> Any: ...
def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ...
def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def fuse(self, body: Any | None, solid: Any) -> Any: ...
def cut(self, body: Any, tool: Any) -> Any: ...
@@ -92,9 +109,23 @@ class ExecutionSession:
active_feature_id: str = ""
def register_body(self, feature_id: str, body: Any, *, replay_node: FeaturePlanNode | None = None) -> None:
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id"
# 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标
# 实体;单体保持 body:{feature}(与历史行为完全一致)。
self.body = body
self.body_id = f"body:{feature_id}"
self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id))
solids = self.adapter.body_solids(body)
if len(solids) <= 1:
self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id))
else:
for index, solid in enumerate(solids):
member_id = f"{self.body_id}:{index}"
self.topology.replace_body_topology(
feature_id, member_id,
self.adapter.topology_records(solid, feature_id, member_id),
active_body_id=self.body_id,
)
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,),
@@ -163,7 +194,7 @@ def _targeted_extent_vector(
*,
end_condition: dict[str, Any] | None = None,
offset_mm: float | None = None,
) -> Vector3:
) -> ExtentVector:
if session.body is None:
raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition)
if condition == "through_next":
@@ -197,8 +228,20 @@ def _targeted_extent_vector(
try:
distance = session.adapter.uniform_intersection_distance(target, faces, direction)
except ValueError as error:
code = "non_uniform_extent_target" if "non-uniform" in str(error) else "extent_target_not_reached"
raise FeatureExecutionError(code, str(error), extent=condition) from error
message = str(error)
code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached"
if condition == "up_to_surface":
# #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未
# 命中目标 → 悬空;或各点命中距离不一 → 斜目标面)时不再整体
# 拒绝,而是"裁剪"——只保留从 profile 到目标面之间的材料。
# extrude_trimmed 内部做穿透拉伸 + 与目标面体层布尔求交,未达
# 目标的部分被切掉(CAD "拉伸到面"标准语义)。若全部采样点都
# 未命中(profile 与目标面无交叠),extrude_trimmed 内部仍抛
# "not reached",保持显式拒绝。
# up_to_vertex/up_to_body/offset_from_surface 无 face 可构造
# 裁剪体层,仍保持显式拒绝。
return ExtentVector(vector_scale(direction, 1.0), trim_to=target)
raise FeatureExecutionError(code, message, extent=condition) from error
if condition == "offset_from_surface":
offset = abs(float(offset_mm if offset_mm is not None else node.params.get("distance_mm") or 0.0))
distance -= offset
@@ -208,7 +251,7 @@ def _targeted_extent_vector(
"Offset distance reaches or passes the target surface",
extent=condition, offset_mm=offset,
)
return vector_scale(direction, distance)
return ExtentVector(vector_scale(direction, distance))
def _side_extent_vectors(
@@ -219,7 +262,7 @@ def _side_extent_vectors(
*,
end_condition: dict[str, Any],
distance_mm: float,
) -> list[Vector3]:
) -> list[ExtentVector]:
"""Resolve one directional extent without borrowing the opposite side.
``extrude_add_two_sided`` calls this once for each independently captured
@@ -231,17 +274,22 @@ def _side_extent_vectors(
if condition == "blind":
if distance <= 0:
raise ValueError("blind extent requires distance_mm > 0")
return [vector_scale(direction, distance)]
return [ExtentVector(vector_scale(direction, distance))]
if condition == "mid_plane":
if distance <= 0:
raise ValueError("mid_plane extent requires distance_mm > 0")
return [vector_scale(direction, distance / 2), vector_scale(direction, -distance / 2)]
return [
ExtentVector(vector_scale(direction, distance / 2)),
ExtentVector(vector_scale(direction, -distance / 2)),
]
if condition == "through_all":
if session.body is None:
if distance <= 0:
raise ValueError("through_all on an initial feature has no body and no fallback distance")
return [direction * distance]
return [vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0)]
# 注意:Vector3 是 tuple,不能直接做 direction * distance(那是元组
# 重复),这里必须用 vector_scale 做数乘(顺带修复的隐藏 bug)。
return [ExtentVector(vector_scale(direction, distance))]
return [ExtentVector(vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0))]
if condition in {"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body"}:
return [
_targeted_extent_vector(
@@ -257,7 +305,7 @@ def _extent_vectors(
faces: list[Any],
sketch: dict[str, Any],
session: ExecutionSession,
) -> list[Vector3]:
) -> list[ExtentVector]:
params = node.params
normal = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal")
if bool(params.get("reverse")):
@@ -285,16 +333,19 @@ def _extent_vectors(
# against. The source must provide a usable blind component.
if distance <= 0:
raise ValueError("through_all on an initial feature has no body and no fallback distance")
return [vector_scale(normal, distance)]
return [ExtentVector(vector_scale(normal, distance))]
span = max(session.adapter.body_span(session.body, normal), 1.0) + 2.0
if condition == "through_all":
return [vector_scale(normal, span)]
return [ExtentVector(vector_scale(normal, span))]
if condition == "through_all_both":
return [vector_scale(normal, span), vector_scale(normal, -span)]
return [ExtentVector(vector_scale(normal, span)), ExtentVector(vector_scale(normal, -span))]
# Through-all-and-blind is represented by a through direction plus
# its captured opposite blind direction when available.
reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0))
return [vector_scale(normal, span), vector_scale(normal, -(reverse_distance or span))]
return [
ExtentVector(vector_scale(normal, span)),
ExtentVector(vector_scale(normal, -(reverse_distance or span))),
]
return _side_extent_vectors(
node, faces, normal, session, end_condition=end_condition, distance_mm=distance,
)
@@ -337,15 +388,30 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
# 3. 按特征类型生成子实体:
if node.atomic_id.startswith("extrude_"):
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
# 再对每个面沿每个向量做拉伸,得到实体列表。
vectors = _extent_vectors(node, faces, selected_sketch, session)
solids = [session.adapter.extrude(face, vector) for face in faces for vector in vectors]
# 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在
# profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
extents = _extent_vectors(node, faces, selected_sketch, session)
solids: list[Any] = []
for face in faces:
for extent in extents:
if 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))
else:
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
axis = _revolve_axis(node, session)
angle = float(node.params.get("angle_deg") or 0.0)
if angle <= 0:
raise ValueError("revolve requires angle_deg > 0")
# reverse=true 表示绕轴反向扫掠(SolidWorks 旋转方向反转):取负
# 旋转角,与 extrude 的 reverse_extent_vectors 反转拉伸方向)同一
# 语义。profile_schema.json 已声明 revolve.* optional_params 含
# reversecdsl_schema.json revolveParams 也已允许,这里补齐 runtime
# 侧实现,使三方合同一致。
if bool(node.params.get("reverse")):
angle = -angle
solids = [session.adapter.revolve(face, angle, axis) for face in faces]
# 4. 将所有子实体做布尔并(fuse)合并为一个工具体(tool)。
tool = None
@@ -494,8 +560,18 @@ def _execute_hole(node: FeaturePlanNode, session: ExecutionSession, *, wizard: b
session.adapter.body_span(session.body, inward) + 2.0,
)
# 6. 从主体上减去工具实体,登记新主体并返回结果。
# thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔
# 即光滑孔):孔按光滑圆柱孔执行,同时记录 info 级诊断便于批量报告
# 追溯降级数量(issue #9capabilities 已不再拒绝 thread)。
diagnostics: list[RuntimeDiagnostic] = []
if wizard and node.params.get("thread"):
diagnostics.append(RuntimeDiagnostic(
code="thread_decoration_ignored",
message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore",
feature_id=node.feature_id,
))
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
return session.result(node)
return session.result(node, diagnostics=diagnostics)
def _selector_edges(node: FeaturePlanNode, session: ExecutionSession, *, tangent_propagation: bool = False) -> list[Any]:
@@ -543,12 +619,21 @@ def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> Featur
distance = float(node.params.get("distance_mm") or 0)
if distance <= 0:
raise ValueError("chamfer distance_mm must be > 0")
# 3. 解析目标边(支持相切传播),执行倒角;distance_2_mm 提供时产生非对称倒角。
# 3. 解析第二距离与角度(importer 对 SolidWorks Distance-Angle 倒角产出
# angle_rad,单位为弧度)。第二距离 = 主距离 * tan(angle)angle=45° 时
# tan=1,退化为等距倒角(与历史行为一致,零回归)。
# 注意:build123d 的 length/length2 侧向分配依赖面的枚举顺序,对非 45°
# 倒角仅保证量级正确,距离所在侧可能反转。
distance_2 = node.params.get("distance_2_mm")
angle_rad = node.params.get("angle_rad")
if distance_2 is None and angle_rad is not None:
distance_2 = distance * math.tan(float(angle_rad))
# 4. 解析目标边(支持相切传播),执行倒角。
body = session.adapter.chamfer(
session.body, distance, node.params.get("distance_2_mm"),
session.body, distance, distance_2,
_selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
)
# 4. 登记新主体并返回结果。
# 5. 登记新主体并返回结果。
session.register_body(node.feature_id, body, replay_node=node)
return session.result(node)
@@ -702,9 +787,20 @@ def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec) ->
host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal")
)
if positions_are_local:
for key in ("origin_mm", "x_dir", "y_dir", "normal"):
for key in ("origin_mm", "x_dir", "normal"):
if host_frame.get(key):
host_frame[key] = _reflect_point(host_frame[key], plane, vector=key != "origin_mm")
# #1 y_dir 保留:PlaneSpec 现在会尊重显式正交 y_dir。镜像后 frame 的
# canonical y 轴必须是 n×x(x 已反射 → y 反转),否则反射后的 frame
# 会保留反射前的 y_dir,与下方"局部坐标 v 取反"双重翻转。
x_reflected = host_frame.get("x_dir")
n_reflected = host_frame.get("normal")
if x_reflected is not None and n_reflected is not None:
host_frame["y_dir"] = [
n_reflected[1] * x_reflected[2] - n_reflected[2] * x_reflected[1],
n_reflected[2] * x_reflected[0] - n_reflected[0] * x_reflected[2],
n_reflected[0] * x_reflected[1] - n_reflected[1] * x_reflected[0],
]
# See _mirrored_sketch: the canonical reflected plane reverses local
# y, so local hole coordinates must do the same.
for position in params.get("positions") or []:
@@ -895,6 +991,9 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
"out_step": str(out_step),
"volume_mm3": float(geometry["volume_mm3"]),
"bbox_mm": {"min": bbox[:3], "max": bbox[3:]},
# #7 multi-body:重建结果里的独立实体数(Compound 成员数),
# 与 batch 验证的 document_truth.geometry.solid_body_count 对齐。
"solid_count": len(session.adapter.body_solids(session.body)),
"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()],
+186 -25
View File
@@ -7,6 +7,7 @@ any geometry adapter without importing OCC objects.
from __future__ import annotations
import warnings
from dataclasses import dataclass, field
from math import sqrt
from typing import Any, Iterable
@@ -14,6 +15,10 @@ from typing import Any, Iterable
Vector3 = tuple[float, float, float]
# y_dir 与 x_dir / normal 点积的绝对值不超过该值时,认为 y_dir 是正交的,
# 予以保留;否则视为偏斜数据,正交化并显式警告。
_Y_DIR_ORTHOGONALITY_TOL = 1e-6
def _vector3(value: Any, *, field_name: str) -> Vector3:
if not isinstance(value, (list, tuple)) or len(value) != 3:
@@ -176,7 +181,25 @@ class PlaneSpec:
x_raw = _vector3(value.get("x_dir"), field_name="plane.x_dir")
projected_x = tuple(x_raw[index] - _dot(x_raw, normal) * normal[index] for index in range(3))
x_dir = _unit(projected_x, field_name="plane.x_dir")
y_dir = _unit(_cross(normal, x_dir), field_name="plane.y_dir")
generated = _unit(_cross(normal, x_dir), field_name="plane.y_dir")
y_raw = value.get("y_dir")
if y_raw is None:
# y_dir 缺失:用 normal × x_dir 补全右手系(默认行为)。
y_dir = generated
else:
y_vec = _unit(_vector3(y_raw, field_name="plane.y_dir"), field_name="plane.y_dir")
if abs(_dot(y_vec, x_dir)) <= _Y_DIR_ORTHOGONALITY_TOL and abs(_dot(y_vec, normal)) <= _Y_DIR_ORTHOGONALITY_TOL:
# 输入 y_dir 与 x_dir / normal 正交:尊重文档作者给的坐标方向,
# 不再静默丢弃(SolidWorks 导出的非标准 y_dir 得以保留)。
y_dir = y_vec
else:
# 偏斜 y_dir:正交化并显式警告,避免"静默丢语义"。
warnings.warn(
f"plane y_dir {list(y_vec)} is not orthogonal to x_dir/normal; re-orthogonalized to {list(generated)}",
UserWarning,
stacklevel=2,
)
y_dir = generated
return cls(origin_mm=origin, x_dir=x_dir, y_dir=y_dir, normal=normal)
def as_dict(self) -> dict[str, list[float]]:
@@ -400,6 +423,11 @@ class TopologyRegistry:
self._records: list[TopologyRecord] = []
self._by_feature: dict[str, list[TopologyRecord]] = {}
self._active_body_id: str | None = None
# #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer
# 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何
# 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的
# stable_id 引用可以解析到 active body 内的新形态。
self._successors: dict[str, list[str]] = {}
def register(self, record: TopologyRecord) -> None:
self._records.append(record)
@@ -423,7 +451,10 @@ class TopologyRegistry:
self.register(record)
return record
def replace_body_topology(self, feature_id: str, body_id: str, records: Iterable[TopologyRecord]) -> None:
def replace_body_topology(
self, feature_id: str, body_id: str, records: Iterable[TopologyRecord],
*, active_body_id: str | None = None,
) -> None:
"""Record a fresh B-rep snapshot after a feature mutates the body.
OCC topology object identity is invalidated by most body mutations.
@@ -432,11 +463,21 @@ class TopologyRegistry:
object has one geometrically equivalent predecessor. A changed or
split object intentionally becomes owned by this feature instead of
being guessed as belonging to an older one.
``active_body_id`` names the whole-body group when ``body_id`` is a
member of a multi-solid body (issue #7): the group id keeps the next
mutation's predecessor lookup scoped to every solid of the previous
body, while each member keeps its own ``body:{feature}:{index}`` id.
"""
previous = [
record for record in self._records
if self._active_body_id is not None and record.body_id == self._active_body_id
if self._active_body_id is not None and record.body_id is not None
and (
record.body_id == self._active_body_id
or record.body_id.startswith(f"{self._active_body_id}:")
)
]
records = list(records)
consumed_predecessors: set[str] = set()
for record in records:
predecessor = self._unique_equivalent_predecessor(record, previous, consumed_predecessors)
@@ -454,7 +495,27 @@ class TopologyRegistry:
owner_feature_ids=owners,
)
)
self._active_body_id = body_id
# #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续
# selector 的 stable_id 引用解析到 active body 内的新形态。多条演化
# 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段
# 也有圆角过渡带的新边,前者的端点与原边重合、漂移更小);漂移并列
# (如竖直边被完整消费成两条等距直段)属于本质歧义,保守不登记。
for prior in previous:
if prior.record_id in consumed_predecessors:
continue
candidates = sorted(
(
(self._evolved_drift(prior, record), record.record_id)
for record in records if self._evolved_equivalent(prior, record)
),
key=lambda item: item[0],
)
if not candidates:
continue
best, second = candidates[0], (candidates[1] if len(candidates) > 1 else None)
if second is None or (second[0] - best[0]) > max(0.5, 0.2 * best[0]):
self._successors[prior.record_id] = [best[1]]
self._active_body_id = active_body_id or body_id
@staticmethod
def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool:
@@ -532,6 +593,56 @@ class TopologyRegistry:
]
return matches[0] if len(matches) == 1 else None
@staticmethod
def _evolved_drift(prior: TopologyRecord, current: TopologyRecord) -> float | None:
"""Endpoint drift between direction-aligned straight edges.
Returns the minimum total endpoint drift (mm) when the two edges are
collinear straight lines (either orientation), otherwise ``None``.
"""
if prior.kind != current.kind:
return None
left, right = prior.geometry, current.geometry
if left.get("curve_type") != "line" or right.get("curve_type") != "line":
return None
if None in (left.get("start_mm"), left.get("end_mm"), right.get("start_mm"), right.get("end_mm")):
return None
def _delta(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return (b[0] - a[0], b[1] - a[1], b[2] - a[2])
def _dist(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))
left_dir = _delta(left["start_mm"], left["end_mm"])
right_dir = _delta(right["start_mm"], right["end_mm"])
if _length(left_dir) <= 1e-9 or _length(right_dir) <= 1e-9:
return None
cross = (
left_dir[1] * right_dir[2] - left_dir[2] * right_dir[1],
left_dir[2] * right_dir[0] - left_dir[0] * right_dir[2],
left_dir[0] * right_dir[1] - left_dir[1] * right_dir[0],
)
if _length(cross) / (_length(left_dir) * _length(right_dir)) > 1e-3:
return None
same_order = _dist(left["start_mm"], right["start_mm"]) + _dist(left["end_mm"], right["end_mm"])
reversed_order = _dist(left["start_mm"], right["end_mm"]) + _dist(left["end_mm"], right["start_mm"])
return min(same_order, reversed_order)
@classmethod
def _evolved_equivalent(cls, prior: TopologyRecord, current: TopologyRecord, *, drift_mm: float = 5.0) -> bool:
"""Loose "position trajectory" equivalence used for evolved successors.
Unlike ``_geometry_equivalent`` (strict, anti-false-positive provenance),
this deliberately tolerates small endpoint drift: fillet/chamfer split a
straight edge into segments (a middle straight run plus end arcs). The
straight run keeps the same direction and stays within ``drift_mm`` of the
original edge, so it can serve as the edge's evolved successor. Uniqueness
is enforced by the caller (only a single best candidate is recorded).
"""
drift = cls._evolved_drift(prior, current)
return drift is not None and drift <= drift_mm
@staticmethod
def _vector_score(expected: Any, actual: Any, tolerance: float = 1e-4) -> float | None:
try:
@@ -556,8 +667,14 @@ class TopologyRegistry:
for key in ("surface_type", "curve_type"):
if key in selector_geometry:
if record_geometry.get(key) != selector_geometry[key]:
return None
scores.append(1.0)
# #8 selector 持久性:fillet/chamfer 会把直线边演化为圆弧、
# 平面演化为柱面,但被选中拓扑的位置锚定(bbox/center/端点)
# 不变。曲线/曲面类型变化不再一票否决,而是记低分:位置完全
# 重合的候选(同一条边的形态演化)仍可胜出;位置不重合的
# 相邻边会被 0 分项拉低,仍被 minimum_score 挡住。
scores.append(0.5)
else:
scores.append(1.0)
if "bbox_mm" in selector_geometry:
expected = selector_geometry["bbox_mm"]
actual = record_geometry.get("bbox_mm")
@@ -592,7 +709,14 @@ class TopologyRegistry:
owner = selector.get("owner_feature_id")
candidates = [record for record in self._records if record.kind == kind]
if active_body_id and kind in {"face", "edge", "vertex", "body"}:
candidates = [record for record in candidates if record.body_id == active_body_id]
# #7 multi-body:记录 body_id 可能是 body:{feature}:{index}(多体
# 成员),用前缀匹配把整个主体的记录纳入候选,同时保证旧 body 的
# 记录(不同 feature 前缀)不会泄漏进来。
candidates = [
record for record in candidates
if record.body_id == active_body_id
or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:"))
]
if owner:
candidates = [record for record in candidates if owner in record.owners]
geometry = normalize_selector_geometry(selector.get("geometry"))
@@ -609,31 +733,68 @@ class TopologyRegistry:
)
stable_id = str(selector.get("stable_id") or "").strip()
if stable_id:
exact = [record for record in candidates if record.record_id == stable_id]
# #8 selector 持久性:stable_id 是跨 body 演化的持久标识符,精确
# 匹配在 active body 过滤之前对整个记录集(kind + owner 过滤)执行。
# 命中已过期(旧 body)的记录时,经演化后继映射解析到 active body
# 内的新形态(fillet/chamfer 拆段后的直段后继);无后继则回落到
# 几何打分流程。
stable_records = [
record for record in self._records
if record.kind == kind and (not owner or owner in record.owners)
]
exact = [record for record in stable_records if record.record_id == stable_id]
if len(exact) == 1:
record = exact[0]
# A stable ID is only a lookup accelerator for snapshot-aware
# selectors. It cannot revive a B-rep entity whose geometric
# signature changed after an upstream rebuild.
if selector.get("snapshot_id"):
score = self._geometry_score(geometry, record.geometry) if geometry else None
if score is None or score < minimum_score:
is_active = active_body_id is None or (
record.body_id == active_body_id
or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:"))
)
if not is_active:
successors = [
candidate for candidate in stable_records
if candidate.record_id in self._successors.get(record.record_id, ())
and (
candidate.body_id == active_body_id
or (candidate.body_id is not None and active_body_id and candidate.body_id.startswith(f"{active_body_id}:"))
)
]
if len(successors) == 1:
record = successors[0]
is_active = True
elif len(successors) > 1:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=({"score": round(float(score or 0), 6), **record.public_dict()},),
status="ambiguous",
candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors),
diagnostic=RuntimeDiagnostic(
code="selector_geometry_mismatch",
message="The stable selector record no longer matches its geometry signature",
detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score},
code="selector_ambiguous",
message="More than one evolved successor record satisfies the stable_id",
detail={"stable_id": stable_id, "candidate_count": len(successors)},
),
)
return SelectorResolution(
selector=selector,
status="resolved",
record=record,
candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},),
)
if is_active:
# A stable ID is only a lookup accelerator for snapshot-aware
# selectors. It cannot revive a B-rep entity whose geometric
# signature changed after an upstream rebuild.
if selector.get("snapshot_id"):
score = self._geometry_score(geometry, record.geometry) if geometry else None
if score is None or score < minimum_score:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=({"score": round(float(score or 0), 6), **record.public_dict()},),
diagnostic=RuntimeDiagnostic(
code="selector_geometry_mismatch",
message="The stable selector record no longer matches its geometry signature",
detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score},
),
)
return SelectorResolution(
selector=selector,
status="resolved",
record=record,
candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},),
)
if len(exact) > 1:
return SelectorResolution(
selector=selector,
+24 -5
View File
@@ -44,14 +44,33 @@ def _to_3d(workplane: _Ctx, u: float, v: float) -> list[float]:
origin = workplane.get("origin_mm") or [0, 0, 0]
x_dir = workplane.get("x_dir") or [1, 0, 0]
normal = workplane.get("normal") or [0, 0, 1]
y_dir = [
normal[1] * x_dir[2] - normal[2] * x_dir[1],
normal[2] * x_dir[0] - normal[0] * x_dir[2],
normal[0] * x_dir[1] - normal[1] * x_dir[0],
]
y_raw = workplane.get("y_dir")
y_dir = _default_y_dir(x_dir, normal)
if y_raw:
magnitude = math.sqrt(sum(component * component for component in y_raw))
if magnitude > 1e-12:
y_unit = [component / magnitude for component in y_raw]
# 与 PlaneSpec.from_mapping 同策略:只有与 x_dir / normal 正交的
# y_dir 才尊重(SolidWorks 导出的 y_dir==x_dir 占位数据与 X 平行,
# 直接使用会让轮廓塌缩成一条线,必须回退到 normal×x_dir)。
if abs(_dot(y_unit, x_dir)) <= 1e-6 and abs(_dot(y_unit, normal)) <= 1e-6:
y_dir = y_unit
return [origin[0] + u * x_dir[0] + v * y_dir[0], origin[1] + u * x_dir[1] + v * y_dir[1], origin[2] + u * x_dir[2] + v * y_dir[2]]
def _dot(left: Iterable[float], right: Iterable[float]) -> float:
return sum(a * b for a, b in zip(left, right))
def _default_y_dir(x_dir: Iterable[float], normal: Iterable[float]) -> list[float]:
x, n = list(x_dir), list(normal)
return [
n[1] * x[2] - n[2] * x[1],
n[2] * x[0] - n[0] * x[2],
n[0] * x[1] - n[1] * x[0],
]
def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]:
transformed: list[_Ctx] = []
normal = workplane.get("normal") or [0, 0, 1]
@@ -0,0 +1,209 @@
"""#5 高级终止条件:up_to_surface 非均匀 profile 必须裁剪而非拒绝。
中文说明
--------
这个文件在测试什么(issue #5「高级终止条件要求整张 profile 同一距离」的回归测试):
1. 背景:up_to_surface(拉伸到面)等终止条件用"profile 采样点射线求交"
判定终止距离。修复前,只要 profile 与目标面**非均匀相交**——一部分
采样点到达目标、一部分悬空(profile 超出目标面范围),或目标面相对
profile 倾斜——runtime 就抛 non_uniform_extent_target 拒绝整个特征,
零件无法重建。而 CAD 的标准语义是**裁剪**:保留"从 profile 到目标面"
的可达材料,切掉悬空部分。
修复后:adapter 新增 extrude_trimmed(穿透拉伸 + 目标面体层布尔求交),
runtime 的 _targeted_extent_vector 在 up_to_surface 非均匀时返回
带 trim_to 的 ExtentVector_shape_from_primary 改用裁剪拉伸。
up_to_vertex / up_to_body / offset_from_surface 没有可构造裁剪体层
的 face,仍保持显式拒绝(语义上无法裁剪)。
2. 本测试套件把"非均匀 up_to_surface 裁剪"合同固定下来:
- 单元几何契约(adapter):斜目标面 + 水平 profile 裁剪出楔形,
体积 = ∫斜顶 dxdy;
- 集成裁剪契约(rebuild):profile 悬空超出目标面时不再拒绝,
悬空部分被切掉,只保留与目标面之间的材料;
- 集成回归护栏(rebuild):profile 完全落在目标面内仍走精确
均匀拉伸路径,体积不变(防止裁剪回退把均匀路径也改坏)。
3. sys.path 说明:把 backend/engine 加入搜索路径,直接 import cdsl_engine
包做端到端测试(与既有测试风格一致)。
函数功能一览
------------
_workplane(origin, normal) 构造指定原点与法向的草图工作平面。
_rectangle(minimum, maximum) 构造 XY 平面内的矩形轮廓(2D 多边形)。
_base_block() 构造 10×10×10 拉伸主体(体积 1000,
顶面 z=10、范围 x/y ∈ [-5, 5])。
_top_face_selector(baseline) 从 baseline 拓扑记录里挑出顶面(法向 +z)
的几何快照,作为 up_to_surface 的 reference。
ExtentTrimContractTests 见各测试方法 docstring。
"""
from __future__ import annotations
import math
import sys
import tempfile
import unittest
from copy import deepcopy
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
sys.path.insert(0, str(ROOT / "backend" / "engine"))
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter # noqa: E402
from cdsl_engine.runtime import rebuild_cdsl # noqa: E402
try:
from build123d import Face, Plane, Vector # noqa: F401
_HAS_BUILD123D = True
except ImportError:
_HAS_BUILD123D = False
# ---------------------------------------------------------------------------
# 测试夹具
# ---------------------------------------------------------------------------
def _workplane(*, origin: list[float], normal: list[float]) -> dict:
"""构造草图工作平面:显式指定原点与法向(x_dir 固定 +X)。"""
return {"origin_mm": origin, "x_dir": [1, 0, 0], "normal": normal}
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
"""XY 平面内的矩形轮廓(2D 多边形),顶点逆时针。"""
return {"type": "polygon", "vertices": [
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
]}
def _base_block() -> dict:
"""10×10×10 拉伸主体:体积 1000,顶面位于 z=10、范围 x/y ∈ [-5, 5]。"""
return {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"part_id": "extent-trim-contract", "meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "base", "workplane": _workplane(origin=[0, 0, 0], normal=[0, 0, 1]),
"profile": _rectangle([-5, -5], [5, 5]),
}]},
"features": [{
"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 10}, "sketch_id": "base",
}],
}
def _top_face_selector(baseline: dict) -> dict:
"""从 baseline 拓扑记录里取顶面(法向 +z 的平面 face)的几何快照。
这个快照被 TopologyRegistry 用来做几何等价匹配,从而把 up_to_surface
的 reference 解析到重建主体上的真实 Face。
"""
return next(
item for item in baseline["topology_records"]
if item["kind"] == "face"
and item["geometry"]["surface_type"] == "plane"
and item["geometry"]["normal"][2] > 0.9
)
# ---------------------------------------------------------------------------
# 测试套件
# ---------------------------------------------------------------------------
class ExtentTrimContractTests(unittest.TestCase):
"""up_to_surface 非均匀相交「裁剪几何-行为-回归」三方合同测试。"""
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
def test_extrude_trimmed_slanted_target_produces_wedge(self) -> None:
"""单元几何契约:斜目标面 + 水平 profile 裁剪出楔形。
profile 是 z=0 的 10×10 矩形;目标面是斜面 z = 0.2x + 5
z_dir=(0.2, 0, 0.98) 的平面)。采样点沿 +z 到斜面的距离从
4x=-5)到 6x=+5)变化 → uniform_intersection_distance 必然
判定非均匀。裁剪结果应是顶面贴斜面的楔形,体积 =
∫∫ (0.2x + 5) dxdy = 5 × 100 = 500。修复前该场景直接抛
non_uniform_extent_target(无实体可断言),本测试锁死裁剪几何。
"""
profile = Face.make_rect(10, 10) # 中心在原点,x/y ∈ [-5, 5]z=0
slanted = Face.make_rect(20, 20, Plane(origin=(0, 0, 5), z_dir=(0.2, 0, 0.98)))
trimmed = Build123dGeometryAdapter.extrude_trimmed(profile, slanted, (0, 0, 1))
self.assertAlmostEqual(trimmed.volume, 500.0, places=5)
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
def test_up_to_surface_hanging_profile_is_trimmed_not_rejected(self) -> None:
"""集成裁剪契约:profile 悬空超出目标面时不再拒绝,悬空部分被切掉。
baseline 是 10×10×10 主体(顶面 z=10、范围 [-5, 5]²)。第二个 add
特征在 z=12 平面、法向 -zprofile 为 16×16 矩形(x/y ∈ [-8, 8]
大部分悬空在顶面范围之外)。up_to_surface 目标 = 顶面:
- 中心采样点沿 -z 命中顶面(距离 2);
- 角落采样点(x/y = ±8)在顶面范围外,射线不命中 → 非均匀。
修复前抛 non_uniform_extent_target;修复后裁剪出
x/y ∈ [-5, 5]、z ∈ [10, 12] 的 10×10×2 体块,总体积 = 1000 + 200。
"""
base = _base_block()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(base, root / "baseline.step")
top_face = _top_face_selector(baseline)
with_cap = deepcopy(base)
with_cap["geometry"]["sketches"].append({
"id": "cap", "workplane": _workplane(origin=[0, 0, 12], normal=[0, 0, -1]),
"profile": _rectangle([-8, -8], [8, 8]),
})
with_cap["features"].append({
"id": "cap_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
"sketch_id": "cap",
"params": {
"distance_mm": 0,
"end_condition": {"type": "up_to_surface", "reference": top_face},
},
})
rebuilt = rebuild_cdsl(with_cap, root / "trimmed.step")
self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 + 200, places=5)
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
def test_up_to_surface_uniform_profile_keeps_exact_distance(self) -> None:
"""集成回归护栏:profile 完全落在目标面内仍走精确均匀拉伸。
与上一个测试同构,但 profile 缩到 6×6x/y ∈ [-3, 3]),完全落在
顶面 [-5, 5]² 范围内 → 所有采样点沿 -z 都命中且距离一致(2)→
保持原精确拉伸路径(不触发裁剪),体积 = 1000 + 36×2 = 1072。
防止裁剪回退把均匀路径也改坏。
"""
base = _base_block()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(base, root / "baseline.step")
top_face = _top_face_selector(baseline)
with_cap = deepcopy(base)
with_cap["geometry"]["sketches"].append({
"id": "cap", "workplane": _workplane(origin=[0, 0, 12], normal=[0, 0, -1]),
"profile": _rectangle([-3, -3], [3, 3]),
})
with_cap["features"].append({
"id": "cap_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
"sketch_id": "cap",
"params": {
"distance_mm": 0,
"end_condition": {"type": "up_to_surface", "reference": top_face},
},
})
rebuilt = rebuild_cdsl(with_cap, root / "exact.step")
self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 + 36 * 2, places=5)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,226 @@
"""#2 draft 假接受陷阱:extrudeParams.draft 必须被显式拒绝,而不是静默忽略。
中文说明
--------
这个文件在测试什么(issue #2「draft 被 schema 接受但 runtime 未执行」的回归测试):
1. 背景:三方合同错位——
- 机器契约 cdsl_schema.jsonextrudeParams,约 101 行)允许
"draft": {"type": "object"},且是空 object(无任何子字段约束),
"文档格式假接受"
- 人读契约 profile_schema.json 的 extrude_add_blind /
extrude_add_two_sided / extrude_cut_blind 均**未**声明 draft
为 optional_params
- runtimebuild123d_adapter.extrude 仅调 Solid.extrude,无锥形
拉伸)也完全没有 draft 实现,遇到 draft 就静默忽略。
- 隐患:importertranslator.py 已解析 SolidWorks 的
draft_angle_rad / reverse_draft_angle_rad)一旦把拔模角写进
CDSL paramsruntime 会静默产出**无拔模角的直壁实体**——
注塑件/压铸件丢失脱模斜度,脱模卡死、分型面配合错误,且全程
无警告(与 #1 y_dir / #3 revolve.reverse 同族的静默错误)。
2. 修复策略:因为 build123d 内核没有锥形拉伸能力、且人读契约未声明
draft,正确的合同是"显式拒绝"而不是"实现拔模"——
capabilities.py 对携带 draft 的 extrude 特征报 unsupported_draft
blocker(与 unsupported_extent 同模式)。schema 字段保留(文档格式
契约,importer 未来可能产出),能力层明确划界。
3. 本测试套件把"draft 必须显式拒绝"固定下来:
- 主契约:带 draft 的 extrude 特征 → analyze 报 unsupported_draft
blockerruntime_eligible=False
- 回归护栏:不带 draft 的 extrude 特征 → 仍 runtime_eligible
- 文档格式契约:带 draft 的文档仍能通过 cdsl_schema.json(拒绝
发生在能力层,不是 schema 层);
- 覆盖:extrude_add_blind / extrude_add_two_sided / extrude_cut_blind
三种原子都报同一 blocker(同一检查全类生效);
- 端到端:rebuild_cdslstrict)带 draft → 抛 ValueError(大声失败),
analyze_document → runtime_eligible=False 且不 built(批量重建
不被静默污染)。
4. sys.path 说明:把 backend/engine 加入搜索路径,是为了直接 import
cdsl_engine 包做端到端测试(与 test_engine_revolve_reverse.py 风格
一致)。
函数功能一览
------------
_rectangle() 构造 XY 平面内的矩形轮廓(2D 多边形)。
_extrude_cdsl() 构造最小 extrude 文档;draft 参数决定
是否携带 draft 字段、atomic_id 可切换
三种 extrude 原子。
_rebuild() 在临时目录内调用 rebuild_cdslstrict),
带 draft 时预期抛 ValueError。
_validate_against_cdsl_schema() 对整张文档跑 cdsl_schema.json 校验。
RevolveReverseContractTests (见各测试方法 docstring)。
"""
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
sys.path.insert(0, str(ROOT / "backend" / "engine"))
import jsonschema # noqa: E402
import cdsl_engine # noqa: E402
from cdsl_engine.batch_rebuild import analyze_document # noqa: E402
from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl # noqa: E402
# cdsl_schema.json 路径:随 cdsl_engine 包部署。
_SCHEMA_PATH = Path(cdsl_engine.__file__).parent / "cdsl_schema.json"
_SCHEMA = json.loads(_SCHEMA_PATH.read_text(encoding="utf-8"))
# ---------------------------------------------------------------------------
# 测试夹具
# ---------------------------------------------------------------------------
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
"""XY 平面内的矩形轮廓(2D 多边形),顶点逆时针。
用于拉伸特征的最小闭合轮廓:x∈[minimum[0], maximum[0]]、
y∈[minimum[1], maximum[1]]。
"""
return {"type": "polygon", "vertices": [
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
]}
def _extrude_cdsl(*, atomic_id: str = "extrude_add_blind", with_draft: bool = True) -> dict:
"""构造最小 extrude 文档。
- with_draft=True 时 params 携带 draft 对象(任意非空 object 即可,
因为 cdsl_schema.json 对 draft 没有子字段约束);
- with_draft=False 时完全不写 draft 字段(回归护栏用)。
- atomic_id 可切换 extrude_add_blind / extrude_add_two_sided /
extrude_cut_blind 三种原子,验证同一 blocker 检查对全类生效。
"""
params: dict = {"distance_mm": 10.0}
if with_draft:
params["draft"] = {"angle_deg": 5.0, "direction": "toward_sketch"}
return {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"part_id": "draft-contract", "meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "base",
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
"profile": _rectangle([-5, -5], [5, 5]),
}]},
"features": [{
"id": "base_add", "atomic_id": atomic_id, "depends_on": [], "sketch_id": "base",
"params": params,
}],
}
def _validate_against_cdsl_schema(doc: dict) -> None:
"""对整张 CDSL 文档跑 cdsl_schema.json 校验;任何字段不通过都会抛 ValidationError。"""
jsonschema.validate(instance=doc, schema=_SCHEMA)
# ---------------------------------------------------------------------------
# 测试套件
# ---------------------------------------------------------------------------
class ExtrudeDraftContractTests(unittest.TestCase):
"""draft 假接受陷阱「文档格式-能力边界-运行时」三方合同的回归测试。"""
def test_draft_extrude_is_explicitly_blocked(self) -> None:
"""主契约:带 draft 的 extrude 特征必须被显式拒绝,而不是静默通过。
修复前(当前):capabilities 对 extrude 只检查 end_condition
draft 字段完全无人过问 → analyze 报 runtime_eligible=True
rebuild 静默产出直壁实体 → 本测试红灯。
修复后:capabilities 报 unsupported_draft blocker →
runtime_eligible=False → 绿灯。
"""
analysis = analyze_cdsl(_extrude_cdsl(with_draft=True))
self.assertFalse(analysis.runtime_eligible)
result = next(item for item in analysis.feature_results if item.feature_id == "base_add")
self.assertIn("unsupported_draft", [blocker.code for blocker in result.blockers])
def test_draft_free_extrude_stays_eligible(self) -> None:
"""回归护栏:不带 draft 的 extrude 特征仍必须 runtime_eligible。
修复前/修复后均应通过。这条测试防止我们把检查加过头——
一旦把"携带 draft"错写成"所有 extrude 都拒绝",护栏会变红。
"""
analysis = analyze_cdsl(_extrude_cdsl(with_draft=False))
self.assertTrue(analysis.runtime_eligible)
def test_draft_passes_machine_schema(self) -> None:
"""文档格式契约:带 draft 的文档仍能通过 cdsl_schema.json 校验。
cdsl_schema.jsonextrudeParams)保留 draft 字段,拒绝发生在
能力层(capabilities),不是 schema 层。这条测试锁死"schema 允许
+ 能力拒绝"的分层职责,防止未来把 schema 改过头(删掉字段后
importer 未来产出 draft 会直接被 schema 打回,失去可诊断性)。
"""
_validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_add_blind", with_draft=True))
_validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_add_two_sided", with_draft=True))
_validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_cut_blind", with_draft=True))
def test_draft_blocks_every_extrude_atomic(self) -> None:
"""覆盖:三种 extrude 原子都报同一个 unsupported_draft blocker。
draft 检查挂在 _SKETCH_ATOM_PREFIXESextrude_/revolve_)公共入口,
必须对 extrude_add_blind / extrude_add_two_sided / extrude_cut_blind
同时生效,而不是只修了某一个。
"""
for atomic_id in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"):
with self.subTest(atomic_id=atomic_id):
analysis = analyze_cdsl(_extrude_cdsl(atomic_id=atomic_id, with_draft=True))
result = next(item for item in analysis.feature_results if item.feature_id == "base_add")
self.assertIn("unsupported_draft", [blocker.code for blocker in result.blockers])
def test_draft_rebuild_fails_loudly_not_silently(self) -> None:
"""端到端:draft 文档的重建必须大声失败,而不是静默产出直壁实体。
修复前:rebuild_cdslstrict)对 draft 视而不见 → 正常返回实体,
volume > 0,但几何是**没有拔模角的直壁**——静默错误。
修复后:rebuild_cdslstrict)因 runtime_eligible=False 抛
ValueErrorfeature is not runtime eligible: unsupported_draft);
analyze_document 报告 runtime_eligible=False 且不 built——
批量重建不会被静默污染。
"""
# 1) strict 重建直接抛错(大声失败)。
with self.assertRaisesRegex(ValueError, "unsupported_draft"):
with tempfile.TemporaryDirectory() as directory:
rebuild_cdsl(_extrude_cdsl(with_draft=True), Path(directory) / "part.step")
# 2) 批量层 analyze_document 报告不可执行且不产出 STEP。
cdsl = _extrude_cdsl(with_draft=True)
with tempfile.TemporaryDirectory() as directory:
out_step = Path(directory) / "part.step"
report = _analyze_inline(cdsl, out_step)
self.assertFalse(report["runtime_eligible"])
self.assertFalse(report.get("built", False))
def _analyze_inline(cdsl: dict, out_step: Path) -> dict:
"""把 cdsl 写入临时 json 后走 analyze_document(与批量层同一入口)。
analyze_document 接受文件路径,这里把内存中的文档落地成临时文件,
保证测试走的路径与 batch_rebuild 完全一致。
"""
import tempfile as _tf
with _tf.TemporaryDirectory() as directory:
source = Path(directory) / "draft.cdsl.json"
source.write_text(json.dumps(cdsl), encoding="utf-8")
return analyze_document(source, out_step=out_step)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,263 @@
"""#9 孔型:hole_wizard.thread(装饰螺纹)必须可执行并降级为光滑孔。
中文说明
--------
这个文件在测试什么(issue #9「HoleSpec 仅支持简单圆柱、沉头、沉孔」的回归测试):
1. 背景:SolidWorks 螺纹孔(hole_wizard + thread,如"M5 螺纹孔"
"底部螺纹孔")在真实语料里大量存在,修复前 capabilities 把 thread
误判为"当前 CDSL runtime 无法表示的几何"而报 unsupported_hole_subtype
blocker,≈712 个带 thread 的孔特征因此整体被拒(runtime_eligible=False),
零件无法重建。
实际上 thread 只是装饰信息:
- 数据形态只有 {diameter_mm, depth_mm, class},没有螺距;
- SolidWorks/STEP 的螺纹孔实体几何就是光滑圆柱孔(装饰螺纹不进
实体、不进 STEP);
- HoleSpec.from_feature 只读直径/深度/位置/沉头/沉孔,thread 天然
不参与几何计算。
因此正确合同是"接受 thread、按光滑孔执行",并在 runtime 记录
info 级诊断(thread_decoration_ignored)便于批量报告追溯降级数量。
2. 本测试套件把"thread 孔必须可执行且降级为光滑孔"固定下来:
- 主契约:带 thread 的 hole_wizard → runtime_eligible=True
(不再被拒绝);
- 回归护栏:不带 thread 的 hole_wizard → 仍可执行(防止把检查
加过头,所有孔都被拒);
- 文档契约:hole_wizard + thread 通过 cdsl_schema.json 校验
(字段本就在 holeWizardParams 里);
- 几何契约:thread 孔切出的体积 = 光滑圆柱孔体积(thread 不建模,
与 SolidWorks/STEP 语义一致);
- 可追溯:rebuild 结果里带 thread_decoration_ignored 信息诊断
(降级不是静默发生的)。
3. sys.path 说明:把 backend/engine 加入搜索路径,是为了直接 import
cdsl_engine 包做端到端测试(与 test_engine_hole_thread_contract 等
既有测试风格一致)。
函数功能一览
------------
_workplane() 构造默认草图工作平面(XY 平面)。
_rectangle() 构造 XY 平面内的矩形轮廓(2D 多边形)。
_base_block() 构造 10×10×10 拉伸主体文档(体积 1000)。
_thread_wizard_feature() 构造 hole_wizard 特征(with_thread 决定
是否携带 thread 装饰字段)。
_validate_against_cdsl_schema() 对整张文档跑 cdsl_schema.json 校验。
HoleThreadContractTests 见各测试方法 docstring。
"""
from __future__ import annotations
import json
import math
import sys
import tempfile
import unittest
from copy import deepcopy
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
sys.path.insert(0, str(ROOT / "backend" / "engine"))
import jsonschema # noqa: E402
import cdsl_engine # noqa: E402
from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl # noqa: E402
# cdsl_schema.json 路径:随 cdsl_engine 包部署。
_SCHEMA_PATH = Path(cdsl_engine.__file__).parent / "cdsl_schema.json"
_SCHEMA = json.loads(_SCHEMA_PATH.read_text(encoding="utf-8"))
try:
import build123d # noqa: F401
_HAS_BUILD123D = True
except ImportError:
_HAS_BUILD123D = False
# ---------------------------------------------------------------------------
# 测试夹具
# ---------------------------------------------------------------------------
def _workplane() -> dict:
"""默认草图工作平面:原点在 (0,0,0)、x 轴沿 +X、法向沿 +Z。"""
return {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
"""XY 平面内的矩形轮廓(2D 多边形),顶点逆时针。"""
return {"type": "polygon", "vertices": [
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
]}
def _base_block() -> dict:
"""10×10×10 拉伸主体:体积 1000,顶面位于 z=10。"""
return {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"part_id": "hole-thread-contract", "meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "base", "workplane": _workplane(),
"profile": _rectangle([-5, -5], [5, 5]),
}]},
"features": [{
"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 10}, "sketch_id": "base",
}],
}
def _thread_wizard_feature(*, with_thread: bool = True) -> dict:
"""构造 hole_wizard 特征(仿真实数据"M5 螺纹孔")。
- with_thread=True:携带 thread 装饰字段
{"diameter_mm", "depth_mm", "class"}(真实语料形态,无螺距);
- with_thread=False:不写 thread(回归护栏用)。
"""
params: dict = {
"hole_type": "底部螺纹孔", "diameter_mm": 5.0, "depth_mm": 10.0,
"end_condition": {"type": "blind", "solidworks_code": 0},
"positions": [{"mm": [0.0, 0.0, 0.0]}],
# 合法的 selectorRefcdsl_schema.json hostFace oneOf 分支),
# 满足机器 schema 的 required: [kind, stable_id, source, confidence]。
"host_face": {"kind": "face", "stable_id": "top", "source": "inferred_from_step", "confidence": 1},
}
if with_thread:
params["thread"] = {"diameter_mm": 5.0, "depth_mm": 10.0, "class": "1B"}
return {"id": "hole", "atomic_id": "hole_wizard", "depends_on": ["base_add"], "params": params}
def _validate_against_cdsl_schema(doc: dict) -> None:
"""对整张 CDSL 文档跑 cdsl_schema.json 校验;任何字段不通过都会抛 ValidationError。"""
jsonschema.validate(instance=doc, schema=_SCHEMA)
# ---------------------------------------------------------------------------
# 测试套件
# ---------------------------------------------------------------------------
class HoleThreadContractTests(unittest.TestCase):
"""thread 装饰螺纹「文档格式-能力边界-运行时-几何」四方合同的回归测试。"""
def test_thread_hole_wizard_is_executable(self) -> None:
"""主契约:带 thread 的 hole_wizard 必须 runtime_eligible。
修复前(当前):capabilities 报 unsupported_hole_subtype →
runtime_eligible=False → 零件无法重建 → 本测试红灯。
修复后:capabilities 不再拒绝 thread → runtime_eligible=True
hole 特征无任何 blocker → 绿灯。
"""
cdsl = _base_block()
cdsl["features"].append(_thread_wizard_feature(with_thread=True))
analysis = analyze_cdsl(cdsl)
hole = next(item for item in analysis.feature_results if item.feature_id == "hole")
self.assertTrue(analysis.runtime_eligible)
self.assertTrue(hole.executable)
self.assertNotIn("unsupported_hole_subtype", [blocker.code for blocker in hole.blockers])
def test_threadless_hole_wizard_stays_eligible(self) -> None:
"""回归护栏:不带 thread 的 hole_wizard 仍必须 runtime_eligible。
修复前/修复后均应通过。这条测试防止我们把修复做成"所有孔都被拒"
(例如误删 hole 检查整段)。
"""
cdsl = _base_block()
cdsl["features"].append(_thread_wizard_feature(with_thread=False))
analysis = analyze_cdsl(cdsl)
self.assertTrue(analysis.runtime_eligible)
def test_thread_hole_passes_machine_schema(self) -> None:
"""文档契约:hole_wizard + thread 必须通过 cdsl_schema.json 校验。
thread 字段本就在 holeWizardParams.properties 里(允许携带),
修复策略是"能力层接受并降级",不是"schema 层拒绝"——这条测试锁死
文档格式对 thread 的认可,防止未来把 schema 改过头。
"""
cdsl = _base_block()
cdsl["features"].append(_thread_wizard_feature(with_thread=True))
_validate_against_cdsl_schema(cdsl)
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
def test_thread_hole_cuts_plain_cylindrical_bore(self) -> None:
"""几何契约:thread 孔切出的体积 = 光滑圆柱孔体积(thread 不建模)。
10×10×10 主体,在顶面(z=10)中心打一个 d=2、深 5 的 thread 盲孔:
体积 = 1000 - π·1²·5。若 thread 参与几何(或孔整体被拒),体积断言
都会失败。这条测试锁死"降级为光滑孔"的几何语义——与 SolidWorks/
STEP 的螺纹孔表示一致。
"""
base = _base_block()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(base, root / "baseline.step")
top_face = next(
item for item in baseline["topology_records"]
if item["kind"] == "face"
and item["geometry"]["surface_type"] == "plane"
and item["geometry"]["normal"][2] > 0.9
)
feature = _thread_wizard_feature(with_thread=True)
feature["params"]["diameter_mm"] = 2.0
feature["params"]["depth_mm"] = 5.0
feature["params"]["positions"] = [{"mm": [0.0, 0.0, 10.0]}]
feature["params"]["host_face"] = {
"kind": "face", "stable_id": "top", "source": "inferred_from_step",
"confidence": 1, "geometry": top_face["geometry"],
}
feature["selectors"] = [
{"kind": "face", "stable_id": "top", "source": "inferred_from_step",
"confidence": 1, "geometry": top_face["geometry"]},
]
with_hole = deepcopy(base)
with_hole["features"].append(feature)
holed = rebuild_cdsl(with_hole, root / "thread-hole.step")
self.assertAlmostEqual(holed["volume_mm3"], 1000 - 5 * math.pi, places=5)
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
def test_thread_fallback_reports_info_diagnostic(self) -> None:
"""可追溯:thread 降级必须留下 thread_decoration_ignored 信息诊断。
降级不是静默发生的:runtime 在 wizard 模式且携带 thread 时记录
info 级诊断,批量报告(summary-by-diagnostic 或 per-part report
可以统计降级数量。这条测试锁死"降级可观测"
"""
base = _base_block()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(base, root / "baseline.step")
top_face = next(
item for item in baseline["topology_records"]
if item["kind"] == "face"
and item["geometry"]["surface_type"] == "plane"
and item["geometry"]["normal"][2] > 0.9
)
feature = _thread_wizard_feature(with_thread=True)
feature["params"]["positions"] = [{"mm": [0.0, 0.0, 10.0]}]
feature["params"]["host_face"] = {
"kind": "face", "stable_id": "top", "source": "inferred_from_step",
"confidence": 1, "geometry": top_face["geometry"],
}
feature["selectors"] = [
{"kind": "face", "stable_id": "top", "source": "inferred_from_step",
"confidence": 1, "geometry": top_face["geometry"]},
]
with_hole = deepcopy(base)
with_hole["features"].append(feature)
holed = rebuild_cdsl(with_hole, root / "thread-hole.step")
hole_result = next(item for item in holed["feature_results"] if item["feature_id"] == "hole")
codes = [diagnostic["code"] for diagnostic in hole_result["diagnostics"]]
self.assertIn("thread_decoration_ignored", codes)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,304 @@
"""#7 ExecutionSession 单 active body → 多实体 body_id 回归测试。
中文说明
--------
这个文件在测试什么(issue #7「ExecutionSession 单 active body」的回归测试):
1. 背景:ExecutionSession 只跟踪一个 active bodysession.body /
session.body_id = body:{feature_id})。多实体零件(例如两个不相交
的拉伸 add,布尔并后 build123d 返回 Compound,含 ≥2 个独立 Solid
被当成一个 body 处理:
- 拓扑记录把 Compound 的所有面/边/顶点统一登记为一个 body_id;
- body_id 实际上变成"最后执行的特征的 id",不反映真实实体数;
- 后续特征无法精确匹配"某个实体"上的拓扑,多体信息在重建报告里
完全丢失。
修复后(#7):register_body 通过 adapter.body_solids 拆出独立实体,
每个 Solid 一个 body:{feature}:{index}selector resolve 与拓扑继承
用前缀匹配整个主体;单体路径保持 body:{feature} 完全不变。
2. 本测试套件把"多体可识别、单体不受影响"固定下来:
- 多体契约:两个不相交 add → 最后主体含 ≥2 个独立 body_id,
rebuild 输出 solid_count=2
- 多体 + 后续操作契约:在多体主体上打孔/切除仍 resolve 到正确实体;
- 单体护栏:单个 add → 只有 1 个实体、solid_count=1body_id 语义
与修复前一致。
3. sys.path 说明:把 backend 与 backend/engine 加入搜索路径,直接 import
cdsl_engine 包做端到端测试(与既有测试风格一致)。
函数功能一览
------------
_workplane(origin) 构造指定原点的 XY 平面草图工作平面。
_rectangle(minimum, maximum) 构造 XY 平面内的矩形轮廓(2D 多边形)。
_single_boss_doc() 10×10×10 单体拉伸文档(体积 1000)。
_two_boss_doc() 两个不相交 10×10×10 拉伸(x 相距 20),
布尔并后为 2 个独立 Solid(体积 2000)。
MultiBodyContractTests 见各测试方法 docstring。
"""
from __future__ import annotations
import math
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
sys.path.insert(0, str(ROOT / "backend" / "engine"))
from cdsl_engine.runtime import rebuild_cdsl # noqa: E402
# ---------------------------------------------------------------------------
# 测试夹具
# ---------------------------------------------------------------------------
def _workplane(*, origin: list[float]) -> dict:
return {"origin_mm": origin, "x_dir": [1, 0, 0], "normal": [0, 0, 1]}
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
return {"type": "polygon", "vertices": [
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
]}
def _single_boss_doc() -> dict:
return {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"part_id": "multi-body-single", "meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
"profile": _rectangle([-5, -5], [5, 5]),
}]},
"features": [
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 10}, "sketch_id": "s1"},
],
}
def _two_boss_doc() -> dict:
"""两个不相交 10×10×10 拉伸:主体 x∈[-5,5],第二个 x∈[15,25]。"""
return {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"part_id": "multi-body-two", "meta": {"unit": "mm"},
"geometry": {"sketches": [
{"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
"profile": _rectangle([-5, -5], [5, 5])},
{"id": "s2", "workplane": _workplane(origin=[20, 0, 0]),
"profile": _rectangle([-5, -5], [5, 5])},
]},
"features": [
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 10}, "sketch_id": "s1"},
{"id": "add_2", "atomic_id": "extrude_add_blind", "depends_on": ["add_1"],
"params": {"distance_mm": 10}, "sketch_id": "s2"},
],
}
# ---------------------------------------------------------------------------
# 测试套件
# ---------------------------------------------------------------------------
class MultiBodyContractTests(unittest.TestCase):
"""多体识别「实体数-拓扑归属-单体护栏」三方合同测试。"""
def test_two_disjoint_bosses_report_two_independent_body_ids(self) -> None:
"""多体契约:两个不相交 add → 最后主体含 ≥2 个独立 body_id。
修复前 body_id 是"每特征一 id"body:add_1 / body:add_2),Compound
的所有面都登记为 body:add_2,无法区分独立实体。修复后 register_body
为每个 Solid 分配 body:{feature}:{index},最后主体应同时包含
body:add_2:0 与 body:add_2:1 的拓扑记录。
"""
cdsl = _two_boss_doc()
with tempfile.TemporaryDirectory() as directory:
rebuilt = rebuild_cdsl(cdsl, Path(directory) / "two.step")
body_ids = sorted({r["body_id"] for r in rebuilt["topology_records"] if r.get("body_id")})
self.assertIn("body:add_2:0", body_ids)
self.assertIn("body:add_2:1", body_ids)
# 两个成员都要有可 resolve 的面记录(而不是只有整体 body 记录)。
member_faces = {
r["body_id"] for r in rebuilt["topology_records"]
if r["kind"] == "face" and r.get("body_id", "").startswith("body:add_2:")
}
self.assertEqual(member_faces, {"body:add_2:0", "body:add_2:1"})
self.assertAlmostEqual(rebuilt["volume_mm3"], 2000.0, places=5)
def test_rebuild_reports_solid_count(self) -> None:
"""多体契约:rebuild 输出 solid_count 反映独立实体数。"""
with tempfile.TemporaryDirectory() as directory:
two = rebuild_cdsl(_two_boss_doc(), Path(directory) / "two.step")
one = rebuild_cdsl(_single_boss_doc(), Path(directory) / "one.step")
self.assertEqual(two["solid_count"], 2)
self.assertEqual(one["solid_count"], 1)
def test_single_body_keeps_legacy_body_id(self) -> None:
"""单体护栏:单个 add → 1 个实体,body_id 语义与修复前一致。
防止把多体拆分做成"所有主体都拆":单体主体必须保持
body:{feature}(无 :index 后缀),后续 selector 行为不变。
"""
with tempfile.TemporaryDirectory() as directory:
rebuilt = rebuild_cdsl(_single_boss_doc(), Path(directory) / "one.step")
body_ids = sorted({r["body_id"] for r in rebuilt["topology_records"] if r.get("body_id")})
self.assertEqual(body_ids, ["body:add_1"])
face_ids = {
r["body_id"] for r in rebuilt["topology_records"]
if r["kind"] == "face" and r.get("body_id")
}
self.assertEqual(face_ids, {"body:add_1"})
def test_cut_on_multi_body_mutates_only_the_intersected_solid(self) -> None:
"""多体 + 后续操作契约:多体主体上切除仍 resolve 到正确实体。
两个不相交 box(各 1000)。在第一个 box(x∈[-5,5])顶面打贯穿孔
(r=1、深 10):只有第一个 box 被切,第二个 box(x∈[15,25])不受
影响。期望体积 = 2000 − π×1²×10,且 STEP 仍含 2 个 Solid。
"""
base = _two_boss_doc()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(base, root / "baseline.step")
top_face = next(
item for item in baseline["topology_records"]
if item["kind"] == "face"
and item["geometry"]["surface_type"] == "plane"
and item["geometry"]["normal"][2] > 0.9
and abs(item["geometry"]["center_mm"][0]) < 1.0
)
with_cut = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"part_id": "multi-body-cut", "meta": {"unit": "mm"},
"geometry": {"sketches": [
{"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
"profile": _rectangle([-5, -5], [5, 5])},
{"id": "s2", "workplane": _workplane(origin=[20, 0, 0]),
"profile": _rectangle([-5, -5], [5, 5])},
{"id": "cut", "workplane": _workplane(origin=[0, 0, 0]),
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 1}},
]},
"features": [
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 10}, "sketch_id": "s1"},
{"id": "add_2", "atomic_id": "extrude_add_blind", "depends_on": ["add_1"],
"params": {"distance_mm": 10}, "sketch_id": "s2"},
{"id": "cut_1", "atomic_id": "extrude_cut_blind", "depends_on": ["add_2"],
"params": {"distance_mm": 10}, "sketch_id": "cut"},
],
}
rebuilt = rebuild_cdsl(with_cut, root / "cut.step")
self.assertAlmostEqual(rebuilt["volume_mm3"], 2000 - math.pi * 10, places=5)
self.assertEqual(rebuilt["solid_count"], 2)
# 最后主体(cut 后)仍是 2 个独立实体,每个都有拓扑记录。
member_ids = {
r["body_id"] for r in rebuilt["topology_records"]
if r["kind"] == "face" and r.get("body_id", "").startswith("body:cut_1:")
}
self.assertEqual(member_ids, {"body:cut_1:0", "body:cut_1:1"})
def test_face_selector_resolves_on_multi_body_via_prefix_matching(self) -> None:
"""多体 + selector resolve 契约:face selector 经前缀匹配命中正确实体。
这是 resolve 前缀匹配的哨兵测试:两个不相交 box 合并为 Compound 后,
拓扑记录属于 body:add_2:0 / body:add_2:1。若 resolve 仍用精确 body_id
过滤(active_body_id=body:add_2 不匹配任何记录),host face 会解析失败;
只有前缀匹配才能让 hole 的宿主面命中第一个 box 的顶面。体积 = 2000 10π
且 box2(x∈[15,25])不受影响,证明解析到的确实是正确实体。
"""
base = _two_boss_doc()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(base, root / "baseline.step")
# 第一个 box 的顶面:center ≈ (0, 0, 10)。
top_face = next(
item for item in baseline["topology_records"]
if item["kind"] == "face"
and item["geometry"]["surface_type"] == "plane"
and item["geometry"]["normal"][2] > 0.9
and abs(item["geometry"]["center_mm"][0]) < 1.0
)
with_hole = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"part_id": "multi-body-hole", "meta": {"unit": "mm"},
"geometry": {"sketches": [
{"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
"profile": _rectangle([-5, -5], [5, 5])},
{"id": "s2", "workplane": _workplane(origin=[20, 0, 0]),
"profile": _rectangle([-5, -5], [5, 5])},
]},
"features": [
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 10}, "sketch_id": "s1"},
{"id": "add_2", "atomic_id": "extrude_add_blind", "depends_on": ["add_1"],
"params": {"distance_mm": 10}, "sketch_id": "s2"},
{"id": "hole_1", "atomic_id": "hole_wizard", "depends_on": ["add_2"],
"params": {
"hole_type": "简单直孔", "diameter_mm": 2.0, "depth_mm": 10.0,
"end_condition": {"type": "blind"},
"positions": [{"mm": [0.0, 0.0, 10.0]}],
"host_face": {"kind": "face", "stable_id": "top", "source": "inferred_from_step",
"confidence": 1, "geometry": top_face["geometry"]},
}},
],
}
rebuilt = rebuild_cdsl(with_hole, root / "hole.step")
self.assertAlmostEqual(rebuilt["volume_mm3"], 2000 - math.pi * 10, places=5)
self.assertEqual(rebuilt["solid_count"], 2)
def test_multi_body_collapse_reverts_to_flat_body_id(self) -> None:
"""转换护栏:多体塌缩回单体后 body_id 恢复单体制(无 :index 后缀)。
两个 box 合并为多体后,用大切除把第二个 box 整体切掉 → 主体恢复为
单个 Solid → register_body 应回到 body:{feature}flat),后续
selector 行为与普通单体零件完全一致。
"""
base = _two_boss_doc()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with_cut = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"part_id": "multi-body-collapse", "meta": {"unit": "mm"},
"geometry": {"sketches": [
{"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
"profile": _rectangle([-5, -5], [5, 5])},
{"id": "s2", "workplane": _workplane(origin=[20, 0, 0]),
"profile": _rectangle([-5, -5], [5, 5])},
{"id": "cut", "workplane": _workplane(origin=[14.5, 0, 0]),
"profile": _rectangle([0, -5.5], [11, 5.5])},
]},
"features": [
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 10}, "sketch_id": "s1"},
{"id": "add_2", "atomic_id": "extrude_add_blind", "depends_on": ["add_1"],
"params": {"distance_mm": 10}, "sketch_id": "s2"},
{"id": "cut_2", "atomic_id": "extrude_cut_blind", "depends_on": ["add_2"],
"params": {"distance_mm": 10}, "sketch_id": "cut"},
],
}
rebuilt = rebuild_cdsl(with_cut, root / "collapse.step")
self.assertAlmostEqual(rebuilt["volume_mm3"], 1000.0, places=5)
self.assertEqual(rebuilt["solid_count"], 1)
# 塌缩回单体:最后主体(body:cut_2)的面全部属于 flat 的 body_id
# (无 :index 成员后缀)——而不是像多体时那样带 body:cut_2:0/:1。
# (registry 会保留历史快照供语义继承,因此只断言最后主体的记录。)
cut_faces = {
r["body_id"] for r in rebuilt["topology_records"]
if r["kind"] == "face" and r.get("body_id", "").startswith("body:cut_2")
}
self.assertEqual(cut_faces, {"body:cut_2"})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,310 @@
"""#6 Pattern selector 变换:固定终止面/宿主面的 pattern source 必须放行。
中文说明
--------
这个文件在测试什么(issue #6「Pattern 严禁 source selector/host selector/
extent selector」的回归测试):
1. 背景:pattern_transform_blocker 原本只放行"带显式 host frame"的孔
"带显式 axis 坐标"的旋转轴。真实语料里绝大多数 pattern source
的依赖是**主体上的固定面**,而非随实例移动的几何:
- hole_wizard 的宿主面是 face selector(带几何快照、无 frame),
孔位置由 positions 平移决定(_translated_node),宿主面本身
resolve 原面即可正确打孔;
- up_to_surface 拉伸的终止面是 face reference,终止面不随实例
平移(CAD 阵列语义:每个实例拉伸到同一终止面),且 #5 修复后
非均匀相交可裁剪。
修复前这些 source 一律报 unsupported_pattern_selector_transform
669 个 pattern 文档里 121 个 source 被整体拒绝。
修复后:face selector(孔宿主面)与 face reference(终止面)放行,
由运行时 resolve 原面执行;edge/vertex selector(需逐实例变换但
无法平移)与 mirror-as-source(镜像面需逐实例平移,架构不支持)
仍保持显式阻塞,避免产出错误几何。
2. 本测试套件把"固定面依赖放行、逐实例拓扑依赖仍阻塞"固定下来:
- analyze 契约:无 frame 孔(face selector)→ executable
- analyze 契约:up_to_surface 拉伸(face reference)→ executable
- 回归护栏:mirror-as-sourceplane selector)→ 仍阻塞
unsupported_pattern_selector_transform 保留);
- 几何契约:无 frame 孔 pattern → 源孔 + 实例孔各切一个圆柱;
- 几何契约:up_to_surface 拉伸 pattern → 源块 + 源拉伸 + 实例
拉伸(实例部分悬空被 #5 裁剪)。
3. sys.path 说明:把 backend/engine 加入搜索路径,直接 import cdsl_engine
包做端到端测试(与既有测试风格一致)。
函数功能一览
------------
_workplane(origin, normal) 构造指定原点与法向的草图工作平面。
_rectangle(minimum, maximum) 构造 XY 平面内的矩形轮廓(2D 多边形)。
_base_block() 构造 10×10×10 拉伸主体(体积 1000,
顶面 z=10、范围 x/y ∈ [-5, 5])。
_top_face_selector(baseline) 从 baseline 拓扑记录里挑出顶面(法向 +z)
的几何快照,作为宿主面 / 终止面 reference。
PatternTransformContractTests 见各测试方法 docstring。
"""
from __future__ import annotations
import math
import sys
import tempfile
import unittest
from copy import deepcopy
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
sys.path.insert(0, str(ROOT / "backend" / "engine"))
from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl # noqa: E402
# ---------------------------------------------------------------------------
# 测试夹具
# ---------------------------------------------------------------------------
def _workplane(*, origin: list[float], normal: list[float]) -> dict:
"""构造草图工作平面:显式指定原点与法向(x_dir 固定 +X)。"""
return {"origin_mm": origin, "x_dir": [1, 0, 0], "normal": normal}
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
"""XY 平面内的矩形轮廓(2D 多边形),顶点逆时针。"""
return {"type": "polygon", "vertices": [
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
]}
def _base_block() -> dict:
"""10×10×10 拉伸主体:体积 1000,顶面位于 z=10、范围 x/y ∈ [-5, 5]。"""
return {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"part_id": "pattern-transform-contract", "meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "base", "workplane": _workplane(origin=[0, 0, 0], normal=[0, 0, 1]),
"profile": _rectangle([-5, -5], [5, 5]),
}]},
"features": [{
"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 10}, "sketch_id": "base",
}],
}
def _top_face_selector(baseline: dict) -> dict:
"""从 baseline 拓扑记录里取顶面(法向 +z 的平面 face)的几何快照。
这个快照被 TopologyRegistry 用来做几何等价匹配,从而把宿主面 / 终止面
reference 解析到重建主体上的真实 Face。
"""
return next(
item for item in baseline["topology_records"]
if item["kind"] == "face"
and item["geometry"]["surface_type"] == "plane"
and item["geometry"]["normal"][2] > 0.9
)
# ---------------------------------------------------------------------------
# 测试套件
# ---------------------------------------------------------------------------
class PatternTransformContractTests(unittest.TestCase):
"""pattern source 固定面依赖「能力边界-几何-护栏」三方合同测试。"""
def test_pattern_replays_face_selector_hole_is_eligible(self) -> None:
"""analyze 契约:无 frame 孔(face selector 宿主面)→ executable。
宿主面是主体上的固定面:实例孔位置由 positions 平移决定,宿主面
resolve 原面即可。修复前 capabilities 把它当成"无法变换的 feature
selector"报 unsupported_pattern_selector_transform → 整个零件
runtime_eligible=False → 本测试红灯。
"""
base = _base_block()
base["features"].extend([
{
"id": "hole_1", "atomic_id": "hole_wizard", "depends_on": ["base_add"],
"params": {
"hole_type": "简单直孔", "diameter_mm": 2.0, "depth_mm": 10.0,
"end_condition": {"type": "blind"},
"positions": [{"mm": [0.0, 0.0, 10.0]}],
"host_face": {
"kind": "face", "stable_id": "top", "source": "inferred_from_step",
"confidence": 1, "geometry": {"bbox_mm": [-5, -5, 10, 5, 5, 10],
"center_mm": [0, 0, 10],
"normal": [0, 0, 1],
"surface_type": "plane"},
},
},
},
{
"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["hole_1"],
"params": {"source_feature_ids": ["hole_1"], "direction_1": [1, 0, 0],
"spacing_1_mm": 4, "pattern_count_1": 2},
},
])
analysis = analyze_cdsl(base)
pattern = next(item for item in analysis.feature_results if item.feature_id == "repeat")
self.assertTrue(pattern.executable)
self.assertNotIn("unsupported_pattern_selector_transform", [blocker.code for blocker in pattern.blockers])
def test_pattern_replays_up_to_surface_extrusion_is_eligible(self) -> None:
"""analyze 契约:up_to_surface 拉伸(face reference 终止面)→ executable。
终止面是主体上的固定面,不随实例平移(CAD 阵列语义);#5 修复后
非均匀相交走裁剪。修复前 reference 一律返回 extent target selector
阻塞。本测试锁死该依赖被放行。
"""
base = _base_block()
base["geometry"]["sketches"].append({
"id": "cap", "workplane": _workplane(origin=[0, 0, 12], normal=[0, 0, -1]),
"profile": _rectangle([-3, -3], [3, 3]),
})
base["features"].extend([
{
"id": "cap_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
"sketch_id": "cap",
"params": {
"distance_mm": 0,
"end_condition": {"type": "up_to_surface", "reference": {
"kind": "face", "stable_id": "top", "source": "inferred_from_step",
"confidence": 1, "geometry": {"bbox_mm": [-5, -5, 10, 5, 5, 10],
"center_mm": [0, 0, 10],
"normal": [0, 0, 1],
"surface_type": "plane"},
}},
},
},
{
"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["cap_add"],
"params": {"source_feature_ids": ["cap_add"], "direction_1": [1, 0, 0],
"spacing_1_mm": 6, "pattern_count_1": 2},
},
])
analysis = analyze_cdsl(base)
pattern = next(item for item in analysis.feature_results if item.feature_id == "repeat")
self.assertTrue(pattern.executable)
self.assertNotIn("unsupported_pattern_selector_transform", [blocker.code for blocker in pattern.blockers])
def test_mirror_source_stays_blocked(self) -> None:
"""回归护栏:mirror-as-sourceplane selector)仍阻塞。
pattern_mirror 的镜像面是 reference_plane 引用;线性阵列重放镜像
特征需要把镜像面逐实例平移,当前执行器没有实例变换通道,放行会
产出"所有实例重合"的错误几何。因此 mirror 特征作为 pattern source
必须继续保持 unsupported_pattern_selector_transform。
"""
base = _base_block()
base["features"].extend([
{
"id": "plane_ctx", "atomic_id": "reference_plane", "depends_on": [],
"params": {"plane": _workplane(origin=[0, 0, 0], normal=[0, 1, 0])},
},
{
"id": "mirror", "atomic_id": "pattern_mirror", "depends_on": ["base_add", "plane_ctx"],
"params": {"source_feature_ids": ["base_add"], "mirror_plane": {
"kind": "plane", "stable_id": "plane-1", "source": "solidworks",
"confidence": 1, "owner_feature_id": "plane_ctx",
}},
"selectors": [{
"kind": "plane", "stable_id": "plane-1", "source": "solidworks",
"confidence": 1, "owner_feature_id": "plane_ctx",
}],
},
{
"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["mirror"],
"params": {"source_feature_ids": ["mirror"], "direction_1": [1, 0, 0],
"spacing_1_mm": 10, "pattern_count_1": 2},
},
])
analysis = analyze_cdsl(base)
pattern = next(item for item in analysis.feature_results if item.feature_id == "repeat")
self.assertFalse(pattern.executable)
self.assertIn("unsupported_pattern_selector_transform", [blocker.code for blocker in pattern.blockers])
def test_pattern_replays_face_selector_hole_geometry(self) -> None:
"""几何契约:无 frame 孔 pattern → 源孔 + 实例孔各切一个圆柱。
baseline 10×10×10(顶面 z=10)。hole_wizard 宿主面是 face selector
(无 frame),positions=[(0,0,10)] 直径 2 深 10(贯穿)。pattern 沿
+x 间距 4 → 实例孔在 (4,0,10),与源孔不重叠。期望体积 =
1000 2×π×1²×10。修复前 capabilities 拒绝整个零件,本测试红灯。
"""
base = _base_block()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(base, root / "baseline.step")
top_face = _top_face_selector(baseline)
with_holes = deepcopy(base)
with_holes["features"].extend([
{
"id": "hole_1", "atomic_id": "hole_wizard", "depends_on": ["base_add"],
"params": {
"hole_type": "简单直孔", "diameter_mm": 2.0, "depth_mm": 10.0,
"end_condition": {"type": "blind"},
"positions": [{"mm": [0.0, 0.0, 10.0]}],
"host_face": {"kind": "face", "stable_id": "top", "source": "inferred_from_step",
"confidence": 1, "geometry": top_face["geometry"]},
},
},
{
"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["hole_1"],
"params": {"source_feature_ids": ["hole_1"], "direction_1": [1, 0, 0],
"spacing_1_mm": 4, "pattern_count_1": 2},
},
])
rebuilt = rebuild_cdsl(with_holes, root / "patterned-holes.step")
self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 - 2 * math.pi * 10, places=5)
def test_pattern_replays_up_to_surface_extrusion_geometry(self) -> None:
"""几何契约:up_to_surface 拉伸 pattern → 源 + 实例(实例悬空被裁剪)。
baseline 10×10×10(顶面 z=10,范围 [-5,5]²)。源 capz=12 平面、
法向 -z,profile 6×6(完全在顶面内)→ 均匀拉伸到顶面,体积 6²×2=72。
pattern 沿 +x 间距 6 → 实例 profile 落在 x ∈ [3,9],与顶面交集为
x ∈ [3,5]、y ∈ [-3,3] → 部分悬空 → #5 裁剪,体积 2×6×2=24。
期望总体积 = 1000 + 72 + 24 = 1096。
"""
base = _base_block()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(base, root / "baseline.step")
top_face = _top_face_selector(baseline)
with_cap = deepcopy(base)
with_cap["geometry"]["sketches"].append({
"id": "cap", "workplane": _workplane(origin=[0, 0, 12], normal=[0, 0, -1]),
"profile": _rectangle([-3, -3], [3, 3]),
})
with_cap["features"].extend([
{
"id": "cap_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
"sketch_id": "cap",
"params": {
"distance_mm": 0,
"end_condition": {"type": "up_to_surface", "reference": {
"kind": "face", "stable_id": "top", "source": "inferred_from_step",
"confidence": 1, "geometry": top_face["geometry"],
}},
},
},
{
"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["cap_add"],
"params": {"source_feature_ids": ["cap_add"], "direction_1": [1, 0, 0],
"spacing_1_mm": 6, "pattern_count_1": 2},
},
])
rebuilt = rebuild_cdsl(with_cap, root / "patterned-caps.step")
self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 + 72 + 24, places=5)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,119 @@
"""#1 y_dir 静默丢弃:PlaneSpec.from_mapping 与 sketch_solver 必须尊重输入的 y_dir。
中文说明
--------
这个文件在测试什么(issue #1「y_dir 静默丢弃」的回归测试):
1. 背景:SolidWorks 导出草图常带冗余或非正交的 y_dir。
runtime 的 PlaneSpec.from_mappingruntime_types.py:172-180)与
sketch_solver._to_3dsketch_solver.py:43-52)都**无视输入的
y_dir 字段**,永远用 normal × x_dir 叉积补全。后果:
a) 文档作者显式给出的**正交** y_dir 被悄悄替换成叉积结果
(当 x_dir 与 normal 不正交时,重建的 y_dir 与真实几何不符);
b) **偏斜** y_dir 被静默丢弃,没有任何提示,用户不知道
坐标系被正交化过了。
2. 本测试把 y_dir 处理契约固定下来:
- 输入 y_dir 存在且与 x_dir / normal 正交 → **保留**输入值;
- 输入 y_dir 存在但偏斜(非正交)→ 正交化 + **显式 UserWarning**
- 输入 y_dir 缺失 → 用 normal × x_dir 补全(回归护栏);
- sketch_solver 的轮廓点变换(_transform_contours → _to_3d
同样尊重正交的输入 y_dir。
3. sys.path 说明:把 backend/engine 加入搜索路径,直接 import
cdsl_engine 包内模块(与前面几个回归测试风格一致)。
"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
sys.path.insert(0, str(ROOT / "backend" / "engine"))
from cdsl_engine.runtime_types import PlaneSpec # noqa: E402
from cdsl_engine.sketch_solver import _transform_contours # noqa: E402
def _assert_vector_close(testcase: unittest.TestCase, actual: tuple | list, expected: tuple | list, *, tol: float = 1e-6) -> None:
testcase.assertEqual(len(actual), len(expected))
for left, right in zip(actual, expected):
testcase.assertAlmostEqual(float(left), float(right), delta=tol)
class PlaneYDirPreservationTests(unittest.TestCase):
"""y_dir 处理契约:正交保留 / 偏斜警告 / 缺失补全。"""
def test_orthogonal_input_y_dir_is_preserved(self) -> None:
"""主契约:输入的正交 y_dir 必须被保留,而不是被叉积结果覆盖。
构造一个 x_dir 与 normal 不垂直的 frame(这是 SolidWorks 导出里
最常见的情形):x_dir=(1,0,0)、normal=(0,1,0)。此时
normal × x_dir = (0,0,-1),但文档显式给出 y_dir=(0,0,1)。
修复前:PlaneSpec.y_dir == (0,0,-1)(静默丢弃,红灯)。
修复后:PlaneSpec.y_dir == (0,0,1)(保留输入,绿灯)。
"""
plane = PlaneSpec.from_mapping({
"origin_mm": [0.0, 0.0, 0.0],
"x_dir": [1.0, 0.0, 0.0],
"y_dir": [0.0, 0.0, 1.0],
"normal": [0.0, 1.0, 0.0],
})
_assert_vector_close(self, plane.y_dir, (0.0, 0.0, 1.0))
def test_skewed_input_y_dir_orthogonalizes_and_warns(self) -> None:
"""主契约:偏斜 y_dir 必须被正交化,并且**显式**发出 UserWarning。
输入 y_dir=(0, 1, 1) 与 normal=(0,0,1) 的夹角不是 90°,无法直接
作为右手系 y 轴。修复前:静默替换为 normal × x_dir(无警告,红灯)。
修复后:正交化为 normal × x_dir,同时 raise UserWarning(绿灯)。
"""
with self.assertWarns(UserWarning):
plane = PlaneSpec.from_mapping({
"origin_mm": [0.0, 0.0, 0.0],
"x_dir": [1.0, 0.0, 0.0],
"y_dir": [0.0, 1.0, 1.0],
"normal": [0.0, 0.0, 1.0],
})
# normal × x_dir = (0,0,1) × (1,0,0) = (0,1,0),且为右手系补全
_assert_vector_close(self, plane.y_dir, (0.0, 1.0, 0.0))
def test_missing_y_dir_gets_orthonormal_default(self) -> None:
"""回归护栏:y_dir 字段缺失时,仍用 normal × x_dir 补全。
修复前后都应通过,这条测试防止我们把默认补全路径改坏。
"""
plane = PlaneSpec.from_mapping({
"origin_mm": [0.0, 0.0, 0.0],
"x_dir": [1.0, 0.0, 0.0],
"normal": [0.0, 0.0, 1.0],
})
_assert_vector_close(self, plane.y_dir, (0.0, 1.0, 0.0))
def test_contour_transform_respects_input_y_dir(self) -> None:
"""端到端:sketch_solver 的轮廓点变换必须尊重正交的输入 y_dir。
workplane 与 test_orthogonal_input_y_dir_is_preserved 相同
x=(1,0,0), n=(0,1,0), 输入 y=(0,0,1))。一条从 (0,0) 到 (0,1)
的轮廓直线,修复前 _to_3d 用默认 y_dir=(0,0,-1) 变换,终点落到
(0,0,-1);修复后用输入 y_dir=(0,0,1),终点落到 (0,0,1)。
"""
workplane = {
"origin_mm": [0.0, 0.0, 0.0],
"x_dir": [1.0, 0.0, 0.0],
"y_dir": [0.0, 0.0, 1.0],
"normal": [0.0, 1.0, 0.0],
}
contours = _transform_contours([
{"type": "line", "start_mm": [0.0, 0.0], "end_mm": [0.0, 1.0]},
], workplane)
_assert_vector_close(self, contours[0]["start_mm"], (0.0, 0.0, 0.0))
_assert_vector_close(self, contours[0]["end_mm"], (0.0, 0.0, 1.0))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,213 @@
"""#3 revolve.reverse:旋转方向反转必须进入 runtime 的旋转计算。
中文说明
--------
这个文件在测试什么(issue #3「revolve.reverse 未进入旋转方向计算」的回归测试):
1. 背景:三方合同错位——
- 人读契约 profile_schema.json 已声明 revolve_add / revolve_cut 的
optional_params 含 reverse
- 机器契约 cdsl_schema.json revolveParams 也已允许 reverse 字段;
- 但 runtimebackend/engine/cdsl_engine/runtime.py:343-349 的
_shape_from_primary revolve 分支)只读取 angle_deg,完全忽略
reverse,导致 reverse=true 的旋转特征被静默当作正向旋转,
实体生成在轴的错误一侧(与 #1 y_dir 同类:合法字段被静默丢弃)。
- 真实数据(如 json_to_cdsl/output/013003.cdsl.json)里 revolve
特征大量携带 reverse: true,一旦 revolve 的 selector 捕获问题
(deferred)解禁,成批旋转特征将几何方向错误。
2. 本测试套件把"reverse=true 必须绕轴反向扫掠"的契约固定下来:
- 主契约:reverse=true 与 reverse=false 的实体 bbox 落在轴的两侧
(z 范围符号相反),方向确实反转;
- 回归护栏:不带 reverse 字段时行为与修复前完全一致(bbox / 体积
不变),不会破坏现有正向旋转;
- 等价性:reverse=true 与"轴方向取反"在几何上恒等(负旋转角 ≡
反向轴),锁死 reverse 的精确语义;
- 机器契约:带 reverse 的 revolve_add / revolve_cut 必须通过
cdsl_schema.json 校验。
3. sys.path 说明:把 backend/engine 加入搜索路径,是为了直接 import
cdsl_engine 包做端到端测试(与 test_engine_host_face_contract.py
风格一致)。
旋转方向约定(已在修复前实测确认)
------------------------------------
profile 矩形 [2,1]-[4,2] 位于 XY 平面(z=0),绕 X 轴(direction=(1,0,0)
与 face 平面共面)旋转 90°:
- 不 reverse:实体落在 y/z 第一象限,bbox z∈[0,2]
- reverse :实体落在 y/z 第四象限,bbox z∈[-2,0]。
若旋转轴垂直于 profile 平面(如绕 Z 轴),build123d 会产出退化薄片
(volume=0),因此夹具必须让轴与 profile 平面共面(与
test_engine_runtime_foundation.test_revolve_can_resolve_an_owner_qualified_reference_axis
一致)。
"""
from __future__ import annotations
import json
import math
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
sys.path.insert(0, str(ROOT / "backend" / "engine"))
import jsonschema # noqa: E402
import cdsl_engine # noqa: E402
from cdsl_engine.runtime import rebuild_cdsl # noqa: E402
# cdsl_schema.json 路径:随 cdsl_engine 包部署。
_SCHEMA_PATH = Path(cdsl_engine.__file__).parent / "cdsl_schema.json"
_SCHEMA = json.loads(_SCHEMA_PATH.read_text(encoding="utf-8"))
try:
import build123d # noqa: F401
_HAS_BUILD123D = True
except ImportError:
_HAS_BUILD123D = False
# ---------------------------------------------------------------------------
# 测试夹具:构造最小 revolve CDSL 文档
# ---------------------------------------------------------------------------
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
"""XY 平面内的矩形轮廓:顶点按逆时针顺序排列。"""
return {"type": "polygon", "vertices": [
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
]}
def _revolve_cdsl(*, atomic_id: str = "revolve_add", reverse: bool | None = None,
axis_direction: list[float] | None = None) -> dict:
"""最小 revolve 文档。
矩形 [2,1]-[4,2] 位于 XY 平面(z=0),绕 X 轴旋转 90°。轴默认
(1,0,0),可通过 axis_direction 覆盖(用于等价性测试)。reverse 为
None 时完全不写该字段(回归护栏:缺省正向)。
"""
params: dict = {
"angle_deg": 90.0,
"axis": {"origin_mm": [0, 0, 0], "direction": axis_direction or [1, 0, 0]},
}
if reverse is not None:
params["reverse"] = reverse
return {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"part_id": "revolve-reverse-contract", "meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "profile",
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
"profile": _rectangle([2, 1], [4, 2]),
}]},
"features": [{
"id": "turn", "atomic_id": atomic_id, "depends_on": [], "sketch_id": "profile",
"params": params,
}],
}
def _rebuild(cdsl: dict) -> dict:
"""临时目录内重建文档并返回 runtime 结果。"""
with tempfile.TemporaryDirectory() as directory:
return rebuild_cdsl(cdsl, Path(directory) / "part.step")
def _validate_against_cdsl_schema(doc: dict) -> None:
"""对整张 CDSL 文档跑 cdsl_schema.json 校验。"""
jsonschema.validate(instance=doc, schema=_SCHEMA)
# ---------------------------------------------------------------------------
# 测试套件
# ---------------------------------------------------------------------------
class RevolveReverseContractTests(unittest.TestCase):
"""revolve.reverse「人读契约-机器契约-运行时」三方一致性的回归测试。"""
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
def test_revolve_reverse_true_flips_rotation_direction(self) -> None:
"""主契约:reverse=true 必须让旋转体落在轴的相反侧。
修复前(当前):runtime 忽略 reverse,正反旋转结果 bbox 完全相同
(都在 z∈[0,2] 第一象限)→ 本测试红灯。
修复后:reverse=true 取负旋转角,实体落在 z∈[-2,0] 第四象限 →
绿灯。z 范围符号相反即方向确实反转。
"""
forward = _rebuild(_revolve_cdsl(reverse=False))
flipped = _rebuild(_revolve_cdsl(reverse=True))
forward_min_z = forward["bbox_mm"]["min"][2]
forward_max_z = forward["bbox_mm"]["max"][2]
flipped_min_z = flipped["bbox_mm"]["min"][2]
flipped_max_z = flipped["bbox_mm"]["max"][2]
# 正向:z 落在 [0, 2];反向:z 落在 [-2, 0]。
self.assertAlmostEqual(forward_min_z, 0.0, places=5)
self.assertAlmostEqual(forward_max_z, 2.0, places=5)
self.assertAlmostEqual(flipped_min_z, -2.0, places=5)
self.assertAlmostEqual(flipped_max_z, 0.0, places=5)
# 语义锁死:方向相反意味着 z 范围严格位于轴的两侧,互不重叠。
self.assertGreater(forward_max_z, flipped_max_z)
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
def test_revolve_without_reverse_keeps_forward_direction(self) -> None:
"""回归护栏:不带 reverse 字段时行为与修复前完全一致。
修复前 runtime 本来就把 revolve 当正向旋转处理;修复后缺省路径
必须保持不动(不 reverse 就绝不能取负角)。bbox 与体积都要和
修复前一致:z∈[0,2]、volume = 3π/21/4 圆柱壳,内半径 1、
外半径 2、轴向长 2)。
"""
result = _rebuild(_revolve_cdsl(reverse=None))
bbox = result["bbox_mm"]
self.assertAlmostEqual(bbox["min"][0], 2.0, places=5)
self.assertAlmostEqual(bbox["max"][0], 4.0, places=5)
self.assertAlmostEqual(bbox["min"][2], 0.0, places=5)
self.assertAlmostEqual(bbox["max"][2], 2.0, places=5)
self.assertAlmostEqual(result["volume_mm3"], 1.5 * math.pi, places=5)
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
def test_revolve_reverse_matches_axis_inversion(self) -> None:
"""等价性:reverse=true ≡ 轴方向取反(负旋转角与反向轴几何恒等)。
这条测试锁死 reverse 的精确语义——它是"绕轴反向扫掠",等价于把
轴方向反转后再正向扫掠。两者 bbox 与体积必须逐分量一致。
"""
flipped = _rebuild(_revolve_cdsl(reverse=True))
axis_inverted = _rebuild(_revolve_cdsl(reverse=False, axis_direction=[-1, 0, 0]))
for axis_name in ("min", "max"):
for component in range(3):
self.assertAlmostEqual(
flipped["bbox_mm"][axis_name][component],
axis_inverted["bbox_mm"][axis_name][component],
places=5,
msg=f"bbox {axis_name}[{component}] must match axis inversion",
)
self.assertAlmostEqual(flipped["volume_mm3"], axis_inverted["volume_mm3"], places=5)
def test_revolve_params_reverse_passes_machine_schema(self) -> None:
"""机器契约:带 reverse 的 revolve_add / revolve_cut 必须通过 schema。
修复前/修复后均应通过(cdsl_schema.json revolveParams 早已允许
reverse)。这条测试是三方合同的一部分:保证机器契约确实承认这个
字段,runtime 侧修复才名正言顺。
"""
_validate_against_cdsl_schema(_revolve_cdsl(atomic_id="revolve_add", reverse=True))
_validate_against_cdsl_schema(_revolve_cdsl(atomic_id="revolve_cut", reverse=True))
if __name__ == "__main__":
unittest.main()
@@ -658,6 +658,40 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
self.assertLess(chamfer_result["volume_mm3"], baseline["volume_mm3"])
self.assertAlmostEqual(pattern_result["volume_mm3"], 1000 - 3 * 10 * 3.141592653589793, places=5)
def test_chamfer_consumes_angle_rad_instead_of_silent_45_degree_fallback(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
import math
base = self._base_block()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(base, root / "baseline.step")
edge = next(item for item in baseline["topology_records"] if item["kind"] == "edge")
selector = {
"kind": "edge", "stable_id": "edge", "source": "solidworks", "confidence": 1,
"owner_feature_id": "base_add", "geometry": edge["geometry"],
}
equal = deepcopy(base)
equal["features"].append({
"id": "chamfer_45", "atomic_id": "chamfer", "depends_on": ["base_add"],
"params": {"distance_mm": 1, "angle_rad": math.pi / 4}, "selectors": [deepcopy(selector)],
})
equal_result = rebuild_cdsl(equal, root / "chamfer-45.step")
# 45° Distance-Angle 等价于等距倒角:tan(45°)=1,切掉 0.5*1*1*10=5 mm³。
self.assertAlmostEqual(equal_result["volume_mm3"], 1000 - 5, places=5)
slanted = deepcopy(base)
slanted["features"].append({
"id": "chamfer_30", "atomic_id": "chamfer", "depends_on": ["base_add"],
"params": {"distance_mm": 1, "angle_rad": math.pi / 6}, "selectors": [deepcopy(selector)],
})
slanted_result = rebuild_cdsl(slanted, root / "chamfer-30.step")
# 30°:第二距离 = 1*tan(30°)≈0.577,切掉 0.5*1*0.577*10≈2.887 mm³,
# 体积明显大于 45° 等距倒角(995),验证 angle_rad 被消费而非静默 45°。
self.assertAlmostEqual(slanted_result["volume_mm3"], 1000 - 0.5 * math.tan(math.pi / 6) * 10, places=5)
def test_linear_pattern_replays_hole_with_explicit_host_frame(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
@@ -0,0 +1,221 @@
"""#8 selector 持久性:上游 fillet 消费边之后的 selector 解析回归测试。
中文说明
--------
这个文件在测试什么issue #8「Selector 持久性」的回归测试):
1. 背景selector stable_id 与几何签名来自特征执行前的 B-rep上游
fillet/chamfer 会重建被选中边附近的拓扑
- fillet 直接选中的边会被双端圆角拆分成"两条等距直段 + 两段
圆弧",几何上不存在唯一"同一条边"的后继(本质歧义,CAD 中该边
也被视为已消费
- 与被圆角边共享端点的相邻边则只被"单端缩短"方向不变另一端
端点重合它有一个明确的演化后继
修复前相邻边的 selector fillet 之后 resolve 失败not_found
因为记录已迁移到新 body 且几何签名变了
修复后#8):registry 记录"位置轨迹延续"的演化后继映射
old_record_id -> 漂移显著最小的唯一后继resolve stable_id
精确匹配在 active body 过滤之前执行命中过期记录时经演化后继解析
active body 内的新形态
2. 本测试套件把"被波及边可解析、被消费边保持保守"固定下来
- 核心契约fillet 圆角竖直边 A 引用相邻底面边 B selector
仍能 resolveB 上的二次 fillet 重建成功体积减少
- 护栏引用被完整消费的边 A selector 保持 not_found不把相邻
直段误匹配为 A 的延续确定性优先
- 护栏引用未被 fillet 波及的边的 selector 行为不变
3. sys.path 说明 backend backend/engine 加入搜索路径直接 import
cdsl_engine 包做端到端测试与既有测试风格一致
函数功能一览
------------
_workplane(origin) 构造指定原点的 XY 平面草图工作平面
_rectangle(minimum, maximum) 构造 XY 平面内的矩形轮廓2D 多边形
_boss_doc() 10×10×10 单体拉伸文档体积 1000
_baseline_edges(...) 从基线重建里挑出 A/B/C 三条边并构造 selector
SelectorPersistenceTests 见各测试方法 docstring
"""
from __future__ import annotations
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
sys.path.insert(0, str(ROOT / "backend" / "engine"))
from cdsl_engine.runtime import rebuild_cdsl # noqa: E402
# ---------------------------------------------------------------------------
# 测试夹具
# ---------------------------------------------------------------------------
def _workplane(*, origin: list[float]) -> dict:
return {"origin_mm": origin, "x_dir": [1, 0, 0], "normal": [0, 0, 1]}
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
return {"type": "polygon", "vertices": [
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
]}
def _boss_doc() -> dict:
return {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"part_id": "selector-persist", "meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
"profile": _rectangle([-5, -5], [5, 5]),
}]},
"features": [
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 10}, "sketch_id": "s1"},
],
}
def _baseline_edges():
"""从基线重建里挑出测试用的三条边。
A = 竖直边 [-5,-5,0][-5,-5,10] fillet 直接选中将被完整消费
B = 底面边 [-5,-5,0][5,-5,0] A 共享端点 [-5,-5,0]被单端缩短
C = 竖直边 [5,5,0][5,5,10]远离 A/B完全不受 fillet 影响
"""
with tempfile.TemporaryDirectory() as directory:
baseline = rebuild_cdsl(_boss_doc(), Path(directory) / "base.step")
def _edge(match) -> dict:
return next(
item for item in baseline["topology_records"]
if item["kind"] == "edge" and match(item["geometry"])
)
def _selector(record: dict) -> dict:
return {"kind": "edge", "stable_id": record["record_id"],
"source": "inferred_from_step", "confidence": 1,
"geometry": record["geometry"]}
edge_a = _edge(lambda g: g.get("curve_type") == "line"
and g.get("start_mm") == [-5.0, -5.0, 0.0]
and g.get("end_mm") == [-5.0, -5.0, 10.0])
edge_b = _edge(lambda g: g.get("curve_type") == "line"
and g.get("start_mm") == [-5.0, -5.0, 0.0]
and g.get("end_mm") == [5.0, -5.0, 0.0])
edge_c = _edge(lambda g: g.get("curve_type") == "line"
and g.get("start_mm") == [5.0, 5.0, 0.0]
and g.get("end_mm") == [5.0, 5.0, 10.0])
return _selector(edge_a), _selector(edge_b), _selector(edge_c)
# ---------------------------------------------------------------------------
# 测试套件
# ---------------------------------------------------------------------------
class SelectorPersistenceTests(unittest.TestCase):
"""#8 selector 持久性契约:被波及边可解析、被消费边保持保守。"""
def test_consumed_adjacent_edge_selector_resolves_after_fillet(self) -> None:
"""核心契约:fillet 圆角 A 后,引用相邻边 B 的 selector 仍可 resolve。
fillet_1 圆角竖直边 Aradius 2把与其共享端点 [-5,-5,0] 的底面
B 单端缩短为 [-3,-5,0][5,-5,0]fillet_2 用修复前捕获的 B
selector 再圆角一次radius 1
- 修复前B 记录已迁移到 body:fillet_1 且几何签名变化 not_found
重建抛 RuntimeExecutionError
- 修复后演化后继解析到 B 的缩短形态 重建成功且体积减少
"""
selector_a, selector_b, _ = _baseline_edges()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(_boss_doc(), root / "base.step")
with_fillets = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"part_id": "selector-persist", "meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
"profile": _rectangle([-5, -5], [5, 5]),
}]},
"features": [
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 10}, "sketch_id": "s1"},
{"id": "fillet_1", "atomic_id": "fillet", "depends_on": ["add_1"],
"params": {"radius_mm": 2}, "selectors": [selector_a]},
{"id": "fillet_2", "atomic_id": "fillet", "depends_on": ["fillet_1"],
"params": {"radius_mm": 1}, "selectors": [selector_b]},
],
}
rebuilt = rebuild_cdsl(with_fillets, root / "two-fillets.step")
self.assertLess(rebuilt["volume_mm3"], baseline["volume_mm3"])
self.assertGreater(rebuilt["volume_mm3"], 900.0)
def test_fully_consumed_edge_selector_stays_conservative(self) -> None:
"""护栏:被完整消费的边 A 的 selector 保持 not_found(确定性优先)。
fillet_1 圆角 A A 被拆分成两条等距直段 + 两段圆弧几何上没有
唯一后继两条直段到原边的漂移并列此场景下 registry 不登记演化
映射二次引用 A 应明确失败而不是把相邻直段误匹配成 A 的延续
"""
selector_a, _, _ = _baseline_edges()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with_second = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"part_id": "selector-persist", "meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
"profile": _rectangle([-5, -5], [5, 5]),
}]},
"features": [
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 10}, "sketch_id": "s1"},
{"id": "fillet_1", "atomic_id": "fillet", "depends_on": ["add_1"],
"params": {"radius_mm": 2}, "selectors": [selector_a]},
{"id": "fillet_2", "atomic_id": "fillet", "depends_on": ["fillet_1"],
"params": {"radius_mm": 1}, "selectors": [selector_a]},
],
}
with self.assertRaises(Exception) as caught:
rebuild_cdsl(with_second, root / "second.step")
message = str(caught.exception)
self.assertIn("No runtime topology record satisfies the selector", message)
def test_unaffected_edge_selector_resolves_after_fillet(self) -> None:
"""护栏:远离 fillet 的边 C 的 selector 行为不受影响。
fillet_1 圆角 A 引用 C[5,5,0][5,5,10]几何完全不变
selector 通过几何打分照常 resolve二次 fillet 重建成功
"""
selector_a, _, selector_c = _baseline_edges()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with_fillets = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"part_id": "selector-persist", "meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
"profile": _rectangle([-5, -5], [5, 5]),
}]},
"features": [
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 10}, "sketch_id": "s1"},
{"id": "fillet_1", "atomic_id": "fillet", "depends_on": ["add_1"],
"params": {"radius_mm": 2}, "selectors": [selector_a]},
{"id": "fillet_2", "atomic_id": "fillet", "depends_on": ["fillet_1"],
"params": {"radius_mm": 1}, "selectors": [selector_c]},
],
}
rebuilt = rebuild_cdsl(with_fillets, root / "with-c.step")
self.assertLess(rebuilt["volume_mm3"], 1000.0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,170 @@
"""#10 Profile chain: evidence_v2 importer must lower annulus to analytic_contours.
The CDSL-only runtime and ``cdsl_schema.json`` accept only three generic
profiles (``circle``, ``polygon``, ``analytic_contours``). The legacy
``annulus`` macro is still emitted by ``evidence_v2_to_cdsl._analytic_profile``
for concentric circles, which makes the resulting document schema-invalid and
runtime-ineligible. This test suite pins the lowering contract: concentric
circles must produce an ``analytic_contours`` profile (outer + inner ring).
中文说明
--------
这个文件在测试什么issue #10「10 Profile 链」的回归测试):
1. 背景CDSL-only 运行时backend/engine/cdsl_engine
cdsl_schema.json 只接受三种通用 profile 类型
circle / polygon / analytic_contours
evidence_v2 导入器json_to_cdsl/evidence_v2_to_cdsl.py
_analytic_profile在遇到"同心双圆"垫圈/圆环 annulus 截面
仍会输出旧宏 {type: "annulus"}导致产出的 CDSL 文档 schema 不合法
运行时不可执行 profile_resolution_failed / unsupported_profile
2. 本测试套件把"降级契约"固定下来同心双圆必须降级为
analytic_contours 轮廓外圆 role=outer + 内圆 role=inner
并保证 importer 输出能被 CDSL-only 运行时端到端重建为实体
3. sys.path 说明 json_to_cdsl backend/engine 加入搜索路径
是为了让测试能直接 import 导入器内部的 _analytic_profile灰盒测试
以及 CDSL-only 运行时的 rebuild_cdsl端到端测试
"""
from __future__ import annotations
import math
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
sys.path.insert(0, str(ROOT / "backend" / "engine"))
sys.path.insert(0, str(ROOT / "json_to_cdsl"))
try:
import build123d # noqa: F401
_HAS_BUILD123D = True
except ImportError:
_HAS_BUILD123D = False
from evidence_v2_to_cdsl import _analytic_profile # noqa: E402
def _circle_segment(center: list[float], radius_mm: float, *, construction: bool = False) -> dict:
"""One full SolidWorks sketch circle segment (start == end == center).
构造一条 SolidWorks 草图中的完整圆线段start == end == center
即闭合圆radius_mm 除以 1000 转成米与导入器内部单位保持一致
"""
return {
"geometry": {
"segment_type": "swSketchARC",
"construction": construction,
"start": center,
"end": center,
"center": center,
"direction": 1,
"curve": {"type": "circle", "parameters": [*center, 0, 0, 0, 1, radius_mm / 1000.0]},
}
}
def _ring_cdsl(profile: dict) -> dict:
"""Assemble a minimal CDSL document whose single feature extrudes a ring profile.
组装一份最简 CDSL 文档一张草图ring携带被测试的 profile+
一个拉伸特征extrude_add_blind供端到端测试使用
验证 importer 产出的 profile 能否被运行时重建为实体
"""
return {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.1.0",
"kind": "part",
"part_id": "annulus-ring",
"meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "ring",
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
"profile": profile,
}]},
"features": [{
"id": "ring_add", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 20}, "sketch_id": "ring",
}],
}
class EvidenceV2AnnulusLoweringTests(unittest.TestCase):
"""annulus 降级契约的回归测试套件。
三个测试用例分别覆盖
1. test_evidence_v2_emits_analytic_contours_for_annulus
主契约同心双圆 r=10mm r=5mm必须降级为 analytic_contours
且恰好携带 outer(10) + inner(5) 两条轮廓
2. test_evidence_v2_emits_analytic_contours_for_multiple_circles
回归护栏两个独立圆心不同互不包含的圆本来就输出
analytic_contours修复 annulus 分支时不得破坏该既有行为
3. test_evidence_v2_annulus_output_rebuilds_to_ring_solid
端到端importer 输出的 analytic_contours 必须能被 CDSL-only 运行时
rebuild_cdsl 重建为实体且体积与圆环公式
π * (r外² - r内²) * 高度 一致误差 1%
"""
def test_evidence_v2_emits_analytic_contours_for_annulus(self) -> None:
"""Concentric circles must lower to a generic ring (outer + inner)."""
# 主契约测试:同圆心 (0,0) 的两个圆,外径 10mm、内径 5mm
sketch = {"segments": [
_circle_segment([0, 0], 10.0),
_circle_segment([0, 0], 5.0),
]}
profile = _analytic_profile(sketch)
# 1) profile 类型必须是 analytic_contours(而不是旧宏 annulus
self.assertEqual(profile["type"], "analytic_contours")
contours = profile["contours"]
# 2) 必须恰好有两条轮廓
self.assertEqual(len(contours), 2)
# 3) 两条轮廓的角色分别是 outer(外圆)与 inner(内圆)
self.assertEqual({contour["role"] for contour in contours}, {"outer", "inner"})
outer = next(contour for contour in contours if contour["role"] == "outer")
inner = next(contour for contour in contours if contour["role"] == "inner")
# 4) 半径正确:外 10mm / 内 5mm
self.assertEqual(outer["segments"][0]["radius_mm"], 10.0)
self.assertEqual(inner["segments"][0]["radius_mm"], 5.0)
def test_evidence_v2_emits_analytic_contours_for_multiple_circles(self) -> None:
"""Regression guard: independent circles already lower to contours."""
# 回归护栏:两个圆心不同(相距 30mm)、互不包含的独立圆,
# 修复 annulus 分支前后都必须保持输出 analytic_contours(各一条 outer 轮廓)
sketch = {"segments": [
_circle_segment([0, 0], 10.0),
_circle_segment([30, 0], 5.0),
]}
profile = _analytic_profile(sketch)
self.assertEqual(profile["type"], "analytic_contours")
self.assertEqual(len(profile["contours"]), 2)
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
def test_evidence_v2_annulus_output_rebuilds_to_ring_solid(self) -> None:
"""Importer output must be consumed end-to-end by the CDSL-only runtime."""
# 端到端:直接取 _analytic_profile 的输出组装成 CDSL 文档,
# 交给 CDSL-only 运行时 rebuild_cdsl 重建实体,再断言体积符合圆环公式。
# 此测试验证修复后运行时不再报 profile_resolution_failed / unsupported_profile。
from cdsl_engine.runtime import rebuild_cdsl
sketch = {"segments": [
_circle_segment([0, 0], 10.0),
_circle_segment([0, 0], 5.0),
]}
profile = _analytic_profile(sketch)
cdsl = _ring_cdsl(profile)
with tempfile.TemporaryDirectory() as directory:
out_step = Path(directory) / "ring.step"
result = rebuild_cdsl(cdsl, out_step)
# 圆环体积 = π * (r外² - r内²) * 高度 = π * (100 - 25) * 20
expected = math.pi * (10.0 ** 2 - 5.0 ** 2) * 20.0
self.assertAlmostEqual(result["volume_mm3"], expected, delta=expected * 0.01)
if __name__ == "__main__":
unittest.main()
+124
View File
@@ -0,0 +1,124 @@
# engine 诊断 → 测试方法 → 修改前后对比
## 1. 截图核对结论
`backend\engine` 等价于 `backend/engine/cdsl_engine/`。对截图 10 行逐一比对代码(行号均能命中),结论是 **全部成立**。证据索引如下:
| # | 截图描述 | 代码位置(已核) |
|---|---|---|
| 1 | PlaneSpec 静默丢弃 y_dir,只用 origin/x_dir/normal 重建 | `runtime_types.py:173-180` `PlaneSpec.from_mapping``sketch_solver.py:43-52` `_to_3d` |
| 2 | draft 被 schema 接受但 runtime 未执行 | `cdsl_schema.json:94-99` extrudeParams.draft`runtime.py:338-342` `_shape_from_primary` |
| 3 | revolve.reverse 未进入旋转方向计算 | `runtime.py:344-349` `_shape_from_primary` 分支;`cdsl_schema.json:100-105` |
| 4 | Hole host-face schema/runtime 合同自相矛盾 | `runtime.py:469-483` 支持 frame + selector 两条路径;`cdsl_schema.json:229-243` 仅 selectorRef |
| 5 | 高级终止条件要求整张 profile 同一距离 | `runtime.py:157-211` `_targeted_extent_vector` 拒绝 non_uniform_extent_target |
| 6 | Pattern 严禁 source selector/host selector/extent selector | `capabilities.py:68-91` `pattern_transform_blocker` |
| 7 | ExecutionSession 单 active body | `runtime.py:81-91`#7 已完成:多体 body_id + 前缀匹配) |
| 8 | Selector 持久性依赖几何等价匹配 | `runtime_types.py` `_unique_equivalent_predecessor` / `_geometry_equivalent`#8 已完成:演化后继映射 `_evolved_equivalent` / `_successors`stable_id 跨 body 解析) |
| 9 | HoleSpec 仅支持简单圆柱、沉头、沉孔 | `runtime_types.py:191-250` `HoleSpec` 字段表;`capabilities.py:305-308` thread 拒绝 |
| 10 | Profile 支持链断裂(circles/annulus 未接入 CDSL-only | `profile_schema.json:8` 仅 3 类;`cdsl_importer/solidworks_to_cdsl.py:113-142` 历史 importer 分类器 |
## 2. 测试方法(四层 + phase 分桶)
### 2.1 单元层
新增 `backend/tests/test_engine_diagnostics_baseline.py`,对应 10 条问题写 14 个 testcase(含正反两面):
- 问题 1:正交 y_dir 保留 / 偏斜 y_dir 显式警告或正交化
- 问题 2draft 实体拔模 / 或 schema 拒绝
- 问题 3revolve.reverse 重心落在正确半侧
- 问题 4hole_wizard.host_face.frame 通过 schema
- 问题 5up_to_surface 非均匀 profile 被裁剪而非拒绝
- 问题 6pattern_linear 重放带 host_face.frame 的孔;pattern_linear 重放 up_to_surface 拉伸
- 问题 7multi-body fixture 出现 ≥ 2 个独立 body_id
- 问题 8fillet 后原 edge selector 仍可 resolve(实现于 `backend/tests/test_engine_selector_persistence.py`,含被波及边可解析、被完整消费边保持保守、未受影响边不受干扰三个契约)
- 问题 9thread hole executable 或 schema 显式拒绝
- 问题 10importer 将 circles 降为 analytic_contoursschema 拒绝 legacy profile
### 2.2 数据层
新增 `backend/tests/test_engine_diagnostics_corpus.py`,扫描 `json_to_cdsl/output/*.cdsl.json`(共 5763 份),统计:
- `workplane.x_dir · workplane.y_dir` 与 ‖x‖·‖y‖·cosθ 的偏差分布
- `profile.type ∈ {circles, annulus}` 计数(已部分修复,应单调下降)
- `hole_wizard``thread` 的特征数
- `params.draft` 出现次数
- `params.reverse=true` 的 revolve_* 出现次数
### 2.3 批量层(已有,零成本复用)
```bash
PYTHONPATH=backend/engine python -m cdsl_engine.batch_rebuild \
json_to_cdsl/output /tmp/batch-before --build --build-timeout 15
```
产物:
- `manifest.json`part_count、runtime_eligible_count、built_count、geometry_verified_count、failure_category_counts
- `summary-by-atomic.json`atomic 频次
- `summary-by-blocker.json`blocker code 频次
- `parts/<id>.report.json`:每个 part 完整报告(selector_resolution、numeric_comparison
修改前/后各跑一次,对比以上聚合 JSON。
### 2.4 几何层
`GJH/scriptTest.py` 已能批量走 CDSL-only 路径。参照 `GJH/exp_compare_084242.py` 思路对单零件做体积/bbox/face 数/凸包方向对比。
### 2.5 phase 分桶
```bash
python -m cdsl_engine.batch_rebuild json_to_cdsl/output /tmp/p3 --phase p3 --build
python -m cdsl_engine.batch_rebuild json_to_cdsl/output /tmp/p4 --phase p4 --build
python -m cdsl_engine.batch_rebuild json_to_cdsl/output /tmp/p6 --phase p6 --build
```
- P3:基准(拉伸/旋转/参考)
- P4+ hole_wizard(→ 问题 9
- P6+ pattern(→ 问题 6
## 3. 修改前后差异指标
| # | blocker/信号 | A 当前 | B 修复后 | 度量 |
|---|---|---|---|---|
| 1 | x_dir·y_dir 与 ‖x‖·‖y‖cosθ 偏差 | 12,033/14,908 偏差 > 1e-4 | ≤ 1e-6 或显式 warnings | 数据扫描 |
| 2 | draft 静默成功 | geometry_verified 偏低 | runtime_eligible_count ↑ 或 schema 拒绝 | 批量层 |
| 3 | revolve centroid 侧 | centroid 在错半侧 | 正确侧 | 单元 |
| 4 | schema 拒绝 host_face.frame | schema violation | 通过 | 单元 + 数据 |
| 5 | non_uniform_extent_target 频次 | 高 | 显著下降 | summary-by-blocker |
| 6 | unsupported_pattern_selector_transform 频次 | 高(截图口径) | 0 | summary-by-blocker |
| 7 | body_id 数 | =1 | ≥2 | 单元 |
| 8 | fillet 后 selector 解析成功率 | not_found | resolved(演化后继唯一时;被完整消费的边保持保守 not_found) | 单元 |
| 9 | unsupported_hole_subtype 频次 | ≈712 | 0 | summary-by-blocker |
| 10 | unsupported_profile 频次 / profile.type=circles/annulus | ≈718 | 0importer 修复后) | 数据 + summary-by-blocker |
## 4. 最小基线流程
```bash
# 数据扫描
python backend/tests/test_engine_diagnostics_corpus.py
# 批量基线
PYTHONPATH=backend/engine python -m cdsl_engine.batch_rebuild \
json_to_cdsl/output /tmp/batch-before --build --build-timeout 15
# phase 分桶
PYTHONPATH=backend/engine python -m cdsl_engine.batch_rebuild \
json_to_cdsl/output /tmp/batch-p3-before --phase p3 --build
PYTHONPATH=backend/engine python -m cdsl_engine.batch_rebuild \
json_to_cdsl/output /tmp/batch-p4-before --phase p4 --build
PYTHONPATH=backend/engine python -m cdsl_engine.batch_rebuild \
json_to_cdsl/output /tmp/batch-p6-before --phase p6 --build
# 单测基线
python -m unittest backend.tests.test_engine_diagnostics_baseline -v
```
任意修复后重跑,diff 出 geometry_verified_count 单调不减、各 blocker 频次单调下降。
## 5. 修复 PR 强约束
`engine/README.md:22-30` 声明:三处必须同步修改——
1. `sketch_solver.py` / `runtime.py` / `build123d_adapter.py` 实现
2. `profile_schema.json`(人读契约)
3. `cdsl_schema.json`(机器契约)
`backend/tests/test_profile_schema.py` 是这三处同步的护栏。
## 6. 与当前修改的关系
`git diff` 显示 `json_to_cdsl/evidence_v2_to_cdsl.py``type=circles` 重写为 `type=analytic_contours`,并已应用于 070825 / 084242 两份 cdsl.json。这是 **问题 10 的入口侧修复**importer 直接产出合规 profile)。
修复后数据扫描预期:
- `json_to_cdsl/output/*.cdsl.json``profile.type=="circles"` 计数:数百 → 0(已在新文档体现)
- `summary-by-blocker.unsupported_profile`:在新文档上 = 0
- 旧文档仍可能含 legacy profile,测试需保留 `json_to_cdsl/output.before/` 快照做对比
+20 -2
View File
@@ -412,9 +412,27 @@ def _analytic_profile(sketch: dict[str, Any]) -> dict[str, Any]:
left, right = drawable
if math.dist(left["center"], right["center"]) <= EPSILON_MM:
smaller, larger = sorted(drawable, key=lambda item: item["radius_mm"])
return {"type": "annulus", "center": larger["center"], "inner_radius_mm": smaller["radius_mm"], "outer_radius_mm": larger["radius_mm"]}
# Annulus is emitted as analytic_contours (outer + inner) so the
# generic runtime profile contract accepts the record; ring_revolve
# can then revolve the two concentric circles into a hollow solid.
return {
"type": "analytic_contours",
"contours": [
{"role": "outer", "closed": True, "segments": [larger]},
{"role": "inner", "closed": True, "segments": [smaller]},
],
}
if drawable and all(item["type"] == "circle" for item in drawable):
return {"type": "circles", "items": [{"center": item["center"], "radius_mm": item["radius_mm"]} for item in drawable]}
# Multiple independent circles share the "extrude every closed loop" intent;
# emit them as analytic_contours (one outer contour per circle) so the
# generic runtime profile contract accepts the record.
return {
"type": "analytic_contours",
"contours": [
{"role": "outer", "closed": True, "segments": [item]}
for item in drawable
],
}
contours = _chain_segments(drawable)
areas = [_contour_area(contour) for contour in contours]