Files
cad-agent/ai.memory/project_agent_roadmap.md
T
gjm 6143bb9ab2 feat(review): PDF/图片离线源适配(C1)—— 无需 CAD 的整条审查链路
支持直接审查 PDF 与位图图纸,全链路离线(不连 CAD、不连 8080 端口)。
渲染在内存内完成,复用既有墨迹热力图 + 自适应瓦片规划。

- 新增 dwg_review/sources.py
  - detect_source / plan_file / collect 统一入口
  - PdfReader:pypdfium2 惰性导入,页序即图框序,处理 /Rotate 与 crop 边距
  - ImageReader:世界单位 = 源像素,snap_box 整数对齐保证窗口与像素严格一致
  - ink_grid:纯 PIL 灰度阈值 → 占用网格(不引入 numpy)
- ReviewContext.create(connect_cad=False):离线时 client/tools 均为 None
- 全链路 ctx.tools is None 守卫:review 只读子循环 / inspect_region / annotator /
  checks 的窗口类检查 / 绘图区最大化
- checklist.build_system_prompt(allow_tools=False, offline=True):离线提示词
- __main__:位置参数 source,按扩展名自动判定源类型并决定是否连 CAD
- config 新增 8 个常量(PDF_MAX_PAGES / 文本最小字数 / 各源缩放上限 / 墨迹阈值等)
- tiling.content_predicate 与 heatmap.cell_box 提升为公开函数

验证:
- pytest 457 passed
- verify_pdf_source.py:PDF 23/23、图片 9/9(不需 CAD),含实测瓦片字高像素与
  旋转映射自检(文本区墨迹 0.311 vs 忽略 /Rotate 反向对照 0.145)
- 真实 LLM 端到端:20 页 HVAC 图纸前 3 页跑通,结论具体可核验且未编造 handle

已知局限:页边线在墨迹网格里会把整页连成单簇,瓦片常退化为整页象限(CAD 侧
靠 drop_oversized 丢图框块,离线侧暂无等价手段),留待后续打磨。
2026-10-16 23:07:23 +08:00

372 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: Agent 路线与多源架构(2026-10-16)
description: dwg_review agent 的总目标、多源图纸分层架构(视觉为最大公约数)、工具层与 agent 层的正交分工、多源证据层、截图去 UI、grounding 实测、热力图 + 自适应瓦片规划 + VLM 分辨率标定、绘图区最大化(set_drawing_area)与空瓦片过滤、审查阶段分辨率对齐、agent 侧虚拟工具 inspect_region(放大取证闭环)、几何干涉检查 find_collisions(BVH + 容器过滤)、PDF/图片离线源(pypdfium2 + 墨迹瓦片)、后续路线
type: project
---
# Agent 路线与多源架构
## 总目标(用户 2026-10-15 明确)
实现 **AI 自动识别 + 评审 DWG 图纸**。分阶段:**先 MCP(已完成),再 agent(进行中)**。
最终要支持的图纸来源**不限于自家自定义实体**:
1. 自家软件的自定义实体 DWG;
2. **其他软件出的 DWG**(各种自定义实体);
3. 将来 **PDF** 图纸;
4. 将来**纯图片**图纸。
## 架构分层(用户认可)
**核心洞察:视觉模型(VLM)是"最大公约数"**。DWG 自定义实体 / PDF / 图片本质是
"拿不到结构化数据时靠视觉理解"的同一件事——**任何图纸只要能渲染成图,VLM 就能审**。
所以结构化数据(ARX 读普通实体)是**增强**,视觉是**兜底**。
```
Agent 推理层(审查逻辑 / 清单 / 规则)
▲ 统一 evidence schema
感知适配层(能力从高到低降级)
CAD结构化(ARX) → CAD几何 → 离线解析(DWG/PDF) → 视觉(VLM)
```
- **MCP 工具层只管 CAD**(进程内能力);**PDF / 图片在 agent 侧**(PyMuPDF / Pillow),
**不塞进 MCP**、不编 DLL。
- **统一证据模型 + 多源适配 + 能力降级**;agent 推理层不关心来源格式。
**How to apply**:新增"支持某格式"时,优先在 agent 侧感知层加适配器;只有"CAD 进程内的
正交感知能力"才考虑加 MCP 工具。
## 第一步已落地(2026-10-15,提交 `1c9fc72`)
**取证层多源化 + 视觉多图**:
- 新增 `dwg_review/evidence_model.py`:统一证据模型(`source_type` +
`images:[{b64,mime,label}]`),访问器 `evidence_images()` 兼容旧 `image_b64/image_mime`
单图字段(历史 evidence.json 仍可读)。
- `phases/evidence.py`:`collect()` 多源分发 seam(当前仅 CAD)、`capture_images()`
整体 + 等分象限多图(结束**复位视口**)、`sub_windows()`。
- 默认 `EVIDENCE_DETAIL_TILES=4` / `EVIDENCE_MAX_IMAGES=5`(每图框 5 图,**默认开启**)。
- 实测:真实 Plant 3D 图纸上多图让审查更细(多报出线型误用 `CENTER2`、图签块属性为空、
按 handle 定位的图层混用等)。
## 截图去 CAD 界面元素(2026-10-15,提交 `f8bd12d`)
`get_viewport_screenshot` 原先截 `adsw_acadDocWnd()` 客户区,含视口控件、ViewCube、滚动条,
污染截图并**误导 VLM**(曾把视口标签当成"需放大的内容")。已修复:
- 改用 **`acedGetAcadDwgView()`** 只截图形区(排除滚动条/状态栏)。
- 截图期间临时置 **`NAVVCUBEDISPLAY=0`**(关 ViewCube)与 **`VPCONTROL=0`**(关视口控件),
用 RAII 守卫(`TempSysVarOff`)自动恢复原值,**不影响用户 CAD 界面设置**;
配合 `RedrawWindow(RDW_UPDATENOW)` 同步重绘。
- 实测:三项 UI 均消失,VLM 不再提界面元素。
**残留(无解,已知)**:绘图区右上角仍有**绘图窗口**的最小化/最大化/关闭按钮——由 AutoCAD
画在图形区内的固有 UI,**无系统变量可关**。它很小且在角落,实测未影响 VLM。
(区别于应用窗口的按钮;用户最初要清的是 视口控件/ViewCube/滚动条,不含此项。)
## grounding 实测(2026-10-15)
用 `verify_grounding.py` 拿真实截图问 `deepseek-flash`"哪里要放大":**grounding 可用**——
给出的归一化坐标**区域级定位准确**(放大后确实套住目标,如图签栏、图框角),
但**语义描述略有偏差**(把图签栏说成"状态栏")。结论:可支撑"按区域放大",但**位置对、语义别全信**。
## 后续路线(未做)
1. **VLM 主动放大(自适应取证)**:**2026-10-16 已落地**(见下节 `inspect_region`)。
剩余可选打磨:把「看清没」的判定进一步形式化(目前靠 agent 用标定数学**构造保证**可读 +
回合上限 + 截图预算兜底),以及 grounding→WCS 之外的其它指认方式。
2. **PDF / 图片源**:**2026-10-16 已落地**(见下节「C1:PDF / 图片离线源」)。
剩余:真实 PDF 图纸量大时按需增量取页;图片源的低分辨率原图无法放大(设计如此)。
3. **审图规范接入**:清单升级为规范库。
## 热力图 → 自适应瓦片规划 → VLM 分辨率标定(2026-10-15,进行中)
**动机**:固定「整体 + 2x2 象限」截图对 A0/A1 大图幅读不清。用户拍板:**驱动信号是实体分布
(热力图),不是文字**(有的图没有文字),且**实际工况下实体占据图框大部分**,稀疏/稠密都要能处理。
用户方案:图框类型 → 纸张真实尺寸 + 比例 → CAD 世界尺寸 → 换算像素 → 知道 VLM 分辨率 →
算出**一个图框要截几张、截哪里**。
**用户已拍板的关键决定**:
- 热力图在 **Python 侧(agent 层)** 算(派生量,工具层只给原始事实)。密集图若受
`query_entities` 的 `limit≤1000` / 帧上限拉不全 → **给现有 `query_entities` 加 `brief` 模式**
(只回 handle/type/bbox、放开 limit),**而不是新增"热力图工具"**。
- `CollisionDetection.h/cpp`(用户写的 BVH)**本步不用**,留作后续**几何相交/干涉检查**引擎。
其按质心中位分割 → 节点框**可能重叠**,不适合铺屏瓦片;瓦片切分用四叉树/网格(空间不相交)。
(**2026-10-16 已落地**:见下文「C2:几何干涉检查 `find_collisions`」——重写了 header-only 2D BVH。)
- 标定判据:**同时测「读出文字」与「分辨线细节」**;标定字高 **200 图形单位**(1:100 = 纸面 2mm,国标最小)。
**已落地(提交 `9b8a232` 热力图;`cba05b0` 瓦片规划 + 离线标定)**:
- `dwg_review/heatmap.py`(纯函数):`entity_boxes` / `build_grid`(counts+coverage)/
`cluster_cells`(4-连通,可选膨胀)/ `grid_to_windows` / `grid_stats` / `render_overlay`。
**`drop_oversized`**:丢掉 bbox 占满图框的实体(实测每框恰 1 个,即图框块),否则热力图全满。
- `dwg_review/tiling.py`(纯函数):`frame_scale`(`sheet_scale>0` 用之;否则由 bbox/纸张**反算**;
否则 default)/ `world_per_px` / `is_text_type` / `plan_regions`(文字簇优先、空白区不出图、
`max_tiles` 截断、窗口夹到图框)。
- `phases/evidence.capture_images`:先截整体图并**测绘图区像素** → 规划成功用规划窗口,
否则退回 `sub_windows`(行为零变化);**任一信封 `total>returned`(截断)→ 退回固定策略**。
- `verify_heatmap.py`(不耗 token,出 PNG + bbox 缓存)、`verify_vlm_resolution_offline.py`、
`verify_text_resolution.py`(CAD 实测)。
**标定结论**:
- **该模型无法可靠计数线条**(6 条线在 2~10px 间距下答 4/10/4/5/4/4)→ 线条判据改用
**「1 条 vs 2 条」二选一**。
- **可读下限与字体强相关**:离线合成(Consolas 抗锯齿填充字体)**12px**(白底 10px);
真实 CAD **单线体 .shx(描边字体)6px 就能读对**(3/3,两轮)。
- 真实 CAD 线条:**2/3/4px 失败,5/8/12px 成功** → R_line ≈ **5px**。
- **标定必须用 ACI 7(白/黑自适应)**:实测**红字(ACI 1)12px 完全读不出**——否则把
"对比度不足"误当"分辨率不足"。
- 实测绘图区(**窗口未最大化时**)**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=...)` 剔除**空瓦片**(实测有效)。
**已知局限**:本图纸的文字在**块 / 自定义实体**内,`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[]`(`set_drawing_area` 加入时 **27** 个;后加 `find_collisions` 现为 **28** 个),
工厂 `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 延迟还原**)。
## 审查阶段分辨率对齐 + 放大取证闭环(2026-10-16,本轮)
**发现的缺口**:`maximized_drawing_area` 原先**只包住取证循环**,审查阶段没被包住 →
LLM 在受控子循环里 zoom + 截图拿到的是**未最大化的 2110×462**(正是上一轮刚花力气解决的
低分辨率问题,在**最需要放大看细节的审查阶段**又出现了);且审查中的 zoom **不复位视口**,
跑完把用户的 CAD 留在最后一次放大处。
**三项改动**:
1. **`__main__.run_review`**:把 `with evidence_phase.maximized_drawing_area(ctx)` 从
「只包取证」扩展到「**包取证 + 审查**」(两者都要截图);`report`/`annotate` 留在 with 之外。
2. **`phases/review.py`**:新增 `_VIEW_TOOLS = {zoom_to_window, zoom_to_entity}`;
`review_frame` 拆成外壳 + `_review_frame`,外壳 `try/finally` 里**仅当确实动过视口**才
`_reset_viewport`(复位到该图框整幅视图 `zoom_to_window(win, margin=0.02)`)。
—— 只在动过时复位,既省一次调用,也不影响"纯查结构化数据"的路径。
3. **`checklist.build_system_prompt`**:把「可调用只读工具」改写成**明确的放大取证协议**:
截图看不清时用 `zoom_to_window`(坐标由图框/实体 bbox 算)或 `zoom_to_entity`,
再 `get_viewport_screenshot` 复核;并约束**不要为看图而反复放大**。
**实测(真实图纸,1 图框,`verify_review_maximized.py`)**:
- 取证阶段采样 `[-4,-2,3444,1442]`、**审查阶段采样同样是 `[-4,-2,3444,1442]`**(最大化态),
结束**精确还原** `[68,0,2316,960]` → 三项全 PASS。
- 子循环里 LLM **真的连发 8 组 `zoom_to_window`+`get_viewport_screenshot`**
(提示词生效,"看不清→放大重看"闭环跑通)。
**同一轮发现的次生问题**:`_MAX_OBS_IMAGES` 原为硬编码 **3**,而模型一轮内会发 8 次截图
→ **拍了 8 张只回灌 3 张**(其余被"已省略"占位)。改为配置项 `REVIEW_OBS_IMAGES`
(默认 **6**,`config.py`),并把预算耗尽时的占位文案改成**明确告知模型"不要再请求截图,
请给出结论"**(原先只说"已省略")。实测模型截图次数不变(8),但**能看到 6 张而非 3 张**。
## A 收尾:agent 侧虚拟工具 `inspect_region`(2026-10-16)
**要解决的三个缺口**:① 放大**没复用标定/瓦片数学**(LLM 一次 zoom 只出一张,宽区域仍读不清);
② 「看清没」由 **VLM 自评**(易过度自信/幻觉);③ 只能按 handle / 手算 bbox 指认,
**图上看到但没 handle** 的东西指认不了。
**核心思路**:把「放大」升级成一个**语义化工具**——LLM 只指认「要看哪里」,
由 agent 复用 `tiling.py` 的标定数学算出该切几张、每张覆盖多大,**用构造保证可读**。
**新增 `dwg_review/inspect.py`**(agent 侧,**不编 DLL**):
- `TOOL_SCHEMA`:`inspect_region(target, purpose)`。`target` 三形态任选其一——
`{handle}` / `{window}` / `{label, cx, cy, span}`;`purpose` = `text`(8px) / `line`(6px)。
- `InspectState`:单图框内的「截图标签 → **世界可见范围**」登记表。初始登记取证阶段的
`overall`/`tile-*`,每次放大再追加 `inspect-1`、`inspect-2`… → **LLM 可用上一轮返回的标签
继续下钻**(逐级放大)。
- `resolve_target` / `_point_box`:`label+cx+cy` → WCS。**cy 是图像坐标(上→下),
世界 y 必须翻转**(`y = y2 - cy*H`)——最容易写错的一处,单测专门钉住。
- `plan_tiles`:复用 `tiling.world_per_px` + `tiles_for_box` + `subdivide`。
超出 `INSPECT_MAX_TILES`(默认 4)→ **硬失败**并给出「缩小 target / 分多次请求」的具体指引
(不静默降级,否则等于给一堆读不清的图);拿不到比例/像素 → 退回单张并如实告知。
**关键几何:`tiling.visible_box`**——CAD 的 `_.ZOOM _W` 是**等比适配(不拉伸)**,
指定窗口按视口宽高比放大到刚好装下,多余一维露出更多内容。所以「截图上的归一化坐标」
要准确映射回 WCS,必须按 `max(W/ew, H/eh)` 反算真实可见范围,**不能**直接用送进去的窗口。
(`zoom_to_window` 返回的 `view` 是**加过 margin 的窗口**,不是可见范围,别混用。)
**证据层配套**:`evidence_model.make_image(..., window=)` 记录每张图的可见世界范围;
`capture_images` 用 `view_px`(overall 图的原始像素)+ `visible_box` 逐张登记。
**子循环接入**(`phases/review.py`):`_run_readonly_tool` 在**只读白名单校验之前**拦截
`inspect_region`(它不在 `READ_ONLY_TOOLS` 里,内部只调白名单内的只读 MCP 工具);
`_review_tools` 启用时用 `inspect_region` **取代**三个裸视口工具(`zoom_to_window` /
`zoom_to_entity` / `get_viewport_screenshot`)——一个语义化入口比三个裸工具更好用;
`inspect_region` 计入 `_VIEW_TOOLS` → 结束时复位视口。
配置:`INSPECT_ENABLED`(True) / `INSPECT_MAX_TILES`(4)。
**端到端实测(真实图纸 1 图框,`verify_review_maximized.py`)**:
- 取证与审查两阶段均在最大化态、结束精确还原 → 4 项全 PASS。
- LLM 的 11 次取证调用里 **2 次是 `inspect_region`**,且**都显式给了 `window` + `purpose:"text"`**:
`x[20000,115000] y[-6500,3000]` 与 `x[-4000,30000] y[-6500,3000]`——A0 图框
(`y[-6434,77666]`)的**底部横条**,即**标题栏**所在。模型自己把范围收窄到横条再放大。
- **验收口径达成**:报告报出标题栏**乱码/占位字符 `???????????????`**,
而 `get_text_content` 对全图返回 **0 条文字**(本图文字全在块/自定义实体内)——
即**块内文字是被视觉放大读出来的**,不是结构化数据给的。
## C2:几何干涉检查 `find_collisions`(2026-10-16)
**方向选择**:用户拍板「C 两个都做,C2 先(BVH → MCP 工具)」;并说明目录里的
`CollisionDetection.h/cpp` 是**参考例子**(可用可重写)。分析后:其中纯 ObjectARX 的
`AABB/EntityProxy/BVHTree`(约 1~367 行)可移植,其余(`needCD` 领域过滤 + MFC 对话框)
依赖用户 App 私有头文件(不在仓库),故**只重写 BVH 内核**。
**新增(C++,`src/cad_mcp_plugins/`)**:
- `bvh.h`:header-only 2D BVH(`namespace dwg_bvh`)。质心中位分割(取较长轴,
`std::nth_element`),叶 ≤ `kLeafSize=4`;`SelfPairs` 枚举相交对(叶内 `i<j` + 子树交叉)。
- `find_collisions.cpp`:`mcp_Tool_FindCollisions`,`EXPORT_MCP_TOOL` 导出。
参数:`window/layer_filter/limit/offset`(通用)+ `min_overlap` / `narrow` /
`include_same_layer` / `exclude_oversized` / `exclude_container_deg` / `exclude_container_frac`。
- **`ratio` = 逐轴重叠比之积**;某轴重叠比 = 该轴重叠长 / 两实体该轴**较短**长,
任一方该轴退化为 0 时取 1.0。于是共线相接→0、横管穿阀门→≈1。
- 按 `ratio` 降序 + 分页;`narrow=true` 只对**返回页**跑 `AcDbEntity::intersectWith` 细筛(附 `exact`)。
- 返回 `{total, returned, offset, entity_count, items[], excluded_containers[]}`。
**关键调参发现:容器(图框块)过滤必须用「重叠度」而非「面积」**。
初版用面积(`exclude_oversized` 相对**实体并集**)——3 张图框并排在模型空间里,
union 面积极大,图框只占 27%/13%/7%,**全部漏过** → 全模型 86 实体产生 **163 对候选**,
top 全是 `INSERT/40B × 一切` 且 ratio=1.0(图框完全包住小实体),`min_overlap=0.99` 仍剩 83 对。
实测重叠**度**分布:**图框 deg=35/25/23,其余实体全部 deg≤3**(分离极干净)。
故新增容器过滤:`deg >= max(exclude_container_deg=8, exclude_container_frac=0.25 × 其他实体数)`
→ 剔除其全部配对并在 `excluded_containers` 里**如实报告**(含 reason=oversized/container)。
**与图幅/缩放无关**,窗口态与非窗口态都生效。
**实测(真实 Plant 3D 图纸 `agent_test.dwg`,86 实体,`verify_collisions.py` 17/17 PASS)**:
- 全模型候选 **163 → 80**,且 80 对**全部 ratio=0.000**(仅管线接口相接);
`min_overlap=0.5` → **0 对**;逐图框(window)同样 0 对 → 本图**无真实干涉**(真阴性)。
- 容器过滤剔除 3 个 INSERT(deg 35/25/23,reason=container)。
- `narrow` 细筛:top-5 里 **4 条 `exact=true`**(接口处 pipe/fitting 真的几何相交)——
与 ratio=0.0 一致:**相交但未深度重叠**。
- **注入式正例**(`--inject`,画 2 个深重叠矩形于图框外 → 应报出 → 删除):
报出 `ratio=0.902`(= (380/400)²,与理论一致),清理 2/2 成功。
**agent 侧接入**(Python):
- `cad_tools.READ_ONLY_TOOLS` 加 `find_collisions` → LLM 受控子循环可调用(写类判定不受影响)。
- `checks.check_collisions` + `run_deterministic_checks`:受 `CHECK_COLLISIONS` 开关控制,
**默认关闭**(AABB 相交噪声大,平面图里管线穿墙/引线跨图元属正常相交),
改由 LLM 按「干涉/碰撞」清单条目**按需**调用;`COLLISION_MIN_OVERLAP` 默认 0.5。
产出的 issue `category="干涉"`(清单新增类别,输出枚举同步加「干涉」)。
- `checklist.py` / `review_checklist.json`:新增「干涉/碰撞」类别(severity=high),
提示词写明「先 `find_collisions` 拿候选 → 对可疑 handle 用 `inspect_region` 目视确认,
**AABB 相交 ≠ 真干涉**」。
**端到端实测(`python -m dwg_review --frames 1`)**:清单显示 **6 类**(含干涉/碰撞);
LLM 在受控子循环里**主动调用** `find_collisions {'min_overlap': 0.2, 'narrow': True,
'limit': 60, 'window': <图框>}`,并结合 `inspect_region`;最终**未误报干涉**(正确真阴性)。
**待办**:无(C1 / C2 均已完成)。
## C1:PDF / 图片**离线源**(2026-10-16)
**用户拍板的关键决定**:
- 支持 **PDF + 图片**两种源;PDF 先做,图片「顺手做了」。
- **完全离线,不连 CAD**(`connect_cad=False`)。
- **不做 OCR**:「都有 VLM 了,还 OCR 实在是画蛇添足」。
- **文本抽取只是次要补充**:国标图纸的文字多为 **SHX 描边**(导出成线条),文本层几乎为空
(实测该暖通 PDF:正文页 **0 个文本矩形**;有文本层的是目录页/表格页)。
- **PDF 在内存渲染,复用既有瓦片规划**能力。
- 后端选 **pypdfium2**(Apache-2.0/BSD、`py3-none-<platform>` wheel 与 Python 版本无关、
~4MB)而非 PyMuPDF(**AGPL-3.0** 有商业约束 + cpXX wheel)。文本抽取已降级为次要,
瓦片规划改用**像素墨迹密度**而非矢量路径,故 pypdfium2 足够。
**新增 `dwg_review/sources.py`(agent 侧,不编 DLL)**:
- 世界坐标系统一(都 y 向上,与 `heatmap`/`tiling` 一致):**PDF = 页内点**(已应用 `/Rotate`)、
**图片 = 源像素**。故 `window`/`bbox` 与 CAD 侧同形,直接复用 `tiling`/`heatmap` 纯函数。
- `detect_source(path)`(扩展名 → cad/pdf/image)、`plan_file(ctx)`(每页一个「图框」,
**保持原始页序**——不能再按 bbox 重排)、`collect(ctx, index, unit)`(同形 evidence)。
- **墨迹网格 `ink_grid(image, box)`**(纯 PIL,无 numpy):灰度 → 缩略 → 阈值二值化 →
`Image.BOX` 块均值降到网格 → 与 `heatmap.build_grid` **同形**的 grid + 占用格 bbox。
于是 `heatmap.cluster_cells` → `tiling.plan_regions` **原样复用**。
- **每张瓦片的渲染倍率由瓦片世界尺寸反推**:`zoom = IMAGE_MAX_EDGE / max(w,h)`,夹到源上限
(PDF `PDF_TILE_MAX_ZOOM`;图片 `IMAGE_MAX_ZOOM=1` **不放大**)。这与 `plan_regions` 的
`s_text`/`s_line` 是**同一套数学**(`zoom = 1/s`):规划出的文字簇瓦片恰好落在
`zoom_text = TEXT_READABLE_PX / (TEXT_HEIGHT_MM · PT_PER_MM)`。
- `PdfReader` / `ImageReader` 读取器(PDF 惰性 import pypdfium2);`open_reader/close_reader`
把读取器缓存在 `ctx.source_reader`(避免每页重复打开)。
**pypdfium2 的关键 API 事实(踩过的坑)**:
- `page.render(scale, rotation, crop)` 的 **`crop` 是「要裁掉的边距」(left,bottom,right,top),
不是绝对矩形**,单位是点、且在**旋转后**的页面上生效 → `crop_of(box, W, H) = (x0, y0, W-x1, H-y1)`。
写成绝对矩形会报 `Crop exceeds page dimensions`(或裁错区域)。
- `page.get_size()` **已应用 `/Rotate`**,`page.get_mediabox()` 未应用。
- **`tp.get_rect(i)` 前必须先调一次 `tp.count_rects()`**,否则 `PdfiumError: Failed to get rectangle`。
- 文本 rect 是**未旋转**坐标 → `/Rotate 90` 映射 `(x,y) → (y, mw - x)`(**实测验证**:
文本区墨迹命中率 0.335 vs 其它假设 ≤0.064,并目视确认「暖通专业图纸」正向)。
- 退化 bbox(宽/高 ≤ 0)会让 crop 边距之和等于页尺寸 → 提前拒绝,别交给 pdfium。
**离线链路打通(守卫 `ctx.tools is None`)**:
`context.ReviewContext.create(connect_cad=False)` → `client/tools` 均为 None;
`phases/evidence.maximized_drawing_area`、`phases/review._review_tools`/`_run_readonly_tool`/
`_reset_viewport`、`checks.run_deterministic_checks`、`annotator.annotate`/`clear` 全部先判 None。
`checklist.build_system_prompt(offline=True)` 换成**纯视觉提示词**(无工具协议、明确要求
**不要输出 handle**、说明文本层为空属正常)。
**CLI**:`python -m dwg_review <source>`(位置参数);`.pdf`/图片 → 离线模式,
`.dwg/.dxf` → 提示后按 CAD 在线模式(Python 打不开 DWG),未知格式/文件不存在 → exit 2。
`--list-tools`/`--check`/`--clear`/`--restore` 在离线源下被拒绝。
**实测(`verify_pdf_source.py`,20 页暖通图纸)**:
- PDF 前 4 页 **20/20 PASS**;图片源(由该 PDF 第 3 页渲染成 2717×1945 PNG)**9/9 PASS**。
- **分辨率自检(实测而非推导)**:每张瓦片的「纸面字高像素」≥ 线条下限 6px,
典型瓦片达 **8.5~13.9px**(文字档 8px);整体图 ~4.3px(整页缩略,**设计如此**,细节由瓦片负责)。
- **旋转自检自动化**:文本区墨迹 0.311 vs 整页平均 0.012;反向对照(忽略 `/Rotate`)0.145 → 映射正确。
- **真实 LLM 端到端**(`python -m dwg_review <pdf> --frames 3`):VLM **读懂了真实工程图**——
报出「设备标注框叠压家具/洁具/门扇弧线」「设备平面定位尺寸缺失」「标高以『梁底』代替数值不统一」
「1-1 剖面室外机未表达与墙柱间距」「材料表单重/共重两列全空」「审定未签署」等**具体且可核验**的问题,
且**未编造 handle**(离线证据无 handle,全部 issue 无 handle 字段)。
**新增配置**:`PDF_MAX_PAGES`(0=全部) / `PDF_TEXT_MIN_CHARS`(2) / `PDF_OVERVIEW_MAX_ZOOM`(4.0) /
`PDF_TILE_MAX_ZOOM`(6.0) / `IMAGE_MAX_ZOOM`(1.0) / `INK_ANALYSIS_MAX_EDGE`(1024) /
`INK_THRESHOLD`(200) / `INK_MIN_CELL_FRAC`(0.01);模块级 `PT_PER_MM = 72/25.4`。
**已知局限**:
- 图框/页边线是**细线**,在墨迹网格里把整页连成**一个大连通簇** → 瓦片常退化为
「整页 2×2 象限 + 少量密集小岛」。「空白区不出图」的收益因此打折(CAD 侧靠
`drop_oversized` 丢掉图框块,离线侧暂无等价手段)。成本由 `TILE_MAX_COUNT`/`EVIDENCE_MAX_IMAGES` 兜底。
- 图片源不能放大(`IMAGE_MAX_ZOOM=1`):低分辨率扫描件无法凭空提升可读性。
- 文本层对国标图纸基本无用(正文页 0 个文本矩形),**这是预期**,主力仍是 VLM 看图。
- 依赖新增:`requirements.txt`(openai / Pillow / pypdfium2)。