"""#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 到斜面的距离从 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_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 平面、法向 -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()