diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index edcee86c..9c0ff2de 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -65,28 +65,58 @@ def _has_explicit_host_frame(params: dict[str, Any]) -> bool: return isinstance(frame, dict) and all(frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal")) +def _is_host_face_source(source: FeaturePlanNode) -> bool: + """Whether a ``face`` selector on the source is a fixed host face. + + A patterned hole keeps its host face: the instance positions travel with + the pattern (``_translated_node`` shifts ``positions``) while the face + itself is resolved unchanged, so the face selector can stay untouched + (issue #6). A face selector on anything else (for example a fillet + selecting a face) would need per-instance edge geometry and must stay + blocked. + """ + return source.atomic_id in _HOLE_ATOMICS or ( + isinstance(source.params.get("host_face"), dict) + and source.params.get("host_face", {}).get("kind") == "face" + ) + + def pattern_transform_blocker(source: FeaturePlanNode) -> str | None: """Return the selector dependency that cannot be transformed exactly. Selectors fully captured by explicit coordinate data (a revolve axis with origin/direction, or a host face with a complete world frame) are transformed together with each patterned instance and are not blockers. - Any remaining topology selector and selector-dependent extent stays - blocked until its geometry transform contract is implemented. + A patterned hole's host face and an up_to_surface extrusion's target face + are *fixed* body faces rather than instance geometry: positions and + profiles travel with the instance while the face itself is resolved + unchanged (issue #6). Selectors that must follow the instance but cannot + be translated (edges, vertices, …) stay blocked until their geometry + transform contract is implemented. """ for selector in source.selectors or (): if selector.get("kind") == "axis" and _has_explicit_axis(source.params.get("axis")): # The axis is coordinate data; _translated_node shifts its origin. continue - if selector.get("kind") == "face" and _has_explicit_host_frame(source.params): - # The host face carries a complete world frame; positions stay local. + if selector.get("kind") == "face" and _is_host_face_source(source): + # 孔宿主面:主体上的固定面,不随实例平移;实例位置由 positions + # 平移决定(_translated_node),selector 原样保留即可正确 resolve。 continue return "feature selector" host = source.params.get("host_face") if host is not None and not _has_explicit_host_frame(source.params): - return "host face selector" + # 无 frame 的孔:宿主面以 face selector 形式给出(host_face 自身或 + # selectors 列表)→ 上面的 face 分支已放行;其它形态(无 frame 也 + # 非 face selector)仍阻塞。 + if not (isinstance(host, dict) and host.get("kind") == "face"): + return "host face selector" end_condition = source.params.get("end_condition") or {} if isinstance(end_condition, dict) and isinstance(end_condition.get("reference"), dict): + # up_to_surface / offset_from_surface 的终止面是主体上的固定面: + # 不随实例平移,reference 原样保留即可正确 resolve(#5 裁剪已支持 + # 非均匀相交)。顶点/主体目标无法构造"固定终止面",仍显式阻塞。 + if end_condition["reference"].get("kind") == "face": + return None return "extent target selector" return None diff --git a/backend/tests/test_engine_pattern_transform_contract.py b/backend/tests/test_engine_pattern_transform_contract.py new file mode 100644 index 00000000..ac721caa --- /dev/null +++ b/backend/tests/test_engine_pattern_transform_contract.py @@ -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()