feat(engine): 支持 up_to_surface 非均匀 profile 裁剪拉伸

为 up_to_surface 终止条件新增裁剪语义,通过穿透拉伸与目标面布尔求交保留可达材料,修复非均匀相交时错误拒绝特征的问题。
This commit is contained in:
2026-08-27 15:40:58 +08:00
parent c84fc3ade4
commit 625b296e06
3 changed files with 310 additions and 19 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
+61 -17
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
@@ -62,6 +77,7 @@ class GeometryAdapter(Protocol):
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: ...
@@ -163,7 +179,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 +213,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 +236,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 +247,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 +259,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 +290,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 +318,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,9 +373,17 @@ 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)
@@ -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()