fix(engine): 放行固定宿主面与终止面的 pattern 变换

允许 hole_wizard 的 face selector 宿主面及 up_to_surface 的 face reference 终止面参与 pattern 变换,同时保留 mirror 等逐实例拓扑依赖的阻塞,并补充回归测试
This commit is contained in:
2026-08-27 16:04:34 +08:00
parent 625b296e06
commit e5c71d07fa
2 changed files with 345 additions and 5 deletions
@@ -0,0 +1,310 @@
"""#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"},
"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_stays_blocked(self) -> None:
"""回归护栏:mirror-as-source(plane selector)仍阻塞。
pattern_mirror 的镜像面是 reference_plane 引用;线性阵列重放镜像
特征需要把镜像面逐实例平移,当前执行器没有实例变换通道,放行会
产出"所有实例重合"的错误几何。因此 mirror 特征作为 pattern source
必须继续保持 unsupported_pattern_selector_transform。
"""
base = _base_block()
base["features"].extend([
{
"id": "plane_ctx", "atomic_id": "reference_plane", "depends_on": [],
"params": {"plane": _workplane(origin=[0, 0, 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": 10, "pattern_count_1": 2},
},
])
analysis = analyze_cdsl(base)
pattern = next(item for item in analysis.feature_results if item.feature_id == "repeat")
self.assertFalse(pattern.executable)
self.assertIn("unsupported_pattern_selector_transform", [blocker.code for blocker in pattern.blockers])
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"},
"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()