Files
cdsl-cad/backend/tests/test_engine_extent_trim_contract.py

243 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""#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, Solid, 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_planar_supporting_surface_accepts_only_a_complete_forward_profile(self) -> None:
"""A wholly unreachable planar face may supply its support plane.
The finite target is deliberately outside the profile's x/y range,
so no ray intersects its trim. The underlying z=5 plane still gives
one exact +z termination distance. This is distinct from the
partial-hit trim contract below.
"""
profile = Face.make_rect(2, 2, Plane(origin=(8, 0, 0)))
target = Face.make_rect(2, 2, Plane(origin=(0, 0, 5)))
self.assertTrue(all(
Build123dGeometryAdapter._forward_intersection_distance(target, point, Vector(0, 0, 1)) is None
for point in Build123dGeometryAdapter.profile_sample_points(profile)
))
self.assertAlmostEqual(
Build123dGeometryAdapter.uniform_planar_supporting_surface_distance(target, [profile], (0, 0, 1)),
5.0,
places=6,
)
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
def test_planar_supporting_surface_rejects_parallel_and_non_planar_targets(self) -> None:
profile = Face.make_rect(2, 2)
parallel = Face.make_rect(2, 2, Plane(origin=(0, 0, 5), z_dir=(1, 0, 0)))
cylindrical = next(face for face in Solid.make_cylinder(2, 4).faces() if face.geom_type != Face.make_rect(1, 1).geom_type)
with self.assertRaisesRegex(ValueError, "parallel"):
Build123dGeometryAdapter.uniform_planar_supporting_surface_distance(parallel, [profile], (0, 0, 1))
with self.assertRaisesRegex(ValueError, "planar"):
Build123dGeometryAdapter.uniform_planar_supporting_surface_distance(cylindrical, [profile], (0, 0, 1))
@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()