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
@@ -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 到斜面的距离从
4(x=-5)到 6(x=+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 平面、法向 -z,profile 为 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×6(x/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()