feat(review): 截屏前最大化绘图区 + 空瓦片过滤覆盖回退路径

新增 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 作为该工具的验收脚本。
This commit is contained in:
gjm committed 2026-10-15 23:44:32 +08:00
1 parent 9c4ab62dc1
commit 1e5bff268a
15 files changed
+657 -50

No files matched your search

+2 -1
View File
@@ -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)。
+45 -3
View File
@@ -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 延迟还原**)。
+1
View File
@@ -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)
+1
View File
@@ -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,
+17 -14
View File
@@ -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)
+13
View File
@@ -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())
-9
View File
@@ -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 = []
+74 -11
View File
@@ -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:
+20
View File
@@ -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": "获取当前图纸的元数据:文件名、单位、布局数量、各空间实体总数。审查流程的第一步。",
+1
View File
@@ -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"
+192
View File
@@ -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 <Windows.h>
#include <string>
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<std::string>();
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)
+13
View File
@@ -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
+138 -3
View File
@@ -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")
-9
View File
@@ -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([])) == []
+140
View File
@@ -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())