"""#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-source(plane 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", "solidworks_code": 0}, "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_is_replayed_by_linear_pattern(self) -> None: """mirror-as-source 放行 + 平移几何正确(体积 3000 精确)。 早期(#6 修复前)mirror 的镜像面是 reference_plane 引用,无实例变换 通道,linear 阵列重放 mirror 会退化成"所有实例重合",因此曾阻塞 mirror-as-source。_translated_node 已把镜像面 frame 随实例平移内联、 源递归平移后重放(镜像副本 = reflect(源@t, 面@t))→ 该护栏已过时。 本测试把新语义固定下来:分析放行(无 unsupported_pattern_selector_transform)+ 几何正确。 baseline 10×10×10(x/y ∈ [-5,5]、z 0..10)。mirror 跨平面 y=5 镜像 base → box x ∈ [-5,5]、y ∈ [5,15](vol 1000,与 base 共面 fuse 为 2000)。linear 沿 +x 间距 12 重放 mirror 源 → 镜像面平移到 (12,5) 且 源 base 平移至 x ∈ [7,17],反射得 box x ∈ [7,17]、y ∈ [5,15] (vol 1000)。期望总体积 = 1000 + 1000 + 1000 = 3000。 """ base = _base_block() base["features"].extend([ { "id": "plane_ctx", "atomic_id": "reference_plane", "depends_on": [], "params": {"plane": _workplane(origin=[0, 5, 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": 12, "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]) with tempfile.TemporaryDirectory() as directory: rebuilt = rebuild_cdsl(base, Path(directory) / "mirror-in-linear.step") self.assertAlmostEqual(rebuilt["volume_mm3"], 3000.0, places=6) 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", "solidworks_code": 0}, "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]²)。源 cap:z=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()