e5c71d07fa
允许 hole_wizard 的 face selector 宿主面及 up_to_surface 的 face reference 终止面参与 pattern 变换,同时保留 mirror 等逐实例拓扑依赖的阻塞,并补充回归测试
311 lines
15 KiB
Python
311 lines
15 KiB
Python
"""#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()
|