Files
cdsl-cad/backend/tests/test_engine_pattern_transform_contract.py
T
2026-09-01 14:10:23 +08:00

311 lines
15 KiB
Python
Raw 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.
"""#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-sourceplane 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_stays_blocked(self) -> None:
"""回归护栏:mirror-as-sourceplane 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", "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]²)。源 capz=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()