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:
1 parent
9c4ab62dc1
commit
1e5bff268a
15 files changed
+657
-50
No files matched your search
+2
-1
@@ -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)。
|
||||
|
||||
@@ -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 延迟还原**)。
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": "获取当前图纸的元数据:文件名、单位、布局数量、各空间实体总数。审查流程的第一步。",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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")
|
||||
@@ -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([])) == []
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user