From 1e5bff268a0a1490a969e9905140b73e5ee23a92 Mon Sep 17 00:00:00 2001 From: LittleGuo Date: Thu, 15 Oct 2026 23:44:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(review):=20=E6=88=AA=E5=B1=8F=E5=89=8D?= =?UTF-8?q?=E6=9C=80=E5=A4=A7=E5=8C=96=E7=BB=98=E5=9B=BE=E5=8C=BA=20+=20?= =?UTF-8?q?=E7=A9=BA=E7=93=A6=E7=89=87=E8=BF=87=E6=BB=A4=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 MCP 工具 set_drawing_area(mode="max"|"restore"):最大化主窗口 + CLEANSCREENON (隐藏功能区/命令行/面板)。实测绘图区 2110x462 -> 3310x1084(面积 3.68x), restore 精确回位(窗口矩形/最大化态/绘图区尺寸全部还原)。 坑:CLEANSCREENOFF 是异步命令,AutoCAD 会按「进入干净屏幕时的布局」重设窗口几何, 把同步的 SetWindowPlacement 覆盖掉(表现为窗口停在最大化)。解法是插件内 SetTimer 延迟 700ms 再压回;实测窗口在 0.5~1.0s 之间收敛,故调用方等待取 1.5s。 取证层: - 新增 maximized_drawing_area(ctx) 上下文管理器,__main__ 用它包住整个取证循环 (多图框只进出一次);受 DRAWING_AREA_MAXIMIZE 控制,工具缺失/调用失败静默降级, restore 失败不掩盖业务异常。 - 内容过滤统一到规划与回退两条路径,共用同一谓词;过滤后为空则退回原集合。 - 判定改用「实体 bbox」而非「占用格」:格子量化会把跨界内容算进相邻格,反而更粗 (实测实体 bbox 正好压象限边界时 4 个象限全判有内容)。heatmap.occupied_boxes 随之删除。 实测(真实图纸 A0/A1/A2 三图框):截图张数 12/8/4 -> 8/5/4;pytest 296 passed。 新增 verify_drawing_area.py 作为该工具的验收脚本。 --- CODEBUDDY.md | 3 +- ai.memory/project_agent_roadmap.md | 48 ++++++- ai.memory/project_review_agent_impl.md | 1 + ai.memory/reference_environment.md | 1 + dwg_review/__main__.py | 31 ++-- dwg_review/config.py | 13 ++ dwg_review/heatmap.py | 9 -- dwg_review/phases/evidence.py | 85 +++++++++-- src/cad_mcp_frame/cad-mcp-frame.json | 20 +++ src/cad_mcp_plugins/cmakelists.txt | 1 + src/cad_mcp_plugins/dwg_review_ui.cpp | 192 +++++++++++++++++++++++++ tests/test_config.py | 13 ++ tests/test_evidence.py | 141 +++++++++++++++++- tests/test_heatmap.py | 9 -- verify_drawing_area.py | 140 ++++++++++++++++++ 15 files changed, 657 insertions(+), 50 deletions(-) create mode 100644 src/cad_mcp_plugins/dwg_review_ui.cpp create mode 100644 verify_drawing_area.py diff --git a/CODEBUDDY.md b/CODEBUDDY.md index 8f15c80..47caed4 100644 --- a/CODEBUDDY.md +++ b/CODEBUDDY.md @@ -50,6 +50,7 @@ python verify_mcp_tcp_hardening.py # TCP 收发路径加固(超长帧 / 粘 python verify_mcp_fault_injection.py # 工具异常保护(类型错误不再崩 CAD) python verify_stdio_bridge.py # stdio ↔ TCP 桥(协议协商 / 转发 / 通知) python verify_annotate.py # 批注回写链路 +python verify_drawing_area.py # 绘图区最大化:基线 → max → restore 全回位(实测 3.68x) ``` 这些脚本通过 TCP 连接 `127.0.0.1:8080` 收发 JSON-RPC。 @@ -90,7 +91,7 @@ python verify_annotate.py # 批注回写链路 ### 配置与产物布局 -- 运行时配置:`mcp_config.json`,必须与 `.arx` 同目录。示例见 `bin/Debug-R230/mcp_config.json`(含 `zoom_window_normalized`、`zoom_extents`、`get_viewport_screenshot` 三个工具);`src/cad_mcp_frame/cad-mcp-frame.json` 是模板(文件名带连字符,非运行时文件)。 +- 运行时配置:`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` 是模板(文件名带连字符,非运行时文件)。 - `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/project_agent_roadmap.md b/ai.memory/project_agent_roadmap.md index d8ede57..f032369 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 分辨率标定与后续路线 +description: dwg_review agent 的总目标、多源图纸分层架构(视觉为最大公约数)、工具层与 agent 层的正交分工、多源证据层、截图去 UI、grounding 实测、热力图 + 自适应瓦片规划 + VLM 分辨率标定、绘图区最大化(set_drawing_area)与空瓦片过滤、后续路线 type: project --- @@ -118,14 +118,56 @@ Agent 推理层(审查逻辑 / 清单 / 规则) - 真实 CAD 线条:**2/3/4px 失败,5/8/12px 成功** → R_line ≈ **5px**。 - **标定必须用 ACI 7(白/黑自适应)**:实测**红字(ACI 1)12px 完全读不出**——否则把 "对比度不足"误当"分辨率不足"。 -- 实测绘图区 **2110×462** → 有效 **1280×280**(长边 1280),视口宽高比 **4.567**。 +- 实测绘图区(**窗口未最大化时**)**2110×462** → 有效 **1280×280**(长边 1280),视口宽高比 **4.567**。 **用户拍板的默认值(2026-10-15)**:`TEXT_READABLE_PX=8`(折中,兼顾描边与 TrueType)、 `LINE_READABLE_PX=6`、`EVIDENCE_MAX_IMAGES=12`(原 5 会把规划结果截到只剩 4 张瓦片)。 **瓦片粒度内容过滤**:簇 bbox 是**包围盒**,L 形/斜向分布(如管道走向)含大片空白 → -`plan_regions(keep=...)` 按 `heatmap.occupied_boxes` 剔除**空瓦片**(实测有效)。 +`plan_regions(keep=...)` 剔除**空瓦片**(实测有效)。 **已知局限**:本图纸的文字在**块 / 自定义实体**内,`get_text_content` 检不到 → 该簇会按 **线条判据**出图(6px),块内文字可能读不清。要更保险可把 `LINE_READABLE_PX` 调到 8 (等价于全用文字判据,代价是同样预算下覆盖面积减半)。 + +## 绘图区最大化 + 内容过滤覆盖回退路径(2026-10-15,本轮) + +**用户诉求**(原话):「截屏之前,要把 CAD 铺满屏幕,并且把命令行也最小化,反正一句话, +**让绘图区最大化**,然后再去截屏。是不是更好?还有,我发现你有几次**截屏区里面没有实体**,浪费了。 +截屏之前判断一下这个区域有没有实体。」 + +**A. 新增 MCP 工具 `set_drawing_area(mode="max"|"restore")`**(`src/cad_mcp_plugins/dwg_review_ui.cpp`) + +- `max`:保存 `WINDOWPLACEMENT` + `CLEANSCREENSTATE`(**只存首次**)→ `ShowWindow(SW_MAXIMIZE)` + → `sendStringToExecute("_.CLEANSCREENON")`(隐藏功能区/命令行/面板)。 +- `restore`:`CLEANSCREENOFF` + 还原窗口布局。 +- **关键坑:CLEANSCREENOFF 是异步命令,会按「进入干净屏幕时的布局」重设窗口几何,把同步执行的 + `SetWindowPlacement` 覆盖掉**(表现为窗口停在最大化)。解法:restore 里用 + `SetTimer(NULL, 0, 700, delayedRestoreProc)` **延迟 700ms 再压回**布局。 + 实测窗口在 **0.5~1.0s** 之间收敛 → 调用方须等待 ≥1.5s。 +- **实测收益(屏幕 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`。 + `set_drawing_area` 以 `set_` 开头 → 被 `cad_tools.is_write_tool` 判为写类(不重试), + 且**不在** `READ_ONLY_TOOLS` 里 → LLM 的受控子循环**不能**动用户界面。 + +**B. 取证层接入**(`phases/evidence.py`) + +- `maximized_drawing_area(ctx)` 上下文管理器:`with` 内 `max`、退出 `finally` 里 `restore`。 + 受 `config.DRAWING_AREA_MAXIMIZE` 控制;工具缺失 / 调用失败 → **静默降级**; + **restore 失败不掩盖业务异常**。`__main__.run_review` 用它包住整个取证循环(多图框只进出一次)。 +- **内容过滤统一到两条路径**:原先只有规划路径过滤,回退路径(`sub_windows` 象限)不过滤。 + 现在 `keep = _content_predicate(boxes)` 一次构造,规划与回退**共用**; + `_apply_keep` 在「过滤后为空」时**退回原集合**(宁可多拍不漏)。 +- **改用「实体 bbox」而非「占用格」判定**(上一版用 `heatmap.occupied_boxes`): + 格子量化会把跨界内容算进相邻格,**反而更粗**(实测某实体 bbox 正好压象限边界时 4 个象限全判有内容)。 + → 直接 `boxes_intersect(tile, entity_box)`,更精确也更简单;`heatmap.occupied_boxes` 随之**删除**。 + 语义:**接触即算有内容**(保守)。 + +**实测(真实图纸,3 个图框 A0/A1/A2)**:截图张数 **12/8/4 → 8/5/4**(收益同时来自 +绘图区变大 → 单张覆盖更多世界尺寸,与空瓦片被剔除)。端到端 `--plan-only` 跑通, +运行中窗口峰值 `[-4,-2,3444,1442]`(面积 2.31×),结束**精确还原** `[68,0,2316,960]`。 + +**新增配置**:`DRAWING_AREA_MAXIMIZE`(True) / `DRAWING_AREA_ENTER_WAIT`(1.5) / +`DRAWING_AREA_RESTORE_WAIT`(1.5,**必须 > 插件内 700ms 延迟还原**)。 diff --git a/ai.memory/project_review_agent_impl.md b/ai.memory/project_review_agent_impl.md index aeda3a3..e76fd34 100644 --- a/ai.memory/project_review_agent_impl.md +++ b/ai.memory/project_review_agent_impl.md @@ -97,6 +97,7 @@ type: project `mcp_Tools::clear()`(唯一 `FreeLibrary` 处)仅在 ARX 卸载时调用 → **没有热重载**。 流程:关 CAD → `cmake --build --preset R230 --config Debug` → 重启 `acad.exe`(`acad.rx` 自动加载) → 轮询 `127.0.0.1:8080` 监听。编译阶段可先行(语法错误会被提前暴露,只有链接会因占用失败)。 + **用户已授权(2026-10-15):遇到 LNK1168 直接 `Stop-Process acad -Force` 即可,不必先问。** ## 基座健壮性:工具执行的异常安全(2026-10-14) diff --git a/ai.memory/reference_environment.md b/ai.memory/reference_environment.md index 38c5b96..d52b6b5 100644 --- a/ai.memory/reference_environment.md +++ b/ai.memory/reference_environment.md @@ -46,6 +46,7 @@ type: reference | `verify_heatmap.py` | 实体分布热力图(占用网格 + 聚类 + PNG),**不耗 token**,缓存 bbox JSON 供离线调参 | | `verify_vlm_resolution_offline.py` | **离线**(纯 PIL 合成)标定 VLM 读图下限 `R_text`/`R_line`,`--dry-run` 只出图 | | `verify_text_resolution.py` | **CAD 实测**标定(临时加字 + 平行线,测完 `delete_entities` 清理);`--keep` 保留 | +| `verify_drawing_area.py` | 绘图区最大化验收:基线 → `set_drawing_area(max)` → `restore`,断言窗口矩形/最大化态/绘图区尺寸全回位(实测 3.68×) | > 历史:根目录 `test.py` / `test_autoagent.py` / `countOfCircle.py` / `agent_loop.py` / > `dwg_review_agent.py` 五个遗留调试脚本已于 2026-10-14 删除(前三个含硬编码 API Key, diff --git a/dwg_review/__main__.py b/dwg_review/__main__.py index a143b58..25b86ae 100644 --- a/dwg_review/__main__.py +++ b/dwg_review/__main__.py @@ -64,20 +64,23 @@ def run_review(ctx, args): print("[2/4] 取证:逐图框采集(串行) ...") evidences = [] - for idx, frame in enumerate(frames, 1): - print( - f" - 图框 {idx}/{len(frames)} kind={frame.get('kind')} " - f"sheet={frame.get('sheet_size')}" - ) - ev = evidence_phase.collect(ctx, idx, frame) - if ev is None: - print(" [跳过] 缺少有效包围盒") - continue - evidences.append(ev) - print( - f" 图元 {(ev.get('entities') or {}).get('total')} 条," - f"截图 {len(evidence_images(ev))} 张" - ) + # 截屏前把绘图区最大化(窗口铺满 + 干净屏幕),采集完在 finally 里还原; + # 旧版插件没有该工具时静默降级。 + with evidence_phase.maximized_drawing_area(ctx): + for idx, frame in enumerate(frames, 1): + print( + f" - 图框 {idx}/{len(frames)} kind={frame.get('kind')} " + f"sheet={frame.get('sheet_size')}" + ) + ev = evidence_phase.collect(ctx, idx, frame) + if ev is None: + print(" [跳过] 缺少有效包围盒") + continue + evidences.append(ev) + print( + f" 图元 {(ev.get('entities') or {}).get('total')} 条," + f"截图 {len(evidence_images(ev))} 张" + ) if args.plan_only: path = _dump_evidence(evidences) diff --git a/dwg_review/config.py b/dwg_review/config.py index 5eb8c91..9a6c461 100644 --- a/dwg_review/config.py +++ b/dwg_review/config.py @@ -155,6 +155,19 @@ HEATMAP_CLUSTER_DILATE = int(os.environ.get("HEATMAP_CLUSTER_DILATE", "0")) # 丢弃 bbox 面积超过图框该比例的实体(去图框边框/图签块) HEATMAP_MAX_AREA_FRAC = float(os.environ.get("HEATMAP_MAX_AREA_FRAC", "0.5")) +# --------------------------------------------------------------------------- +# 绘图区最大化(截屏前把 CAD 窗口铺满屏幕 + 进入干净屏幕) +# --------------------------------------------------------------------------- +# 是否在取证前最大化绘图区。实测绘图区面积 2110x462 → 3310x1084(3.68x), +# 同样的视觉 token 预算下细节更清楚。工具不存在(旧版插件)时**静默降级**。 +DRAWING_AREA_MAXIMIZE = _read_bool("DRAWING_AREA_MAXIMIZE", True) +# 进入干净屏幕后等待 CAD 渲染 / 重排窗口的秒数 +DRAWING_AREA_ENTER_WAIT = float(os.environ.get("DRAWING_AREA_ENTER_WAIT", "1.5")) +# 还原后等待的秒数。插件内部是「700ms 延迟 + SetWindowPlacement」(CLEANSCREENOFF 是 +# 异步命令,会把窗口几何改回它记住的布局,必须等它跑完再压回),实测窗口在 0.5~1.0s +# 之间收敛,故取 1.5s 留余量——太小会在界面还没回位时就开始下一步。 +DRAWING_AREA_RESTORE_WAIT = float(os.environ.get("DRAWING_AREA_RESTORE_WAIT", "1.5")) + def has_api_key() -> bool: return bool(API_KEY.strip()) diff --git a/dwg_review/heatmap.py b/dwg_review/heatmap.py index 083bb69..40d9b6f 100644 --- a/dwg_review/heatmap.py +++ b/dwg_review/heatmap.py @@ -246,15 +246,6 @@ def _cell_box(grid, r, c): return (x1, y_top - ch, x1 + cw, y_top) -def occupied_boxes(grid, min_count=1): - """占用格的**世界 bbox** 列表。 - - 供瓦片规划做「内容过滤」:簇 bbox 是**包围盒**,对 L 形/斜向分布会含大片空白, - 故瓦片切分后还要按这些格子判断该瓦片是否真有内容。 - """ - return [_cell_box(grid, r, c) for r, c in occupied_cells(grid, min_count)] - - def grid_to_windows(clusters, margin=0.0): """把簇 bbox 转成 zoom 窗口列表(保持输入顺序,即实体计数降序)。""" out = [] diff --git a/dwg_review/phases/evidence.py b/dwg_review/phases/evidence.py index ee3e5b3..735381a 100644 --- a/dwg_review/phases/evidence.py +++ b/dwg_review/phases/evidence.py @@ -7,6 +7,7 @@ (未来在对应分支产出**同形** evidence,下游无需改动)。 """ +import contextlib import math import time @@ -16,6 +17,44 @@ from ..mcp_client import result_images, result_json _LABEL_OVERALL = "overall" +# 绘图区最大化用的工具名(旧版插件没有此工具 → 静默降级) +_TOOL_DRAWING_AREA = "set_drawing_area" + + +@contextlib.contextmanager +def maximized_drawing_area(ctx): + """截屏前最大化绘图区(窗口铺满屏幕 + 干净屏幕),退出时还原。 + + 实测绘图区像素 2110x462 → 3310x1084(3.68x);在同样的长边下采样预算下, + 有效像素更多、细节更清楚,自适应瓦片张数也更少。 + + - 受 `config.DRAWING_AREA_MAXIMIZE` 控制; + - 工具不存在(旧版插件)/ 调用失败 → **静默降级**:照常取证,只是绘图区小一些; + - 退出时一定尝试还原;**还原失败不掩盖业务异常**(不 raise)。 + """ + if not config.DRAWING_AREA_MAXIMIZE: + yield + return + + entered = False + try: + if ctx.tools.has(_TOOL_DRAWING_AREA): + ctx.tools.call(_TOOL_DRAWING_AREA, {"mode": "max"}) + entered = True + time.sleep(config.DRAWING_AREA_ENTER_WAIT) + except Exception: # noqa: BLE001 - 界面优化失败不应中断审查 + entered = False + + try: + yield + finally: + if entered: + try: + ctx.tools.call(_TOOL_DRAWING_AREA, {"mode": "restore"}) + time.sleep(config.DRAWING_AREA_RESTORE_WAIT) + except Exception: # noqa: BLE001 + pass + def bbox_to_window(bbox): """[minx,miny,maxx,maxy] -> window 对象。""" @@ -101,6 +140,32 @@ def _text_boxes(entities, texts, dimensions): return boxes +def _content_predicate(boxes): + """构造「该窗口内是否有实体」谓词;无实体返回 None。 + + 用于剔除空白瓦片:簇 / 象限的 bbox 只是**包围盒**,L 形或斜向分布会含大片空白。 + 这里直接按**实体 bbox** 判断——比先量化成占用格更精确(格子的跨界膨胀会把 + 相邻空白瓦片也判成有内容)。接触即算有内容(保守,宁可多拍不漏)。 + """ + boxes = [b for b in (boxes or []) if b] + if not boxes: + return None + + def _has_content(win): + box = (win["min_x"], win["min_y"], win["max_x"], win["max_y"]) + return any(tiling.boxes_intersect(box, b) for b in boxes) + + return _has_content + + +def _apply_keep(regions, keep): + """按 keep 过滤窗口;过滤后为空则**退回原集合**(宁可多拍也不漏)。""" + if keep is None or not regions: + return regions + kept = [w for w in regions if keep(w)] + return kept or regions + + def _plan_tiles(window, frame, boxes, text_boxes, truncated, view_px): """尝试按实体分布规划瓦片窗口;任何条件不满足都返回 [](调用方退回固定策略)。 @@ -128,18 +193,11 @@ def _plan_tiles(window, frame, boxes, text_boxes, truncated, view_px): flags = tiling.cluster_text_flags(clusters, text_boxes) text_idx = {i for i, is_text in enumerate(flags) if is_text} - # 内容过滤:簇 bbox 是包围盒,L 形/斜向分布会含大片空白 → 按**占用格**剔除空瓦片 - occ = heatmap.occupied_boxes(grid) - - def _has_content(win): - box = (win["min_x"], win["min_y"], win["max_x"], win["max_y"]) - return any(tiling.boxes_intersect(box, b) for b in occ) - return tiling.plan_regions( frame, clusters, text_clusters=text_idx, s_text=s_text, s_line=s_line, view_px=eff, max_tiles=config.TILE_MAX_COUNT, overlap=config.TILE_OVERLAP, - keep=_has_content, + keep=_content_predicate(boxes), ) @@ -149,7 +207,8 @@ def capture_images(ctx, window, *, tiles=0, max_images=None, frame=None, - 整体 label="overall",局部 label="tile-1"..;行优先。 - 局部窗口优先由 `_plan_tiles` 按**实体分布**给出(空白区不出图); - 不可用时退回 `sub_windows(window, tiles)`(行为与旧版完全一致)。 + 不可用时退回 `sub_windows(window, tiles)`,**同样按内容过滤**空白象限 + (过滤后为空则退回未过滤集合,保证不漏拍)。 - 结束时**复位到整体视口**,保证后续流程从整幅视图起手。 - 受 max_images(默认 config.EVIDENCE_MAX_IMAGES)截断。 """ @@ -166,10 +225,14 @@ def capture_images(ctx, window, *, tiles=0, max_images=None, frame=None, view_px = image_size_b64(overall[0]) if overall else None _add(overall, _LABEL_OVERALL) - # 局部:优先自适应瓦片,失败退回等分象限 + # 内容过滤:空白区不出图。按**实体 bbox** 判定,规划与回退两条路径共用同一谓词; + # 实体数据被截断时不可信,放弃过滤(宁可多拍也不漏)。 + keep = None if truncated else _content_predicate(boxes) + + # 局部:优先自适应瓦片,失败退回等分象限(回退路径同样按内容过滤) regions = _plan_tiles(window, frame, boxes, text_boxes, truncated, view_px) if not regions: - regions = sub_windows(window, tiles) + regions = _apply_keep(sub_windows(window, tiles), keep) for i, sub in enumerate(regions, 1): if len(images) >= max_images: diff --git a/src/cad_mcp_frame/cad-mcp-frame.json b/src/cad_mcp_frame/cad-mcp-frame.json index 3651ee6..2700fc9 100644 --- a/src/cad_mcp_frame/cad-mcp-frame.json +++ b/src/cad_mcp_frame/cad-mcp-frame.json @@ -50,6 +50,26 @@ "factory_function": "CreateViewportScreenshotTool" } }, +{ + "name": "set_drawing_area", + "description": "设置绘图区为最大化状态,显著提升截图像素与细节可读性。mode=max 时最大化 CAD 主窗口并进入干净屏幕(隐藏功能区/命令行/面板);mode=restore 时还原。截图取证前调用 max、结束后必须调用 restore。", + "inputSchema": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["max", "restore"], + "description": "max=最大化绘图区(最大化主窗口并进入干净屏幕,隐藏功能区/命令行/面板);restore=还原调用前的窗口位置与干净屏幕状态" + } + }, + "required": ["mode"] + }, + "backend": { + "type": "c++_dll", + "dll_name": "cad_mcp_plugins.dll", + "factory_function": "CreateSetDrawingAreaTool" + } +}, { "name": "get_drawing_info", "description": "获取当前图纸的元数据:文件名、单位、布局数量、各空间实体总数。审查流程的第一步。", diff --git a/src/cad_mcp_plugins/cmakelists.txt b/src/cad_mcp_plugins/cmakelists.txt index 4d98ce7..5df98cb 100644 --- a/src/cad_mcp_plugins/cmakelists.txt +++ b/src/cad_mcp_plugins/cmakelists.txt @@ -14,6 +14,7 @@ set(SOURCES "dwg_review_frame.cpp" "dwg_review_check.cpp" "dwg_review_view.cpp" + "dwg_review_ui.cpp" "dwg_annotate_common.cpp" "dwg_annotate_draw.cpp" "dwg_annotate_edit.cpp" diff --git a/src/cad_mcp_plugins/dwg_review_ui.cpp b/src/cad_mcp_plugins/dwg_review_ui.cpp new file mode 100644 index 0000000..1982407 --- /dev/null +++ b/src/cad_mcp_plugins/dwg_review_ui.cpp @@ -0,0 +1,192 @@ +// ============================================================================ +// dwg_review_ui.cpp +// 图纸审查 - 绘图区界面控制工具(c++_dll 后端) +// set_drawing_area : 最大化绘图区(最大化主窗口 + 进入「干净屏幕」)与还原 +// +// 背景:截图质量与「绘图区像素」直接相关。实测 CAD 主窗口未最大化时,绘图区只有 +// 2110x462 —— 竖向被标题栏 / 功能区 / 命令行 / 状态栏吃掉约 500px。最大化窗口并进入 +// 干净屏幕(隐藏功能区 / 命令行 / 面板)后绘图区显著变大;同样按长边 1280 下采样时 +// 有效像素更多、视口宽高比更接近图纸,自适应瓦片张数更少。 +// +// 实测(AutoCAD 2020,屏幕 3440x1440,测试图纸)绘图区像素: +// 非最大化 / 无干净屏幕 : 2110 x 462 +// 最大化 + 干净屏幕 : 3310 x 1084 → 面积 3.68x +// +// 重要:本工具会**改动用户的 CAD 界面**(窗口位置 + 干净屏幕状态)。 +// 调用方必须在 finally 里以 mode="restore" 还原;还原信息在本进程内静态保存。 +// 注意 restore 的窗口几何是**延迟**压回的(CLEANSCREENOFF 异步,见 restore 分支), +// 调用方在 restore 后应等待约 0.8s 再认为界面已还原。 +// ============================================================================ + +#include "cadsdk.h" +#include "dwg_review_common.h" +#include "mcp_plugin_api.h" + +#include +#include + +using namespace dwg_review; + +namespace +{ + // 进程内保存的还原信息(工具调用跨请求,故用静态变量)。 + struct SavedUiState + { + bool valid; + bool cleanWasOn; + WINDOWPLACEMENT place; + + SavedUiState() : valid(false), cleanWasOn(false) + { + place.length = sizeof(WINDOWPLACEMENT); + } + }; + SavedUiState g_saved; + + // 待延迟还原的窗口(见 restore 分支:CLEANSCREENOFF 是异步命令, + // AutoCAD 处理它时会按「进入干净屏幕时的布局」重设窗口几何, + // 覆盖掉我们同步执行的 SetWindowPlacement,故需延后一段时间再压回去)。 + HWND g_pendingRestore = NULL; + + // 把保存的窗口布局压回去(幂等;无保存信息则空操作)。 + void applySavedPlacement() + { + if (g_pendingRestore && ::IsWindow(g_pendingRestore) && g_saved.valid) + ::SetWindowPlacement(g_pendingRestore, &g_saved.place); + g_pendingRestore = NULL; + g_saved.valid = false; + } + + void CALLBACK delayedRestoreProc(HWND, UINT, UINT_PTR idEvent, DWORD) + { + ::KillTimer(NULL, idEvent); + applySavedPlacement(); + } + + // CAD 主框架窗口:优先 adsw_acadMainWnd(),退回绘图视图的根窗口。 + HWND mainFrame() + { + HWND h = adsw_acadMainWnd(); + if (h && ::IsWindow(h)) return h; + CView *v = acedGetAcadDwgView(); + if (v && v->m_hWnd) return ::GetAncestor(v->m_hWnd, GA_ROOT); + return NULL; + } + + // 绘图视图窗口(仅图形区,不含滚动条 / 状态栏)。 + HWND dwgView() + { + CView *v = acedGetAcadDwgView(); + if (v && v->m_hWnd) return v->m_hWnd; + return adsw_acadDocWnd(); + } + + // 发送命令(异步;调用方需等待渲染)。 + void sendCmd(const wchar_t *cmd) + { + if (acDocManager && acDocManager->curDocument()) + acDocManager->sendStringToExecute(acDocManager->curDocument(), cmd); + } + + // 读取整型系统变量;失败返回 fallback。 + int getIntVar(const ACHAR *name, int fallback) + { + struct resbuf rb; + if (acedGetVar(name, &rb) == RTNORM && rb.restype == RTSHORT) + return rb.resval.rint; + return fallback; + } + + nlohmann::json viewSize() + { + nlohmann::json a = nlohmann::json::array(); + HWND h = dwgView(); + RECT rc; + if (!h || !::GetClientRect(h, &rc)) return a; + a.push_back(rc.right - rc.left); + a.push_back(rc.bottom - rc.top); + return a; + } + + nlohmann::json mainRect() + { + nlohmann::json a = nlohmann::json::array(); + HWND h = mainFrame(); + RECT rc; + if (!h || !::GetWindowRect(h, &rc)) return a; + a.push_back(rc.left); + a.push_back(rc.top); + a.push_back(rc.right); + a.push_back(rc.bottom); + return a; + } +} + +class mcp_Tool_SetDrawingArea : public mcp_Tool +{ +public: + virtual nlohmann::json Execute(const nlohmann::json &args) override + { + std::string mode = "max"; + if (args.contains("mode") && args["mode"].is_string()) + mode = args["mode"].get(); + + HWND hMain = mainFrame(); + if (!hMain) + return makeError(kErrBadParam, "cannot locate AutoCAD main window"); + + nlohmann::json r; + + if (mode == "max") + { + // 只保存一次(重复 max 不会覆盖首次的原始状态) + if (!g_saved.valid) + { + g_saved.place.length = sizeof(WINDOWPLACEMENT); + ::GetWindowPlacement(hMain, &g_saved.place); + // CLEANSCREENSTATE 读不到时按「原本没开」处理 + g_saved.cleanWasOn = (getIntVar(L"CLEANSCREENSTATE", 0) != 0); + g_saved.valid = true; + } + if (!::IsZoomed(hMain)) + ::ShowWindow(hMain, SW_MAXIMIZE); + if (!g_saved.cleanWasOn) + sendCmd(L"_.CLEANSCREENON "); + r["mode"] = "max"; + r["clean_before"] = g_saved.cleanWasOn; + } + else if (mode == "restore") + { + if (g_saved.valid) + { + if (!g_saved.cleanWasOn) + { + // 退出干净屏幕是**异步**命令:AutoCAD 随后会按「进入干净屏幕时的布局」 + // 重设窗口几何,把我们同步执行的 SetWindowPlacement 覆盖掉 + // (表现为窗口仍最大化)。故延后 700ms 再压回原始布局。 + sendCmd(L"_.CLEANSCREENOFF "); + g_pendingRestore = hMain; + ::SetTimer(NULL, 0, 700, delayedRestoreProc); + } + else + { + // 原本就在干净屏幕:无异步覆盖,直接同步还原。 + g_pendingRestore = hMain; + applySavedPlacement(); + } + } + r["mode"] = "restore"; + } + else + { + return makeError(kErrBadParam, "mode must be 'max' or 'restore'"); + } + + r["ok"] = true; + r["main_rect"] = mainRect(); + r["view_size"] = viewSize(); // 注意:CLEANSCREENON 异步,刚调用时可能还是旧值 + return makeJsonResult(r); + } +}; + +EXPORT_MCP_TOOL(mcp_Tool_SetDrawingArea, CreateSetDrawingAreaTool) diff --git a/tests/test_config.py b/tests/test_config.py index 755f9b5..73872d2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -101,6 +101,19 @@ def test_tiling_defaults_are_sane(): assert config.HEATMAP_CELL_DIV > 0 +def test_drawing_area_constants_have_expected_types(): + assert isinstance(config.DRAWING_AREA_MAXIMIZE, bool) + assert isinstance(config.DRAWING_AREA_ENTER_WAIT, float) + assert isinstance(config.DRAWING_AREA_RESTORE_WAIT, float) + + +def test_drawing_area_defaults_are_sane(): + # 默认开启绘图区最大化;还原等待须盖过插件内 700ms 的延迟还原(实测 1.0s 才收敛) + assert config.DRAWING_AREA_MAXIMIZE is True + assert config.DRAWING_AREA_ENTER_WAIT >= 0 + assert config.DRAWING_AREA_RESTORE_WAIT >= 1.2 + + def test_credibility_defaults_are_on(): # 默认开启,保证默认拿到可信度收益 assert config.DETERMINISTIC_CHECKS is True diff --git a/tests/test_evidence.py b/tests/test_evidence.py index 0c57abc..ef385d7 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -6,13 +6,25 @@ import io import pytest from dwg_review.phases.evidence import ( + _apply_keep, + _content_predicate, _is_truncated, _plan_tiles, _text_boxes, bbox_to_window, collect_frame, + maximized_drawing_area, +) +from tests.fakes import ( + STANDARD_TOOLS, + FakeMcpClient, + err, + frame, + make_ctx, + ok_image, + ok_text, + tool_schema, ) -from tests.fakes import FakeMcpClient, frame, make_ctx, ok_image, ok_text def test_bbox_to_window_mapping(): @@ -267,9 +279,25 @@ def test_capture_images_tiles_text_cluster_is_finer(tiling_env): def test_capture_images_falls_back_without_scale(tiling_env): - """缺 sheet_size/sheet_scale → 退回固定 2x2 象限。""" + """缺 sheet_size/sheet_scale → 退回固定 2x2 象限,并按内容过滤空白象限。""" pytest.importorskip("PIL") - ctx = make_ctx(client=_tiling_client(_png_b64())) + client = _tiling_client(_png_b64()) + # 实体完全落在**左下**象限内(不贴边界) + client.responses["query_entities"] = ok_text( + {"total": 1, "items": [{"handle": "A", "type": "LINE", + "bbox": [0, 0, 50000, 15000]}]} + ) + ctx = make_ctx(client=client) + ev = collect_frame(ctx, 1, frame([0, 0, 120000, 40000], sheet_size="ZZ")) + assert [i["label"] for i in ev["images"]] == ["overall", "tile-1"] + + +def test_capture_images_fallback_keeps_all_when_no_boxes(tiling_env): + """拿不到实体 bbox(无法判断内容)→ 回退路径不过滤,四象限全拍。""" + pytest.importorskip("PIL") + client = _tiling_client(_png_b64()) + client.responses["query_entities"] = ok_text({"total": 0, "items": []}) + ctx = make_ctx(client=client) ev = collect_frame(ctx, 1, frame([0, 0, 120000, 40000], sheet_size="ZZ")) assert [i["label"] for i in ev["images"]] == [ "overall", "tile-1", "tile-2", "tile-3", "tile-4", @@ -291,3 +319,110 @@ def test_capture_images_falls_back_when_truncated(tiling_env): assert [i["label"] for i in ev["images"]] == [ "overall", "tile-1", "tile-2", "tile-3", "tile-4", ] + + +# --------------------------------------------------------------------------- +# 内容过滤 / 绘图区最大化 +# --------------------------------------------------------------------------- +def test_apply_keep_filters_and_falls_back(): + regions = [ + {"min_x": 0.0, "min_y": 0.0, "max_x": 1.0, "max_y": 1.0}, + {"min_x": 1.0, "min_y": 1.0, "max_x": 2.0, "max_y": 2.0}, + ] + keep_first = lambda w: w["min_x"] == 0.0 # noqa: E731 + assert _apply_keep(regions, keep_first) == regions[:1] + assert _apply_keep(regions, None) == regions # 无谓词 → 原样 + assert _apply_keep(regions, lambda w: False) == regions # 全被滤掉 → 退回原集合 + assert _apply_keep([], keep_first) == [] + + +def test_content_predicate_none_without_boxes(): + assert _content_predicate([]) is None + assert _content_predicate(None) is None + + +def test_content_predicate_detects_intersection(): + keep = _content_predicate([(0.0, 0.0, 5.0, 5.0)]) + assert keep({"min_x": 4.0, "min_y": 4.0, "max_x": 9.0, "max_y": 9.0}) is True + assert keep({"min_x": 6.0, "min_y": 6.0, "max_x": 9.0, "max_y": 9.0}) is False + + +@pytest.fixture +def drawing_area_env(monkeypatch): + """打开绘图区最大化,并把等待时间归零(用例不真的 sleep)。""" + from dwg_review import config + + monkeypatch.setattr(config, "DRAWING_AREA_MAXIMIZE", True) + monkeypatch.setattr(config, "DRAWING_AREA_ENTER_WAIT", 0.0) + monkeypatch.setattr(config, "DRAWING_AREA_RESTORE_WAIT", 0.0) + return config + + +def _area_client(responses=None): + """带 set_drawing_area 工具的假客户端。""" + return FakeMcpClient( + tools=STANDARD_TOOLS + [tool_schema("set_drawing_area", "绘图区最大化")], + responses=responses, + ) + + +def test_maximized_drawing_area_calls_max_then_restore(drawing_area_env): + client = _area_client() + ctx = make_ctx(client=client) + with maximized_drawing_area(ctx): + assert client.called_names() == ["set_drawing_area"] # 进入时只 max + assert [c["arguments"] for c in client.called("set_drawing_area")] == [ + {"mode": "max"}, {"mode": "restore"}, + ] + + +def test_maximized_drawing_area_degrades_without_tool(drawing_area_env): + """旧版插件没有该工具 → 静默降级,一次调用都不发。""" + client = FakeMcpClient() + ctx = make_ctx(client=client) + with maximized_drawing_area(ctx): + pass + assert client.calls == [] + + +def test_maximized_drawing_area_disabled_by_config(drawing_area_env): + drawing_area_env.DRAWING_AREA_MAXIMIZE = False + client = _area_client() + ctx = make_ctx(client=client) + with maximized_drawing_area(ctx): + pass + assert client.calls == [] + + +def test_maximized_drawing_area_restores_even_if_body_raises(drawing_area_env): + client = _area_client() + ctx = make_ctx(client=client) + with pytest.raises(RuntimeError): + with maximized_drawing_area(ctx): + raise RuntimeError("boom") + assert client.arguments_of("set_drawing_area") == {"mode": "restore"} + + +def test_maximized_drawing_area_degrades_when_max_fails(drawing_area_env): + """max 调用失败 → 视为没进入最大化,不再发 restore,也不抛异常。""" + client = _area_client(responses={"set_drawing_area": err("no window")}) + ctx = make_ctx(client=client) + with maximized_drawing_area(ctx): + pass + assert [c["arguments"] for c in client.called("set_drawing_area")] == [{"mode": "max"}] + + +def test_maximized_drawing_area_restore_failure_does_not_mask_body(drawing_area_env): + """restore 失败不应掩盖业务异常。""" + client = _area_client() + ctx = make_ctx(client=client) + + def boom(args): + if args.get("mode") == "restore": + raise RuntimeError("restore failed") + return ok_text({}) + + client.responses["set_drawing_area"] = boom + with pytest.raises(ValueError): + with maximized_drawing_area(ctx): + raise ValueError("business error") diff --git a/tests/test_heatmap.py b/tests/test_heatmap.py index fd9a9a2..eeaf7a6 100644 --- a/tests/test_heatmap.py +++ b/tests/test_heatmap.py @@ -171,12 +171,3 @@ def test_grid_stats_without_clusters(): st = hm.grid_stats(_grid([])) assert st["occupied"] == 0 assert "clusters" not in st - - -def test_occupied_boxes_returns_cell_world_boxes(): - # 左下角格(row 1 / col 0)→ 世界 (0,0)-(50,50) - assert hm.occupied_boxes(_grid([(0, 0, 50, 50)])) == [(0.0, 0.0, 50.0, 50.0)] - - -def test_occupied_boxes_empty(): - assert hm.occupied_boxes(_grid([])) == [] diff --git a/verify_drawing_area.py b/verify_drawing_area.py new file mode 100644 index 0000000..35352b7 --- /dev/null +++ b/verify_drawing_area.py @@ -0,0 +1,140 @@ +"""绘图区最大化验证:`set_drawing_area(max)` 到底把绘图区放大了多少,`restore` 能否保真还原。 + +不消耗 VLM token(只调截图工具 + 解码 PNG 量尺寸)。 + +用法: + python verify_drawing_area.py [--wait 3.0] [--restore-wait 1.5] + +前置:CAD 在线(127.0.0.1:8080 监听)且已加载最新插件。不需要 DEEPSEEK_API_KEY。 + +判定: + 1. max 后绘图区面积应显著大于基线(实测约 3.7x); + 2. restore 后窗口矩形 / 最大化状态 / 绘图区尺寸应回到基线(容差 ±2px)。 + +注意:脚本会改动 CAD 窗口位置与干净屏幕状态,结束时一定还原(finally)。 +""" + +import argparse +import ctypes +import sys +import time +from ctypes import wintypes + +from dwg_review.llm_client import image_size_b64 +from dwg_review.mcp_client import McpClient, result_images, result_json + +_TOL = 2 # 还原容差(像素) + + +# -------------------------------------------------------------------------- +# Win32:定位 CAD 主窗口并读取其状态 +# -------------------------------------------------------------------------- +def _user32(): + u = ctypes.windll.user32 + u.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] + u.IsWindowVisible.argtypes = [wintypes.HWND] + u.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.RECT)] + u.IsZoomed.argtypes = [wintypes.HWND] + return u + + +def find_main_hwnd(u): + """枚举可见顶层窗口,取标题含 AutoCAD 且带 [图纸名] 的那个。""" + found = [] + proc_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + + def cb(hwnd, _): + if not u.IsWindowVisible(hwnd): + return True + n = u.GetWindowTextLengthW(hwnd) + if n == 0: + return True + buf = ctypes.create_unicode_buffer(n + 1) + u.GetWindowTextW(hwnd, buf, n + 1) + if "AutoCAD" in buf.value and "[" in buf.value: + found.append(hwnd) + return False + return True + + u.EnumWindows(proc_type(cb), 0) + return found[0] if found else None + + +def win_state(u, hwnd): + rc = wintypes.RECT() + u.GetWindowRect(hwnd, ctypes.byref(rc)) + return (rc.left, rc.top, rc.right, rc.bottom), bool(u.IsZoomed(hwnd)) + + +# -------------------------------------------------------------------------- +# 测量 +# -------------------------------------------------------------------------- +def shot_size(client): + """截一张整图,返回 (宽, 高)。""" + resp = client.call_tool("get_viewport_screenshot", {}) + imgs = result_images(resp) + if not imgs: + return None + return image_size_b64(imgs[0][0]) + + +def _area(sz): + return (sz[0] * sz[1]) if sz else 0 + + +def main(argv=None): + ap = argparse.ArgumentParser() + ap.add_argument("--wait", type=float, default=3.0, help="max 后等待渲染秒数") + ap.add_argument("--restore-wait", type=float, default=1.5, + help="restore 后等待秒数(须 > 插件内 700ms 延迟还原)") + args = ap.parse_args(argv) + + u = _user32() + hwnd = find_main_hwnd(u) + if not hwnd: + print("找不到 AutoCAD 主窗口,请先启动 CAD", file=sys.stderr) + return 1 + print(f"CAD 主窗口: {hwnd:#x}") + + client = McpClient() + rc0, zoom0 = win_state(u, hwnd) + base = shot_size(client) + print(f"[基线] 窗口矩形={list(rc0)} 最大化={zoom0} 绘图区={base}") + + ok = True + try: + info = result_json(client.call_tool("set_drawing_area", {"mode": "max"}), {}) + time.sleep(args.wait) + big = shot_size(client) + rc1, zoom1 = win_state(u, hwnd) + ratio = _area(big) / _area(base) if _area(base) else 0.0 + print(f"[max] 窗口矩形={list(rc1)} 最大化={zoom1} 绘图区={big} " + f"面积={ratio:.2f}x 工具返回={info.get('main_rect')}") + if ratio < 1.5: + print(" !! 绘图区放大不足 1.5x,请检查窗口是否被其它窗口遮挡") + ok = False + finally: + client.call_tool("set_drawing_area", {"mode": "restore"}) + time.sleep(args.restore_wait) + + rc2, zoom2 = win_state(u, hwnd) + back = shot_size(client) + print(f"[restore] 窗口矩形={list(rc2)} 最大化={zoom2} 绘图区={back}") + + # ---- 还原保真度判定 ---- + if zoom2 != zoom0: + print(f" !! 最大化状态未还原: {zoom0} -> {zoom2}") + ok = False + if any(abs(a - b) > _TOL for a, b in zip(rc0, rc2)): + print(f" !! 窗口矩形未还原(容差 {_TOL}px)") + ok = False + if base and back and any(abs(a - b) > _TOL for a, b in zip(base, back)): + print(f" !! 绘图区尺寸未还原: {base} -> {back}") + ok = False + + print("\n结果:", "PASS" if ok else "FAIL") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main())