From d4a9442e30db9aded1e2e773929c85b0863fc76d Mon Sep 17 00:00:00 2001 From: LittleGuo Date: Fri, 16 Oct 2026 19:46:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(review):=20=E5=87=A0=E4=BD=95=E5=B9=B2?= =?UTF-8?q?=E6=B6=89=E6=A3=80=E6=9F=A5=20find=5Fcollisions=EF=BC=88BVH=20?= =?UTF-8?q?=E7=B2=97=E7=AD=9B=20+=20=E5=AE=B9=E5=99=A8=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C++(cad_mcp_plugins): - 新增 bvh.h:header-only 2D BVH(质心中位分割,叶<=4,SelfPairs 交叉对枚举) - 新增 find_collisions.cpp:粗筛 AABB 相交实体对,附重叠盒 + 逐轴重叠比 ratio (= 逐轴重叠长/较短长 之积,退化轴取 1.0),按 ratio 降序 + 分页; narrow=true 只对返回页跑 intersectWith 细筛(附 exact) - 容器过滤(关键):图框块/分区边界会与一切相交,用重叠【度】判定 deg >= max(exclude_container_deg=8, exclude_container_frac=0.25 * 其他实体数), 与图幅/缩放无关;被剔除者在 excluded_containers 里如实报告 (初版用面积判据对多图框并排失效:3 框只占并集 27/13/7% 全部漏过, 全模型 163 对噪声;改度判据后图框 deg=35/25/23 vs 其余 deg<=3,分离干净) - cmakelists.txt + 两个 mcp_config.json 注册(28 工具) agent 侧(Python): - cad_tools.READ_ONLY_TOOLS += find_collisions(受控子循环可调用) - checks.check_collisions + run_deterministic_checks(受 CHECK_COLLISIONS 开关,默认关) - config += CHECK_COLLISIONS=False / COLLISION_MIN_OVERLAP=0.5 - checklist.py / review_checklist.json += 「干涉/碰撞」类别(high)+ 提示词协议 (先 find_collisions 拿候选 → inspect_region 目视确认;AABB 相交 != 真干涉) 验证: - verify_collisions.py 17/17 PASS:全模型候选 163→80(全部 ratio=0.000), min_overlap=0.5 → 0 对;逐图框 0 对;narrow top-5 中 4 条 exact=true; --inject 注入深重叠矩形报出 ratio=0.902 并清理 2/2 - pytest 355 passed(新增 12 项) - 端到端:LLM 主动调用 find_collisions{min_overlap:0.2,narrow:true,window:<图框>},未误报 --- CODEBUDDY.md | 3 +- ai.memory/feedback_preferences.md | 15 +- ai.memory/project_agent_roadmap.md | 59 ++++- ai.memory/reference_environment.md | 1 + dwg_review/cad_tools.py | 1 + dwg_review/checklist.py | 10 +- dwg_review/checks.py | 48 +++- dwg_review/config.py | 7 + review_checklist.json | 10 + src/cad_mcp_frame/cad-mcp-frame.json | 54 ++++ src/cad_mcp_plugins/bvh.h | 229 ++++++++++++++++ src/cad_mcp_plugins/cmakelists.txt | 1 + src/cad_mcp_plugins/find_collisions.cpp | 333 ++++++++++++++++++++++++ tests/fakes.py | 1 + tests/test_cad_tools.py | 9 +- tests/test_checklist.py | 13 + tests/test_checks.py | 94 +++++++ tests/test_config.py | 9 + tests/test_review.py | 1 + verify_collisions.py | 267 +++++++++++++++++++ 20 files changed, 1152 insertions(+), 13 deletions(-) create mode 100644 src/cad_mcp_plugins/bvh.h create mode 100644 src/cad_mcp_plugins/find_collisions.cpp create mode 100644 verify_collisions.py diff --git a/CODEBUDDY.md b/CODEBUDDY.md index 583a6b0..ff4376f 100644 --- a/CODEBUDDY.md +++ b/CODEBUDDY.md @@ -52,6 +52,7 @@ python verify_stdio_bridge.py # stdio ↔ TCP 桥(协议协商 / 转发 python verify_annotate.py # 批注回写链路 python verify_drawing_area.py # 绘图区最大化:基线 → max → restore 全回位(实测 3.68x) python verify_review_maximized.py # 审查全链路:取证+审查两阶段均在最大化态、结束精确还原;记录 LLM 取证工具调用 +python verify_collisions.py # find_collisions:全模型/逐图框粗筛、容器过滤、阈值/分页/细筛;--inject 注入干涉正例后清理 ``` 这些脚本通过 TCP 连接 `127.0.0.1:8080` 收发 JSON-RPC。 @@ -92,7 +93,7 @@ python verify_review_maximized.py # 审查全链路:取证+审查两阶段 ### 配置与产物布局 -- 运行时配置:`mcp_config.json`,必须与 `.arx` 同目录。示例见 `bin/Debug-R230/mcp_config.json`(当前注册 27 个工具,含 `zoom_window_normalized`、`zoom_extents`、`get_viewport_screenshot`、`set_drawing_area` 等);`src/cad_mcp_frame/cad-mcp-frame.json` 是模板(文件名带连字符,非运行时文件)。 +- 运行时配置:`mcp_config.json`,必须与 `.arx` 同目录。示例见 `bin/Debug-R230/mcp_config.json`(当前注册 28 个工具,含 `zoom_window_normalized`、`zoom_extents`、`get_viewport_screenshot`、`set_drawing_area`、`find_collisions` 等);`src/cad_mcp_frame/cad-mcp-frame.json` 是模板(文件名带连字符,非运行时文件)。 - `global_settings.plugin_directory` 指向 C++ DLL 目录,`lisp_directory` 指向 LISP 目录(默认都相对模块目录)。示例 LISP 见 `bin/Debug-R230/mcp_plugins/mcp_table_array.lsp`。 - `cmake/functions.cmake` 提供工程级封装:`target_link_objectarx`(自动按 ZWCAD 与否链接 `ThirdParty::ZRX`/`ThirdParty::ARX`、注入 `ARX`/`SUB_ARX` 宏)、`target_set_name_and_suffix`(`.arx` 在 ZWCAD 下自动变 `.zrx`)、`add_def_file_name`、`set_debug_post_build` 等。新增目标应复用这些函数而非手写链接。 - `cmake/ThirdPartyDeps.cmake` 注册所有 `ThirdParty::*` INTERFACE 目标(ARX/ZRX/cURL/SQLite3/BitAnswer/modernGlue/CadOcr)。 diff --git a/ai.memory/feedback_preferences.md b/ai.memory/feedback_preferences.md index 5cc018b..0b92b7a 100644 --- a/ai.memory/feedback_preferences.md +++ b/ai.memory/feedback_preferences.md @@ -29,11 +29,16 @@ type: feedback - **Why:** 用户(2026-10-14)明确要求:「以后遇到 cad 没有运行的情况,就直接打开, 运行你的测试就行,我已经设置好了自动加载」。自动加载确实已配好。 - **How to apply:** PowerShell 里 - `Start-Process "C:\Program Files\Autodesk\AutoCAD 2020\acad.exe"`,再轮询 8080 - (实测约 10 秒内监听)。自动加载链路:`bin/Debug-R230/acad.rx`(内容一行 - `cad_mcp_frame.arx`)+ 该目录已在 CAD 的搜索/信任路径中。 - **注意**:CAD 默认打开的是**空模板**(0 图元、无图框),端到端审查会因 - 「未识别到任何图框」提前退出;要跑完整审查需另开一张真实图纸。 + `Start-Process "C:\Program Files\Autodesk\AutoCAD 2020\acad.exe" -ArgumentList '/p','SHS-ENVI2026','e:\desktop\agent_test.dwg'`, + 再轮询 8080(实测**数秒内**即监听,AutoCAD 2020 热启动很快)。自动加载链路: + `bin/Debug-R230/acad.rx`(内容一行 `cad_mcp_frame.arx`)+ 该目录已在 CAD 的搜索/信任路径中。 + **`/p SHS-ENVI2026` 不可省**——否则 Plant 3D 自定义实体(`AECDBEW*`)**不显示**, + 截图里是空的;`e:\desktop\agent_test.dwg` 是标准真实测试图纸(3 图框 / 86 实体)。 + **注意**:不带图纸参数时 CAD 打开的是**空模板**(0 图元、无图框),端到端审查会因 + 「未识别到任何图框」提前退出。 + **重建插件后必须先 `Stop-Process acad -Force`**(DLL 被占用会 LNK1168),再重启。 + 另:AutoCAD 2019(R23.0)也装了同名 profile,但**实际使用的是 2020(R23.1)**—— + MRU 里 `E:\desktop\agent_test.dwg` 属 2020。 ## 调试用 R230 + Debug,不用 Release diff --git a/ai.memory/project_agent_roadmap.md b/ai.memory/project_agent_roadmap.md index 61e5d68..d99cae9 100644 --- a/ai.memory/project_agent_roadmap.md +++ b/ai.memory/project_agent_roadmap.md @@ -1,6 +1,6 @@ --- name: Agent 路线与多源架构(2026-10-15) -description: dwg_review agent 的总目标、多源图纸分层架构(视觉为最大公约数)、工具层与 agent 层的正交分工、多源证据层、截图去 UI、grounding 实测、热力图 + 自适应瓦片规划 + VLM 分辨率标定、绘图区最大化(set_drawing_area)与空瓦片过滤、审查阶段分辨率对齐、agent 侧虚拟工具 inspect_region(放大取证闭环)、后续路线 +description: dwg_review agent 的总目标、多源图纸分层架构(视觉为最大公约数)、工具层与 agent 层的正交分工、多源证据层、截图去 UI、grounding 实测、热力图 + 自适应瓦片规划 + VLM 分辨率标定、绘图区最大化(set_drawing_area)与空瓦片过滤、审查阶段分辨率对齐、agent 侧虚拟工具 inspect_region(放大取证闭环)、几何干涉检查 find_collisions(BVH + 容器过滤)、后续路线 type: project --- @@ -93,6 +93,7 @@ Agent 推理层(审查逻辑 / 清单 / 规则) (只回 handle/type/bbox、放开 limit),**而不是新增"热力图工具"**。 - `CollisionDetection.h/cpp`(用户写的 BVH)**本步不用**,留作后续**几何相交/干涉检查**引擎。 其按质心中位分割 → 节点框**可能重叠**,不适合铺屏瓦片;瓦片切分用四叉树/网格(空间不相交)。 + (**2026-10-16 已落地**:见下文「C2:几何干涉检查 `find_collisions`」——重写了 header-only 2D BVH。) - 标定判据:**同时测「读出文字」与「分辨线细节」**;标定字高 **200 图形单位**(1:100 = 纸面 2mm,国标最小)。 **已落地(提交 `9b8a232` 热力图;`cba05b0` 瓦片规划 + 离线标定)**: @@ -145,7 +146,8 @@ Agent 推理层(审查逻辑 / 清单 / 规则) - **实测收益(屏幕 3440×1440)**:绘图区 **2110×462 → 3310×1084**(面积 **3.68×**), 窗口矩形 `[68,0,2316,960]` → `[-4,-2,3444,1442]`,还原**精确回位**。 - 验收脚本:`verify_drawing_area.py`(基线 → max → restore,断言矩形/最大化态/绘图区尺寸全回位)。 -- 工具注册:`mcp_config.json` 的 `tools[]`(现 **27** 个),工厂 `CreateSetDrawingAreaTool`。 +- 工具注册:`mcp_config.json` 的 `tools[]`(`set_drawing_area` 加入时 **27** 个;后加 `find_collisions` 现为 **28** 个), + 工厂 `CreateSetDrawingAreaTool`。 `set_drawing_area` 以 `set_` 开头 → 被 `cad_tools.is_write_tool` 判为写类(不重试), 且**不在** `READ_ONLY_TOOLS` 里 → LLM 的受控子循环**不能**动用户界面。 @@ -244,3 +246,56 @@ LLM 在受控子循环里 zoom + 截图拿到的是**未最大化的 2110×462** - **验收口径达成**:报告报出标题栏**乱码/占位字符 `???????????????`**, 而 `get_text_content` 对全图返回 **0 条文字**(本图文字全在块/自定义实体内)—— 即**块内文字是被视觉放大读出来的**,不是结构化数据给的。 + +## C2:几何干涉检查 `find_collisions`(2026-10-16) + +**方向选择**:用户拍板「C 两个都做,C2 先(BVH → MCP 工具)」;并说明目录里的 +`CollisionDetection.h/cpp` 是**参考例子**(可用可重写)。分析后:其中纯 ObjectARX 的 +`AABB/EntityProxy/BVHTree`(约 1~367 行)可移植,其余(`needCD` 领域过滤 + MFC 对话框) +依赖用户 App 私有头文件(不在仓库),故**只重写 BVH 内核**。 + +**新增(C++,`src/cad_mcp_plugins/`)**: + +- `bvh.h`:header-only 2D BVH(`namespace dwg_bvh`)。质心中位分割(取较长轴, + `std::nth_element`),叶 ≤ `kLeafSize=4`;`SelfPairs` 枚举相交对(叶内 `i= max(exclude_container_deg=8, exclude_container_frac=0.25 × 其他实体数)` +→ 剔除其全部配对并在 `excluded_containers` 里**如实报告**(含 reason=oversized/container)。 +**与图幅/缩放无关**,窗口态与非窗口态都生效。 + +**实测(真实 Plant 3D 图纸 `agent_test.dwg`,86 实体,`verify_collisions.py` 17/17 PASS)**: +- 全模型候选 **163 → 80**,且 80 对**全部 ratio=0.000**(仅管线接口相接); + `min_overlap=0.5` → **0 对**;逐图框(window)同样 0 对 → 本图**无真实干涉**(真阴性)。 +- 容器过滤剔除 3 个 INSERT(deg 35/25/23,reason=container)。 +- `narrow` 细筛:top-5 里 **4 条 `exact=true`**(接口处 pipe/fitting 真的几何相交)—— + 与 ratio=0.0 一致:**相交但未深度重叠**。 +- **注入式正例**(`--inject`,画 2 个深重叠矩形于图框外 → 应报出 → 删除): + 报出 `ratio=0.902`(= (380/400)²,与理论一致),清理 2/2 成功。 + +**agent 侧接入**(Python): +- `cad_tools.READ_ONLY_TOOLS` 加 `find_collisions` → LLM 受控子循环可调用(写类判定不受影响)。 +- `checks.check_collisions` + `run_deterministic_checks`:受 `CHECK_COLLISIONS` 开关控制, + **默认关闭**(AABB 相交噪声大,平面图里管线穿墙/引线跨图元属正常相交), + 改由 LLM 按「干涉/碰撞」清单条目**按需**调用;`COLLISION_MIN_OVERLAP` 默认 0.5。 + 产出的 issue `category="干涉"`(清单新增类别,输出枚举同步加「干涉」)。 +- `checklist.py` / `review_checklist.json`:新增「干涉/碰撞」类别(severity=high), + 提示词写明「先 `find_collisions` 拿候选 → 对可疑 handle 用 `inspect_region` 目视确认, + **AABB 相交 ≠ 真干涉**」。 + +**端到端实测(`python -m dwg_review --frames 1`)**:清单显示 **6 类**(含干涉/碰撞); +LLM 在受控子循环里**主动调用** `find_collisions {'min_overlap': 0.2, 'narrow': True, +'limit': 60, 'window': <图框>}`,并结合 `inspect_region`;最终**未误报干涉**(正确真阴性)。 + +**待办**:C1(PDF / 图片源适配,见「后续路线」第 2 条)仍未做。 diff --git a/ai.memory/reference_environment.md b/ai.memory/reference_environment.md index f881204..e6e8a72 100644 --- a/ai.memory/reference_environment.md +++ b/ai.memory/reference_environment.md @@ -48,6 +48,7 @@ type: reference | `verify_text_resolution.py` | **CAD 实测**标定(临时加字 + 平行线,测完 `delete_entities` 清理);`--keep` 保留 | | `verify_drawing_area.py` | 绘图区最大化验收:基线 → `set_drawing_area(max)` → `restore`,断言窗口矩形/最大化态/绘图区尺寸全回位(实测 3.68×) | | `verify_review_maximized.py` | 审查全链路验收:在 `collect`/`review_frame` 入口各采一次窗口矩形,断言**取证与审查两阶段都在最大化态**、结束精确还原;并统计子循环 zoom/截图次数 + 记录 LLM 实际调用的取证工具(含虚拟工具 `inspect_region`) | +| `verify_collisions.py` | `find_collisions` 验收:工具注册 / 全模型与逐图框粗筛 / **容器过滤**(图框块剔除)/ `min_overlap` 阈值 / 分页 / `narrow` 细筛 / `--inject` 注入干涉正例(画 2 个深重叠矩形 → 应报出 ratio≈0.902 → 清理)。**只读**(`--inject` 段除外,会自动清理) | > 历史:根目录 `test.py` / `test_autoagent.py` / `countOfCircle.py` / `agent_loop.py` / > `dwg_review_agent.py` 五个遗留调试脚本已于 2026-10-14 删除(前三个含硬编码 API Key, diff --git a/dwg_review/cad_tools.py b/dwg_review/cad_tools.py index e4c7f35..537bbf1 100644 --- a/dwg_review/cad_tools.py +++ b/dwg_review/cad_tools.py @@ -17,6 +17,7 @@ READ_ONLY_TOOLS = frozenset( "detect_drawing_frames", "find_duplicate_entities", "find_zero_length", + "find_collisions", "zoom_to_window", "zoom_to_entity", "get_viewport_screenshot", diff --git a/dwg_review/checklist.py b/dwg_review/checklist.py index ce8cb7b..0113ca0 100644 --- a/dwg_review/checklist.py +++ b/dwg_review/checklist.py @@ -15,6 +15,8 @@ DEFAULT_CHECKLIST = { "checks": ["关键尺寸缺失", "标注值异常"]}, {"title": "几何", "severity": "medium", "checks": ["零长度/退化实体", "重复重叠实体", "未闭合轮廓"]}, + {"title": "干涉/碰撞", "severity": "high", + "checks": ["构件/管线间非预期侵入", "同位置重复叠画", "引线/填充异常遮挡"]}, {"title": "图面整体", "severity": "low", "checks": ["图面完整性", "内容是否超出图框"]}, ], @@ -67,7 +69,11 @@ def build_system_prompt(checklist, *, allow_tools=True): "若它提示区域过大,请缩小 `target`(或用 `span` 缩小范围)后重试。", "", "结构化数据不足以判断时:用 `get_entity_detail` / `find_duplicate_entities` /", - "`find_zero_length` / `get_layers` 等查证后再下结论。", + "`find_zero_length` / `find_collisions` / `get_layers` 等查证后再下结论。", + "排查「干涉/碰撞」时,先调 `find_collisions` 拿到**包围盒相交候选**" + "(按重叠比降序,`min_overlap` 可调阈值),", + "再对可疑的候选 handle 用 `inspect_region` 放大目视确认——" + "**AABB 相交不等于真干涉**,务必核实后再下结论。", "", ] @@ -84,7 +90,7 @@ def build_system_prompt(checklist, *, allow_tools=True): ' "summary": "<该图框的一句话整体结论;若未发现问题,写“未发现明显问题”>",', ' "issues": [', " {", - ' "category": "<图层/文字/标注/几何/图面整体 之一>",', + ' "category": "<图层/文字/标注/几何/干涉/图面整体 之一>",', ' "severity": "",', ' "message": "<问题描述,简洁具体>",', ' "handle": "<该问题对应的实体句柄;无法定位到具体实体时省略此字段>"', diff --git a/dwg_review/checks.py b/dwg_review/checks.py index 65f8266..15679f6 100644 --- a/dwg_review/checks.py +++ b/dwg_review/checks.py @@ -1,4 +1,5 @@ -"""确定性检查:MCP 工具(find_zero_length / find_duplicate_entities)+ 纯 Python 派生。 +"""确定性检查:MCP 工具(find_zero_length / find_duplicate_entities / find_collisions) ++ 纯 Python 派生。 产出的 issue 带 `source="deterministic"`,其 handle 来自工具/数据本身, 因此被视为**可信**,可直接进入批注链路,不依赖 LLM 回忆。 @@ -101,6 +102,44 @@ def check_duplicate_entities(ctx, window, *, tolerance, group_limit, log=print): return out +def check_collisions(ctx, window, *, min_overlap, limit, log=print): + """find_collisions(window) → 每条「包围盒干涉候选」转 issue。 + + **候选**语义:AABB 相交不等于真干涉(L 形布局、相邻管段接口、标注引线跨越 + 图元都会相交),故严重度取 medium、category 单列「干涉」,由报告层 / 人复核。 + 重叠比 `ratio` 写进 message,便于按可疑程度排序与人工筛选。 + """ + try: + resp = ctx.tools.call( + "find_collisions", + {"window": window, "min_overlap": min_overlap, "limit": limit}, + ) + except (McpError, McpToolError) as exc: + log(f" [确定性] find_collisions 失败: {exc}") + return [] + + out = [] + for item in _iter_items(result_json(resp, {})): + a = item.get("a") if isinstance(item.get("a"), dict) else {} + b = item.get("b") if isinstance(item.get("b"), dict) else {} + ha, hb = a.get("handle"), b.get("handle") + if not ha or not hb: + continue + ov = item.get("overlap") if isinstance(item.get("overlap"), dict) else {} + ratio = ov.get("ratio") + ratio_txt = f"{ratio:.2f}" if isinstance(ratio, (int, float)) else "?" + out.append( + make_issue( + "干涉", "medium", + f"包围盒干涉候选({a.get('type')}/{ha} × {b.get('type')}/{hb}," + f"重叠比 {ratio_txt},同层={item.get('same_layer')})", + handle=ha, + handles=[ha, hb], + ) + ) + return out + + # --------------------------------------------------------------------------- # 纯 Python 派生(零额外 MCP 往返) # --------------------------------------------------------------------------- @@ -161,4 +200,11 @@ def run_deterministic_checks(ctx, evidence, *, log=print): group_limit=config.DUP_GROUP_LIMIT, log=log, ) + if config.CHECK_COLLISIONS: + issues += check_collisions( + ctx, window, + min_overlap=config.COLLISION_MIN_OVERLAP, + limit=config.FRAME_ENTITY_LIMIT, + log=log, + ) return issues diff --git a/dwg_review/config.py b/dwg_review/config.py index 2f15e6e..2ea1550 100644 --- a/dwg_review/config.py +++ b/dwg_review/config.py @@ -119,6 +119,13 @@ ZERO_LENGTH_TOLERANCE = float(os.environ.get("ZERO_LENGTH_TOLERANCE", "0.001")) DUP_TOLERANCE = float(os.environ.get("DUP_TOLERANCE", "0.001")) # find_duplicate_entities 的 limit(语义是「最大重复组数」,非条目数) DUP_GROUP_LIMIT = int(os.environ.get("DUP_GROUP_LIMIT", "50")) +# 是否在确定性检查里跑 find_collisions(包围盒干涉**候选**粗筛)。 +# 默认关闭:平面图里管线穿墙、标注引线跨图元等属正常相交,AABB 相交 ≠ 真干涉, +# 全量跑会淹没报告;改由审查 LLM 按「干涉/碰撞」清单条目**按需**调用该工具。 +CHECK_COLLISIONS = _read_bool("CHECK_COLLISIONS", False) +# find_collisions 的最小重叠比阈值(逐轴重叠比之积,见 find_collisions.cpp 注释)。 +# 0.5 已能滤掉「仅角部相接 / 细长交叉」的弱候选,只留明显互相侵入的对。 +COLLISION_MIN_OVERLAP = float(os.environ.get("COLLISION_MIN_OVERLAP", "0.5")) # --------------------------------------------------------------------------- # 截图(视觉 token 成本控制) diff --git a/review_checklist.json b/review_checklist.json index 242bad5..3754cb7 100644 --- a/review_checklist.json +++ b/review_checklist.json @@ -44,6 +44,16 @@ "轮廓/多段线是否应当闭合而未闭合" ] }, + { + "id": "collision", + "title": "干涉/碰撞", + "severity": "high", + "checks": [ + "管线、设备、结构构件之间是否存在非预期的实体侵入或重叠(可用 find_collisions 粗筛候选)", + "是否存在同一位置重复放置的图元(叠画),导致施工歧义", + "标注引线、填充、图签等是否与图形主体异常交叠遮挡" + ] + }, { "id": "visual", "title": "图面整体", diff --git a/src/cad_mcp_frame/cad-mcp-frame.json b/src/cad_mcp_frame/cad-mcp-frame.json index 2700fc9..6872930 100644 --- a/src/cad_mcp_frame/cad-mcp-frame.json +++ b/src/cad_mcp_frame/cad-mcp-frame.json @@ -294,6 +294,60 @@ "factory_function": "CreateFindZeroLengthTool" } }, + { + "name": "find_collisions", + "description": "查找包围盒相交(可能干涉)的实体对,返回候选 + 重叠证据(重叠盒、逐轴重叠比 ratio、类型/图层)。ratio 越大越可疑。注意:包围盒相交不等于真干涉(相邻管段在接口处会相接、L 形布局的包围盒会相交),请结合类型/图层判断,必要时用 inspect_region 放大目视复核。", + "inputSchema": { + "type": "object", + "properties": { + "space": { + "type": "string" + }, + "layer_filter": { + "type": "string", + "description": "图层通配符,空 = 不过滤" + }, + "window": { + "type": "object", + "description": "限定范围(bbox 与之相交即纳入)", + "properties": { + "min_x": {"type": "number"}, + "min_y": {"type": "number"}, + "max_x": {"type": "number"}, + "max_y": {"type": "number"} + } + }, + "min_overlap": { + "type": "number", + "description": "最小重叠比,默认 0" + }, + "include_same_layer": { + "type": "boolean", + "description": "是否包含同图层实体对,默认 true" + }, + "exclude_oversized": { + "type": "number", + "description": "剔除面积占参考面积超过该比例的实体,默认 0.5(用于去掉图框块)" + }, + "narrow": { + "type": "boolean", + "description": "是否做几何细筛(intersectWith),默认 false;较慢" + }, + "limit": { + "type": "integer", + "description": "最大返回对数,默认 200" + }, + "offset": { + "type": "integer" + } + } + }, + "backend": { + "type": "c++_dll", + "dll_name": "cad_mcp_plugins.dll", + "factory_function": "CreateFindCollisionsTool" + } + }, { "name": "zoom_to_entity", "description": "缩放到指定 handle(或 handle 列表)实体的范围,四周留 5% 边距。配合截图确认。", diff --git a/src/cad_mcp_plugins/bvh.h b/src/cad_mcp_plugins/bvh.h new file mode 100644 index 0000000..1679fee --- /dev/null +++ b/src/cad_mcp_plugins/bvh.h @@ -0,0 +1,229 @@ +#pragma once +// ============================================================================ +// bvh.h +// 2D AABB 层次包围盒 —— 干涉检查的**粗筛**层。 +// +// 纯 C++(不依赖 ObjectARX),header-only,便于复用与推理。 +// +// 相比「按质心中位分割」的朴素实现,这里的取舍: +// - 按**质心中位数**沿**较长轴**二分 → 深度严格 O(log n),无退化分支; +// - 内部节点记录**子树图元数**,交叉遍历时优先拆较大的一侧,减少无效下降; +// - 自相交遍历**每对只报告一次**(叶内 i +#include +#include + +namespace dwg_bvh +{ + +// 叶节点最多容纳的图元数:越小查询越快、建树越慢 +constexpr int kLeafSize = 4; + +struct Box +{ + double x1 = 0.0, y1 = 0.0, x2 = 0.0, y2 = 0.0; + + double width() const { return x2 - x1; } + double height() const { return y2 - y1; } + double area() const { return width() * height(); } + bool valid() const { return x2 >= x1 && y2 >= y1; } + + // 反向极值盒:任何 grow 都能把它撑开(用于求并集) + static Box empty() + { + Box b; + b.x1 = b.y1 = 1e300; + b.x2 = b.y2 = -1e300; + return b; + } + + void grow(const Box& o) + { + if (o.x1 < x1) x1 = o.x1; + if (o.y1 < y1) y1 = o.y1; + if (o.x2 > x2) x2 = o.x2; + if (o.y2 > y2) y2 = o.y2; + } + + // 接触即算相交 + bool intersects(const Box& o) const + { + return !(x2 < o.x1 || o.x2 < x1 || y2 < o.y1 || o.y2 < y1); + } + + // 交集盒(可能退化,调用方自行判断) + Box overlap(const Box& o) const + { + Box r; + r.x1 = (x1 > o.x1) ? x1 : o.x1; + r.y1 = (y1 > o.y1) ? y1 : o.y1; + r.x2 = (x2 < o.x2) ? x2 : o.x2; + r.y2 = (y2 < o.y2) ? y2 : o.y2; + return r; + } +}; + +struct Primitive +{ + Box box; + int payload = -1; // 调用方的索引(如实体在自身数组里的下标) +}; + +struct Node +{ + Box box; + int first = 0; // 叶:图元区间起点;内部:左子节点下标 + int count = 0; // 叶:图元个数;内部:子树图元总数 + bool leaf = true; +}; + +class Tree +{ +public: + // 建树(就地重排 prims;payload 随图元一起搬,调用方索引不受影响) + void Build(std::vector prims) + { + m_prims.swap(prims); + m_nodes.clear(); + if (m_prims.empty()) return; + m_nodes.reserve(2 * m_prims.size()); + m_nodes.emplace_back(); + buildNode(0, 0, static_cast(m_prims.size())); + } + + bool empty() const { return m_nodes.empty(); } + int size() const { return static_cast(m_prims.size()); } + + // 两两自检:所有包围盒相交的 (payloadA, payloadB),每对仅一次 + void SelfPairs(std::vector>& out) const + { + if (!m_nodes.empty()) selfPairs(0, out); + } + + // 查询与 box 相交的 payload + void Query(const Box& q, std::vector& out) const + { + out.clear(); + if (m_nodes.empty()) return; + std::vector stack; + stack.push_back(0); + while (!stack.empty()) + { + const int idx = stack.back(); + stack.pop_back(); + const Node& n = m_nodes[idx]; + if (!n.box.intersects(q)) continue; + if (n.leaf) + { + const int end = n.first + n.count; + for (int i = n.first; i < end; ++i) + if (m_prims[i].box.intersects(q)) out.push_back(m_prims[i].payload); + } + else + { + stack.push_back(n.first); + stack.push_back(n.first + 1); + } + } + } + +private: + void buildNode(int nodeIdx, int first, int count) + { + Box hull = Box::empty(); + Box cent = Box::empty(); + for (int i = first; i < first + count; ++i) + { + const Box& b = m_prims[i].box; + hull.grow(b); + Box c; + c.x1 = c.x2 = (b.x1 + b.x2) * 0.5; + c.y1 = c.y2 = (b.y1 + b.y2) * 0.5; + cent.grow(c); + } + + m_nodes[nodeIdx].box = hull; + m_nodes[nodeIdx].first = first; + m_nodes[nodeIdx].count = count; + m_nodes[nodeIdx].leaf = true; + if (count <= kLeafSize) return; + + const bool byX = cent.width() >= cent.height(); + const int mid = count / 2; + auto begin = m_prims.begin() + first; + std::nth_element(begin, begin + mid, begin + count, + [byX](const Primitive& a, const Primitive& b) + { + const double ca = byX ? (a.box.x1 + a.box.x2) : (a.box.y1 + a.box.y2); + const double cb = byX ? (b.box.x1 + b.box.x2) : (b.box.y1 + b.box.y2); + return ca < cb; + }); + + // 注意:emplace_back 可能重分配 m_nodes,故不持有 Node& 引用 + const int leftIdx = static_cast(m_nodes.size()); + m_nodes.emplace_back(); + m_nodes.emplace_back(); + m_nodes[nodeIdx].first = leftIdx; + m_nodes[nodeIdx].leaf = false; + + buildNode(leftIdx, first, mid); + buildNode(leftIdx + 1, first + mid, count - mid); + } + + void selfPairs(int idx, std::vector>& out) const + { + const Node& n = m_nodes[idx]; + if (n.leaf) + { + const int end = n.first + n.count; + for (int i = n.first; i < end; ++i) + for (int j = i + 1; j < end; ++j) + if (m_prims[i].box.intersects(m_prims[j].box)) + out.emplace_back(m_prims[i].payload, m_prims[j].payload); + return; + } + selfPairs(n.first, out); + selfPairs(n.first + 1, out); + crossPairs(n.first, n.first + 1, out); + } + + void crossPairs(int a, int b, std::vector>& out) const + { + const Node& na = m_nodes[a]; + const Node& nb = m_nodes[b]; + if (!na.box.intersects(nb.box)) return; + + if (na.leaf && nb.leaf) + { + const int ea = na.first + na.count; + const int eb = nb.first + nb.count; + for (int i = na.first; i < ea; ++i) + for (int j = nb.first; j < eb; ++j) + if (m_prims[i].box.intersects(m_prims[j].box)) + out.emplace_back(m_prims[i].payload, m_prims[j].payload); + return; + } + + // 优先拆「较大」的一侧,让递归更快收敛 + const bool splitA = nb.leaf || (!na.leaf && na.count >= nb.count); + if (splitA) + { + crossPairs(na.first, b, out); + crossPairs(na.first + 1, b, out); + } + else + { + crossPairs(a, nb.first, out); + crossPairs(a, nb.first + 1, out); + } + } + + std::vector m_prims; + std::vector m_nodes; +}; + +} // namespace dwg_bvh diff --git a/src/cad_mcp_plugins/cmakelists.txt b/src/cad_mcp_plugins/cmakelists.txt index 5df98cb..8a03898 100644 --- a/src/cad_mcp_plugins/cmakelists.txt +++ b/src/cad_mcp_plugins/cmakelists.txt @@ -15,6 +15,7 @@ set(SOURCES "dwg_review_check.cpp" "dwg_review_view.cpp" "dwg_review_ui.cpp" + "find_collisions.cpp" "dwg_annotate_common.cpp" "dwg_annotate_draw.cpp" "dwg_annotate_edit.cpp" diff --git a/src/cad_mcp_plugins/find_collisions.cpp b/src/cad_mcp_plugins/find_collisions.cpp new file mode 100644 index 0000000..e80aa13 --- /dev/null +++ b/src/cad_mcp_plugins/find_collisions.cpp @@ -0,0 +1,333 @@ +// ============================================================================ +// find_collisions.cpp +// 图纸审查 - 干涉 / 碰撞**候选**检查 +// +// find_collisions : 粗筛出「包围盒相交」的实体对,附重叠证据,供审查 agent 判断 +// +// 设计要点: +// - **粗筛**:BVH(bvh.h)两两自检,只对相交的包围盒做后续处理; +// - **容器过滤**:图框块 / 分区边界会与「一切」相交,制造海量假候选。 +// 用重叠**度**(与多少其他实体相交)识别:`deg >= max(deg_min, frac * 其他实体数)` +// → 判为容器,剔除其全部配对。**与图幅/缩放无关**,实测图框 deg=35/25/23, +// 其余实体全部 deg<=3,分离极干净。(另保留基于面积的 `exclude_oversized` 作快速预筛。) +// - **证据**:重叠盒 + 逐轴重叠比(`ratio`,见下)+ 类型 / 图层 / 是否同层同类型; +// - **排序**:按 `ratio` 降序 + 分页 → 最可疑的排在最前; +// - **只报候选、不下结论**:AABB 相交 ≠ 真干涉(L 形布局的两个实体包围盒会相交, +// 相邻管段在接口处也会相接)。真伪由审查 agent 依据证据判断,必要时用 +// `inspect_region` 放大目视复核。 +// +// `ratio` 定义:逐轴重叠比之积。 +// 某轴重叠比 = 该轴重叠长度 / 两实体该轴较短长度;**至少一方在该轴退化为 0** +// (水平线的高、竖直线的宽)时该轴取 1.0(视为完全重叠)。 +// 于是:共线相接的两段 → 0;横管穿过阀门 → ≈1;斜管交叉 → 较大。 +// +// 注:本工具按 **2D(x/y)** 判定,与插件其余工具一致;z 不参与。 +// ============================================================================ + +#include "cadsdk.h" +#include "dwg_review_common.h" +#include "mcp_plugin_api.h" +#include "bvh.h" + +#include +#include +#include +#include +#include + +using namespace dwg_review; + +namespace +{ + +struct EntInfo +{ + AcDbObjectId id; + std::string handle; + std::string type; + std::string layer; + BBox box; +}; + +struct Pair +{ + int a = 0; + int b = 0; + double ratio = 0.0; + BBox ov; + double ow = 0.0; + double oh = 0.0; + bool exact = false; +}; + +// ---------- 宽松参数解析(LLM 常把数字写成字符串、把 bool 写成 0/1) ---------- +double numArg(const nlohmann::json& args, const char* key, double dflt) +{ + auto it = args.find(key); + if (it == args.end() || it->is_null()) return dflt; + if (it->is_number()) return it->get(); + if (it->is_boolean()) return it->get() ? 1.0 : 0.0; + if (it->is_string()) + { + try { return std::stod(it->get()); } + catch (...) { return dflt; } + } + return dflt; +} + +bool boolArg(const nlohmann::json& args, const char* key, bool dflt) +{ + auto it = args.find(key); + if (it == args.end() || it->is_null()) return dflt; + if (it->is_boolean()) return it->get(); + if (it->is_number()) return it->get() != 0.0; + return dflt; +} + +double clampd(double v, double lo, double hi) +{ + return v < lo ? lo : (v > hi ? hi : v); +} + +dwg_bvh::Box toBvhBox(const BBox& b) +{ + dwg_bvh::Box r; + r.x1 = b.minX; r.y1 = b.minY; r.x2 = b.maxX; r.y2 = b.maxY; + return r; +} + +// 单轴重叠比:至少一方在该轴退化为 0 → 视为完全重叠(1.0) +double axisFrac(double a1, double a2, double b1, double b2, double o1, double o2) +{ + const double shorter = std::min(a2 - a1, b2 - b1); + if (shorter <= 0.0) return 1.0; + return (o2 - o1) / shorter; +} + +// 可选细筛:几何是否真的相交。AEC 自定义实体未必实现 intersectWith, +// 失败 / 未实现时返回 false(调用方据此把 exact 置 false,但仍保留候选)。 +bool geometryIntersects(const AcDbObjectId& idA, const AcDbObjectId& idB) +{ + AcDbEntity* pa = nullptr; + if (acdbOpenObject(pa, idA, AcDb::kForRead) != Acad::eOk || !pa) return false; + + AcDbEntity* pb = nullptr; + if (acdbOpenObject(pb, idB, AcDb::kForRead) != Acad::eOk || !pb) + { + pa->close(); + return false; + } + + bool hit = false; + try + { + AcGePoint3dArray pts; + hit = (pa->intersectWith(pb, AcDb::kOnBothOperands, pts) == Acad::eOk) + && pts.length() > 0; + } + catch (...) + { + hit = false; + } + pb->close(); + pa->close(); + return hit; +} + +nlohmann::json entJson(const EntInfo& e) +{ + nlohmann::json j; + j["handle"] = e.handle; + j["type"] = e.type; + j["layer"] = e.layer; + j["bbox"] = bboxJson(e.box); + return j; +} + +} // namespace + +class mcp_Tool_FindCollisions : public mcp_Tool +{ +public: + nlohmann::json Execute(const nlohmann::json& args) override + { + CommonQuery q = parseCommonQuery(args); + + const int limit = (q.limit > 0 && q.limit <= 1000) ? q.limit : 200; + const double minOverlap = clampd(numArg(args, "min_overlap", 0.0), 0.0, 1.0); + const bool sameLayer = boolArg(args, "include_same_layer", true); + const double maxAreaFrac = clampd(numArg(args, "exclude_oversized", 0.5), 0.0, 1.0); + const bool narrow = boolArg(args, "narrow", false); + // 容器过滤(图框块 / 分区边界):重叠度阈值 + const int contDegMin = static_cast( + clampd(numArg(args, "exclude_container_deg", 8.0), 1.0, 100000.0)); + const double contDegFrac = clampd(numArg(args, "exclude_container_frac", 0.25), 0.0, 1.0); + + // ---- 1) 收集实体(含包围盒) ---- + std::vector ents; + forEachEntity(q, [&](AcDbEntity* pEnt) -> bool + { + BBox b; + if (!entityBBox(pEnt, b) || !b.valid) return true; + + EntInfo info; + info.id = pEnt->objectId(); + info.handle = handleOf(pEnt); + info.type = entityTypeName(pEnt); + info.layer = layerOf(pEnt); + info.box = b; + ents.push_back(std::move(info)); + return true; + }); + + if (ents.size() < 2) + { + nlohmann::json r = pageResult(0, 0, 0, nlohmann::json::array()); + r["entity_count"] = static_cast(ents.size()); + r["excluded_containers"] = nlohmann::json::array(); + return makeJsonResult(r); + } + + // 被剔除的「容器」(图框块 / 分区边界),供审查 agent 知情 + nlohmann::json containers = nlohmann::json::array(); + + // ---- 2) 面积预筛:剔除「过大」实体(图框块会与一切相交) ---- + // 参考面积:有 window 用 window,否则用全部实体的并集 + BBox ref = ents[0].box; + for (const EntInfo& e : ents) + { + if (e.box.minX < ref.minX) ref.minX = e.box.minX; + if (e.box.minY < ref.minY) ref.minY = e.box.minY; + if (e.box.maxX > ref.maxX) ref.maxX = e.box.maxX; + if (e.box.maxY > ref.maxY) ref.maxY = e.box.maxY; + } + const double refArea = (ref.maxX - ref.minX) * (ref.maxY - ref.minY); + + std::vector prims; + std::vector kept; + prims.reserve(ents.size()); + kept.reserve(ents.size()); + for (const EntInfo& e : ents) + { + if (refArea > 0.0) + { + const double a = (e.box.maxX - e.box.minX) * (e.box.maxY - e.box.minY); + if (a > refArea * maxAreaFrac) + { + nlohmann::json c; + c["handle"] = e.handle; + c["type"] = e.type; + c["layer"] = e.layer; + c["reason"] = "oversized"; + containers.push_back(std::move(c)); + continue; + } + } + prims.push_back({ toBvhBox(e.box), static_cast(kept.size()) }); + kept.push_back(e); + } + + // ---- 3) 粗筛 ---- + dwg_bvh::Tree tree; + tree.Build(prims); + std::vector> hits; + tree.SelfPairs(hits); + + // ---- 3.5) 容器过滤:重叠度过高的实体判为图框块 / 分区边界,剔除其全部配对 ---- + // 判据与图幅、缩放无关,只依赖「与多少其他实体相交」——实测分离极干净 + // (图框 deg=35/25/23,其余实体全部 deg<=3)。 + std::vector deg(kept.size(), 0); + for (const auto& h : hits) + { + ++deg[h.first]; + ++deg[h.second]; + } + const int others = (kept.size() > 0) ? static_cast(kept.size()) - 1 : 0; + const int degLimit = std::max( + contDegMin, static_cast(std::ceil(contDegFrac * others))); + + std::vector isContainer(kept.size(), 0); + for (std::size_t i = 0; i < kept.size(); ++i) + { + if (deg[i] < degLimit) continue; + isContainer[i] = 1; + nlohmann::json c; + c["handle"] = kept[i].handle; + c["type"] = kept[i].type; + c["layer"] = kept[i].layer; + c["degree"] = deg[i]; + c["reason"] = "container"; + containers.push_back(std::move(c)); + } + + // ---- 4) 组装证据 / 过滤 / 排序 ---- + std::vector pairs; + pairs.reserve(hits.size()); + for (const auto& h : hits) + { + if (isContainer[h.first] || isContainer[h.second]) continue; + const EntInfo& ea = kept[h.first]; + const EntInfo& eb = kept[h.second]; + if (!sameLayer && ea.layer == eb.layer) continue; + + const BBox& A = ea.box; + const BBox& B = eb.box; + const double ox1 = std::max(A.minX, B.minX); + const double oy1 = std::max(A.minY, B.minY); + const double ox2 = std::min(A.maxX, B.maxX); + const double oy2 = std::min(A.maxY, B.maxY); + if (ox2 < ox1 || oy2 < oy1) continue; + + Pair p; + p.a = h.first; + p.b = h.second; + p.ov.minX = ox1; p.ov.minY = oy1; p.ov.maxX = ox2; p.ov.maxY = oy2; + p.ov.valid = true; + p.ow = ox2 - ox1; + p.oh = oy2 - oy1; + p.ratio = axisFrac(A.minX, A.maxX, B.minX, B.maxX, ox1, ox2) + * axisFrac(A.minY, A.maxY, B.minY, B.maxY, oy1, oy2); + if (p.ratio < minOverlap) continue; + pairs.push_back(p); + } + + std::sort(pairs.begin(), pairs.end(), + [](const Pair& x, const Pair& y) { return x.ratio > y.ratio; }); + + // ---- 5) 可选细筛(只对进入返回页的候选做,控制开销) ---- + const int total = static_cast(pairs.size()); + const int from = (q.offset > 0) ? std::min(q.offset, total) : 0; + const int to = std::min(total, from + limit); + + nlohmann::json items = nlohmann::json::array(); + for (int i = from; i < to; ++i) + { + Pair& p = pairs[i]; + nlohmann::json it; + it["a"] = entJson(kept[p.a]); + it["b"] = entJson(kept[p.b]); + it["overlap"] = { + { "bbox", bboxJson(p.ov) }, + { "width", p.ow }, + { "height", p.oh }, + { "area", p.ow * p.oh }, + { "ratio", p.ratio } + }; + it["same_layer"] = (kept[p.a].layer == kept[p.b].layer); + it["same_type"] = (kept[p.a].type == kept[p.b].type); + if (narrow) + { + p.exact = geometryIntersects(kept[p.a].id, kept[p.b].id); + it["exact"] = p.exact; + } + items.push_back(std::move(it)); + } + + nlohmann::json r = pageResult(total, to - from, from, items); + r["entity_count"] = static_cast(kept.size()); + r["excluded_containers"] = std::move(containers); + return makeJsonResult(r); + } +}; + +EXPORT_MCP_TOOL(mcp_Tool_FindCollisions, CreateFindCollisionsTool) diff --git a/tests/fakes.py b/tests/fakes.py index 720d77f..f42a080 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -204,6 +204,7 @@ STANDARD_TOOLS = [ tool_schema("get_entity_detail", "实体详情"), tool_schema("find_duplicate_entities", "重复实体"), tool_schema("find_zero_length", "零长度实体"), + tool_schema("find_collisions", "干涉候选"), tool_schema("add_leader_annotation", "添加引线批注"), tool_schema("highlight_entities", "高亮实体"), tool_schema("delete_entities", "删除实体"), diff --git a/tests/test_cad_tools.py b/tests/test_cad_tools.py index 0a23173..e187cd4 100644 --- a/tests/test_cad_tools.py +++ b/tests/test_cad_tools.py @@ -1,6 +1,6 @@ """`dwg_review.cad_tools` 单元测试。""" -from dwg_review.cad_tools import CadTools, is_write_tool +from dwg_review.cad_tools import READ_ONLY_TOOLS, CadTools, is_write_tool from dwg_review.mcp_client import result_json from tests.fakes import FakeMcpClient, ok_text, tool_schema @@ -19,10 +19,15 @@ def test_write_exact_names_detected(): def test_readonly_tools_are_not_write(): - for name in ("query_entities", "get_text_content", "zoom_to_window", "find_zero_length"): + for name in ("query_entities", "get_text_content", "zoom_to_window", + "find_zero_length", "find_collisions"): assert is_write_tool(name) is False +def test_find_collisions_is_whitelisted(): + assert "find_collisions" in READ_ONLY_TOOLS + + # --------------------------------------------------------------------------- # 注册表 # --------------------------------------------------------------------------- diff --git a/tests/test_checklist.py b/tests/test_checklist.py index 73a831b..f41d21f 100644 --- a/tests/test_checklist.py +++ b/tests/test_checklist.py @@ -76,6 +76,19 @@ def test_build_system_prompt_instructs_inspect_region(): assert "不需要判断截图够不够清楚" in prompt +def test_build_system_prompt_mentions_collision_tool(): + prompt = build_system_prompt(DEFAULT_CHECKLIST, allow_tools=True) + # 干涉排查入口:find_collisions 粗筛候选 + 强调 AABB 相交 ≠ 真干涉 + assert "find_collisions" in prompt + assert "包围盒相交" in prompt + assert "不等于真干涉" in prompt + + +def test_default_checklist_has_collision_category(): + titles = [c["title"] for c in DEFAULT_CHECKLIST["categories"]] + assert "干涉/碰撞" in titles + + def test_build_system_prompt_requires_structured_json_output(): # allow_tools=False 以隔离「工具提示块」里的「只读」字样 prompt = build_system_prompt(DEFAULT_CHECKLIST, allow_tools=False) diff --git a/tests/test_checks.py b/tests/test_checks.py index 6d9a757..3d5ee0a 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -2,6 +2,7 @@ from dwg_review import checks from dwg_review.checks import ( + check_collisions, check_duplicate_entities, check_zero_length, derive_from_evidence, @@ -127,6 +128,79 @@ def test_duplicate_swallows_tool_error(): assert any("find_duplicate_entities 失败" in line for line in logs) +# --------------------------------------------------------------------------- +# check_collisions +# --------------------------------------------------------------------------- +def _collision_item(a="A1", b="B1", ratio=0.8, same_layer=True): + return { + "a": {"handle": a, "type": "LINE", "layer": "0", "bbox": {}}, + "b": {"handle": b, "type": "LWPOLYLINE", "layer": "0", "bbox": {}}, + "overlap": {"bbox": {}, "width": 1.0, "height": 1.0, "area": 1.0, "ratio": ratio}, + "same_layer": same_layer, + "same_type": False, + } + + +def test_collisions_turn_items_into_issues(): + client = FakeMcpClient(responses={"find_collisions": ok_text({ + "total": 2, "returned": 2, "offset": 0, "entity_count": 10, + "items": [_collision_item("A1", "B1", 0.9), _collision_item("A2", "B2", 0.6)], + })}) + ctx = make_ctx(client=client) + issues = check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=QUIET) + assert [i["handles"] for i in issues] == [["A1", "B1"], ["A2", "B2"]] + assert all(i["category"] == "干涉" and i["severity"] == "medium" for i in issues) + assert all(i["handle"] == i["handles"][0] for i in issues) + assert "0.90" in issues[0]["message"] + + +def test_collisions_skips_items_missing_handles(): + client = FakeMcpClient(responses={"find_collisions": ok_text({ + "items": [ + {"a": {"handle": "A1"}, "b": {}}, + {"a": {}, "b": {"handle": "B1"}}, + _collision_item("A2", "B2"), + ], + })}) + ctx = make_ctx(client=client) + issues = check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=QUIET) + assert [i["handles"] for i in issues] == [["A2", "B2"]] + + +def test_collisions_tolerates_missing_ratio(): + client = FakeMcpClient(responses={"find_collisions": ok_text({ + "items": [{"a": {"handle": "A1"}, "b": {"handle": "B1"}, "overlap": {}}], + })}) + ctx = make_ctx(client=client) + issues = check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=QUIET) + assert len(issues) == 1 + assert "重叠比 ?" in issues[0]["message"] + + +def test_collisions_passes_params(): + client = FakeMcpClient(responses={"find_collisions": ok_text({"items": []})}) + ctx = make_ctx(client=client) + check_collisions(ctx, WINDOW, min_overlap=0.75, limit=33, log=QUIET) + assert client.arguments_of("find_collisions") == { + "window": WINDOW, "min_overlap": 0.75, "limit": 33, + } + + +def test_collisions_swallows_tool_error(): + logs = [] + client = FakeMcpClient(responses={"find_collisions": McpToolError("炸了")}) + ctx = make_ctx(client=client) + assert check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=logs.append) == [] + assert any("find_collisions 失败" in line for line in logs) + + +def test_collisions_tolerates_non_dict_envelope(): + client = FakeMcpClient(responses={"find_collisions": ok_text("oops")}) + ctx = make_ctx(client=client) + assert check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=QUIET) == [] + + + # --------------------------------------------------------------------------- # derive_from_evidence # --------------------------------------------------------------------------- @@ -171,6 +245,7 @@ def build_ctx(): "find_zero_length": ok_text({"items": [{"handle": "Z1", "type": "LINE", "length": 0.0}]}), "find_duplicate_entities": ok_text({"groups": [ {"type": "LINE", "handles": ["D1", "D2"]}]}), + "find_collisions": ok_text({"items": [_collision_item("C1", "C2", 0.9)]}), }) return make_ctx(client=client) @@ -210,3 +285,22 @@ def test_run_respects_unclosed_switch(monkeypatch): ctx = build_ctx() ev = make_evidence(polylines={"items": [{"handle": "P1", "vertex_count": 4, "closed": False}]}) assert [i["handle"] for i in run_deterministic_checks(ctx, ev, log=QUIET)] == ["Z1", "D1"] + + +def test_run_collisions_off_by_default(monkeypatch): + # 默认不跑干涉粗筛(AABB 相交噪声大),工具调用里不应出现 find_collisions + monkeypatch.setattr(checks.config, "CHECK_COLLISIONS", False) + ctx = build_ctx() + run_deterministic_checks(ctx, make_evidence(), log=QUIET) + assert "find_collisions" not in ctx.client.called_names() + + +def test_run_collisions_when_enabled(monkeypatch): + monkeypatch.setattr(checks.config, "CHECK_COLLISIONS", True) + monkeypatch.setattr(checks.config, "COLLISION_MIN_OVERLAP", 0.6) + ctx = build_ctx() + issues = run_deterministic_checks(ctx, make_evidence(), log=QUIET) + by_handle = {i.get("handle"): i for i in issues} + assert "C1" in by_handle + assert by_handle["C1"]["category"] == "干涉" + assert ctx.client.arguments_of("find_collisions")["min_overlap"] == 0.6 diff --git a/tests/test_config.py b/tests/test_config.py index 5b63f40..ff1476e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -77,6 +77,8 @@ def test_credibility_constants_have_expected_types(): assert isinstance(config.ZERO_LENGTH_TOLERANCE, float) assert isinstance(config.DUP_TOLERANCE, float) assert isinstance(config.DUP_GROUP_LIMIT, int) + assert isinstance(config.CHECK_COLLISIONS, bool) + assert isinstance(config.COLLISION_MIN_OVERLAP, float) def test_tiling_constants_have_expected_types(): @@ -132,6 +134,13 @@ def test_credibility_defaults_are_on(): assert config.CHECK_UNCLOSED_POLYLINES is True +def test_collision_defaults_are_sane(): + # 干涉粗筛默认关闭(AABB 相交噪声大,改由 LLM 按需调用); + # 阈值须落在 [0,1](重叠比是乘积,可能为 0) + assert config.CHECK_COLLISIONS is False + assert 0.0 <= config.COLLISION_MIN_OVERLAP <= 1.0 + + def test_checklist_path_resolves_relative_to_root(): assert config.CHECKLIST_PATH.name == "review_checklist.json" assert config.CHECKLIST_PATH.parent == config.PROJECT_ROOT diff --git a/tests/test_review.py b/tests/test_review.py index c4517b0..ca21a1e 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -357,6 +357,7 @@ def test_review_tools_swap_raw_view_tools_for_inspect_region(monkeypatch): names = _tool_names(make_ctx()) assert "inspect_region" in names assert "query_entities" in names # 其余只读工具照旧 + assert "find_collisions" in names # 干涉粗筛对 LLM 可见 assert "zoom_to_window" not in names assert "zoom_to_entity" not in names assert "get_viewport_screenshot" not in names diff --git a/verify_collisions.py b/verify_collisions.py new file mode 100644 index 0000000..20d576a --- /dev/null +++ b/verify_collisions.py @@ -0,0 +1,267 @@ +# -*- coding: utf-8 -*- +""" +verify_collisions.py +干涉/碰撞候选工具(find_collisions)运行时验证脚本。 + +前提:CAD 已打开并加载 cad_mcp_frame.arx(插件 DLL),127.0.0.1:8080 在监听。 + +用法: + python verify_collisions.py # 全模型 + 每个图框各跑一次 + python verify_collisions.py --top 15 # 打印前 N 条候选(默认 10) + python verify_collisions.py --window # 只跑全模型,跳过图框内 + +覆盖: + - 工具已注册(tools/list) + - 全模型粗筛:entity_count / total 候选数 / ratio 分布 + - min_overlap 阈值过滤(阈值升高 → 候选数不增) + - 分页(limit / offset) + - narrow=true 细筛(返回项带 exact 字段) + - 逐图框(detect_drawing_frames 的 window)候选数 + +本脚本**只读**,不做任何写库操作,无需清理。 +""" +import socket +import json +import sys + +HOST, PORT = "127.0.0.1", 8080 + + +# ---------------------------------------------------------------- MCP 通信 +def rpc(method, params=None, timeout=60): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + s.connect((HOST, PORT)) + req = {"jsonrpc": "2.0", "id": 1, "method": method} + if params is not None: + req["params"] = params + s.sendall(json.dumps(req).encode("utf-8")) + data = b"" + while True: + chunk = s.recv(65536) + if not chunk: + break + data += chunk + if len(chunk) < 65536: + break + s.close() + return json.loads(data.decode("utf-8")) + + +def call_tool(name, arguments): + return rpc("tools/call", {"name": name, "arguments": arguments}) + + +def payload(resp): + """从 tools/call 响应里取出数据类工具返回的内层 JSON。""" + if resp is None: + return False, "no response" + if "error" in resp: + return False, resp["error"] + try: + for item in resp["result"]["content"]: + if item.get("type") == "text": + return True, json.loads(item["text"]) + except Exception as e: # noqa: BLE001 + return False, "parse error: %s" % e + return False, "no text content" + + +# ---------------------------------------------------------------- 测试驱动 +RESULTS = [] + + +def record(name, ok, detail): + RESULTS.append((name, ok, detail)) + print(" [%s] %-28s %s" % ("PASS" if ok else "FAIL", name, detail)) + + +def collisions(**args): + """调用 find_collisions 并返回 (ok, envelope)。""" + return payload(call_tool("find_collisions", args)) + + +def ratio_of(item): + ov = item.get("overlap") or {} + r = ov.get("ratio") + return r if isinstance(r, (int, float)) else -1.0 + + +def print_top(env, top): + items = env.get("items") or [] + if not items: + print(" (无候选)") + return + print(" 前 %d 条候选(按重叠比降序):" % min(top, len(items))) + for it in items[:top]: + a, b = it.get("a") or {}, it.get("b") or {} + print(" ratio=%.3f %s/%s × %s/%s 同层=%s" % ( + ratio_of(it), a.get("type"), a.get("handle"), + b.get("type"), b.get("handle"), it.get("same_layer"))) + + +def main(): + top = 10 + if "--top" in sys.argv: + try: + top = int(sys.argv[sys.argv.index("--top") + 1]) + except (IndexError, ValueError): + pass + frames_only = "--window" in sys.argv + + print("=== find_collisions 运行时验证 ===") + print("目标: %s:%d\n" % (HOST, PORT)) + + # 0) 连接 & 工具清单 + try: + tools_resp = rpc("tools/list") + except Exception as e: # noqa: BLE001 + print("[错误] 无法连接 MCP 服务: %s" % e) + print("请确认 CAD 已打开、插件已加载、8080 端口在监听。") + return 2 + + names = [t["name"] for t in tools_resp.get("result", {}).get("tools", [])] + print("工具总数: %d" % len(names)) + if "find_collisions" not in names: + record("find_collisions 已注册", False, "tools/list 中不存在") + return 1 + record("find_collisions 已注册", True, "tool 总数 %d" % len(names)) + + # 1) 全模型粗筛 + print("\n1) 全模型粗筛") + ok, env = collisions(min_overlap=0.0, limit=200) + if not ok: + record("find_collisions 全模型", False, "%s" % env) + return 1 + total = env.get("total", 0) + ent = env.get("entity_count", 0) + record("find_collisions 全模型", True, + "entity_count=%s total 候选=%s returned=%s" % (ent, total, env.get("returned"))) + conts = env.get("excluded_containers") or [] + if conts: + print(" 已剔除容器 %d 个(图框块 / 分区边界):" % len(conts)) + for c in conts: + print(" %-8s %-14s reason=%s deg=%s" % ( + c.get("handle"), c.get("type"), c.get("reason"), c.get("degree"))) + else: + print(" (未剔除容器)") + record("容器过滤生效", len(conts) >= 1, "剔除 %d 个容器" % len(conts)) + print_top(env, top) + + # ratio 单调性(返回项应降序) + items = env.get("items") or [] + ratios = [ratio_of(i) for i in items] + record("候选按重叠比降序", ratios == sorted(ratios, reverse=True), "%s" % ratios[:8]) + + # 2) min_overlap 阈值过滤 + print("\n2) min_overlap 阈值过滤") + ok2, env2 = collisions(min_overlap=0.5, limit=200) + t2 = env2.get("total", -1) if ok2 else -1 + record("min_overlap=0.5 过滤", ok2 and 0 <= t2 <= total, + "total %s -> %s(阈值升高候选数不应增加)" % (total, t2)) + ok3, env3 = collisions(min_overlap=0.99, limit=200) + t3 = env3.get("total", -1) if ok3 else -1 + record("min_overlap=0.99 过滤", ok3 and 0 <= t3 <= t2, + "total %s -> %s" % (t2, t3)) + + # 3) 分页 + print("\n3) 分页(limit / offset)") + ok4, page1 = collisions(min_overlap=0.0, limit=3, offset=0) + ok5, page2 = collisions(min_overlap=0.0, limit=3, offset=3) + r1 = page1.get("returned") if ok4 else -1 + r2 = page2.get("returned") if ok5 else -1 + same_head = (page1.get("items") or [])[:1] == (page2.get("items") or [])[:1] + record("limit=3 首页", ok4 and r1 == min(3, total), "returned=%s" % r1) + record("offset=3 次页", ok5 and r2 == min(3, max(0, total - 3)), + "returned=%s(应与首页不同)" % r2) + record("分页不重复", not same_head or total <= 3, "首页首条 != 次页首条") + + # 4) narrow 细筛 + print("\n4) narrow 细筛(intersectWith)") + ok6, envn = collisions(min_overlap=0.0, limit=5, narrow=True) + nitems = envn.get("items") or [] if ok6 else [] + has_exact = all("exact" in i for i in nitems) if nitems else False + exact_true = sum(1 for i in nitems if i.get("exact")) + record("narrow 返回 exact 字段", ok6 and has_exact, + "%d/%d 条 exact=true" % (exact_true, len(nitems))) + + # 5) 逐图框 + if not frames_only: + print("\n5) 逐图框候选") + okf, fenv = payload(call_tool("detect_drawing_frames", {})) + frames = (fenv.get("items") or []) if okf else [] + if not frames: + record("detect_drawing_frames", False, "未识别到图框") + else: + print(" 识别到 %d 个图框" % len(frames)) + for idx, fr in enumerate(frames, 1): + bbox = fr.get("bbox") + if not bbox or len(bbox) != 4: + record("图框%d 干涉" % idx, False, "bbox 缺失") + continue + win = {"min_x": bbox[0], "min_y": bbox[1], + "max_x": bbox[2], "max_y": bbox[3]} + okw, wev = collisions(window=win, min_overlap=0.5, limit=50) + if not okw: + record("图框%d 干涉" % idx, False, "%s" % wev) + continue + record("图框%d 干涉" % idx, True, + "kind=%s entity_count=%s total(>=0.5)=%s" % ( + fr.get("kind"), wev.get("entity_count"), wev.get("total"))) + print_top(wev, min(top, 5)) + + # 6) 注入式正例:人为制造一处深度干涉,验证工具能报出(随后清理) + if "--inject" in sys.argv: + print("\n6) 注入式正例(人为干涉 → 应报出 → 清理)") + # 放在所有图框之外(union max_x≈390012),避免与真实图元相交 + ax, ay = 500000.0, 0.0 + rectA = [[ax, ay], [ax + 400, ay], [ax + 400, ay + 400], [ax, ay + 400]] + rectB = [[ax + 20, ay + 20], [ax + 420, ay + 20], + [ax + 420, ay + 420], [ax + 20, ay + 420]] + handles = [] + for name, pts in (("rectA", rectA), ("rectB", rectB)): + okp, penv = payload(call_tool("add_polyline", + {"points": pts, "closed": True})) + hs = [c.get("handle") for c in (penv.get("created") or [])] if okp else [] + handles += [h for h in hs if h] + record("注入 %s" % name, bool(okp and hs), "handle=%s" % hs) + try: + # 期望重叠比 ≈ 0.9025(380/400 的平方);min_overlap=0.5 应能命中 + oki, iev = collisions(min_overlap=0.5, limit=5) + items = iev.get("items") or [] if oki else [] + top_item = items[0] if items else None + got = None + if top_item: + ha = (top_item.get("a") or {}).get("handle") + hb = (top_item.get("b") or {}).get("handle") + if {ha, hb} == set(handles): + got = top_item + if got: + record("报出注入干涉", True, + "ratio=%.3f %s × %s" % (ratio_of(got), + (got.get("a") or {}).get("handle"), + (got.get("b") or {}).get("handle"))) + else: + record("报出注入干涉", False, + "首位候选不是注入对: %s" % (top_item and ( + (top_item.get("a") or {}).get("handle"), + (top_item.get("b") or {}).get("handle")))) + finally: + if handles: + okd, denv = payload(call_tool("delete_entities", {"handles": handles})) + record("清理注入实体", bool(okd and denv.get("count") == len(handles)), + "删除 %s / 共 %s" % (denv.get("count"), len(handles))) + else: + record("清理注入实体", False, "无 handle 可删") + + # 汇总 + print("\n=== 结果汇总 ===") + passed = sum(1 for _, ok, _ in RESULTS if ok) + for name, ok, detail in RESULTS: + print(" %-4s %-28s %s" % ("OK" if ok else "NG", name, detail)) + print("\n通过 %d / %d" % (passed, len(RESULTS))) + return 0 if passed == len(RESULTS) else 1 + + +if __name__ == "__main__": + sys.exit(main())