038d38ed98
- 新增 loft、双向切除、through-all/up-to-next 等 CADFS lowering 与 engine 支持 - 支持多种 reference plane、B-spline profile 和 circular pattern replay - 保留 transform 历史,并烘焙安全的单源平移/旋转变换 - 改进 selector 绑定、拓扑快照和 pattern 变换处理 - 建立 17 个代表样本的转换、重建与比较回归工具链 - 补充 schema、author guidance、运行时和几何回归测试
371 lines
19 KiB
Python
371 lines
19 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", "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)
|
||
|
||
def test_circular_pattern_rotates_analytic_arc_normals_with_the_sketch(self) -> None:
|
||
"""倾斜圆草图的环形阵列必须保持每条圆弧处于旋转后的工作平面。
|
||
|
||
``analytic_contours`` 在运行时会预先展开为世界坐标的圆弧边。环形阵列
|
||
不能只旋转边端点和圆心:Build123d 依据 edge.normal 计算三点圆弧中点,
|
||
法向遗留在源草图平面会让中点偏离 wire 平面,最终报
|
||
``Cannot build face(s): wires not planar``。该 fixture 使用绕 Z 轴的
|
||
垂直圆草图,保证每个实例都实际变换该法向。
|
||
"""
|
||
cdsl = {
|
||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||
"part_id": "circular-arc-normal", "meta": {"unit": "mm"},
|
||
"geometry": {"sketches": [
|
||
{
|
||
"id": "base", "workplane": _workplane(origin=[0, 0, 0], normal=[0, 0, 1]),
|
||
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
|
||
},
|
||
{
|
||
"id": "boss", "workplane": _workplane(origin=[0, -10, 0], normal=[0, 1, 0]),
|
||
"profile": {"type": "analytic_contours", "contours": [{
|
||
"role": "outer", "closed": True,
|
||
"segments": [{"type": "circle", "center": [0, -5], "radius_mm": 1}],
|
||
}]},
|
||
},
|
||
]},
|
||
"features": [
|
||
{
|
||
"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||
"sketch_id": "base", "params": {"distance_mm": 10},
|
||
},
|
||
{
|
||
"id": "boss_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
|
||
"sketch_id": "boss", "params": {"distance_mm": 2},
|
||
},
|
||
{
|
||
"id": "repeat", "atomic_id": "pattern_circular", "depends_on": ["boss_add"],
|
||
"params": {
|
||
"source_feature_ids": ["boss_add"],
|
||
"axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]},
|
||
"pattern_count": 4, "sweep_angle_deg": 360,
|
||
},
|
||
},
|
||
],
|
||
}
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
rebuilt = rebuild_cdsl(cdsl, Path(directory) / "circular-arc-normal.step")
|
||
self.assertGreater(rebuilt["volume_mm3"], math.pi * 1000)
|
||
self.assertEqual(rebuilt["solid_count"], 1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|