Files
cad-agent/dwg_review/annotator.py
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

125 lines
4.4 KiB
Python
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.
"""批注回写:把审查问题标到 CAD 图上(统一落 AI_REVIEW 层)。
只在问题带有 handle 时才回写。产出两类可撤销的副作用:
- created :新建的批注实体(引线等)→ 用 delete_entities 删除
- highlighted :被临时改色的原实体 → 用 highlight_entities(restore=true) 还原
"""
from . import config
from .mcp_client import McpError, McpToolError, result_json
# 引线落点相对实体定位点的偏移(图纸单位)
_LEADER_OFFSET = (30.0, 20.0)
def _pick_point(detail):
"""从实体详情中挑一个用于放引线的点(WCS)。"""
if not isinstance(detail, dict):
return None
bbox = detail.get("bbox")
if isinstance(bbox, (list, tuple)) and len(bbox) == 4:
return [
(float(bbox[0]) + float(bbox[2])) / 2.0,
(float(bbox[1]) + float(bbox[3])) / 2.0,
0.0,
]
for key in ("position", "insert", "center", "start"):
pt = detail.get(key)
if isinstance(pt, (list, tuple)) and len(pt) >= 2:
return [float(pt[0]), float(pt[1]), float(pt[2]) if len(pt) > 2 else 0.0]
return None
def annotate(ctx, issues, *, dry_run=False, log=print):
"""按问题列表回写批注,返回 (created, highlighted) 两个 handle 列表。
离线源(PDF / 图片,`ctx.tools is None`)没有可回写的 CAD → 直接返回空结果。
"""
created = []
highlighted = []
if ctx.tools is None:
log("[批注] 当前为离线源,跳过回写")
return created, highlighted
seen = set() # 同一 handle 只处理一次,避免重复批注
for issue in issues:
handle = issue.get("handle")
if not handle or handle in seen:
continue
seen.add(handle)
text = f"[{issue.get('severity', '?')}] {issue.get('message', '')}"
if dry_run:
log(f"[dry-run] 批注 handle={handle} 层={config.ANNOTATE_LAYER}: {text}")
continue
# 先定位,拿不到坐标就跳过(避免产生无意义的批注)
detail = result_json(ctx.tools.call("get_entity_detail", {"handle": handle}), {})
point = _pick_point(detail)
if not point:
log(f"[跳过] handle={handle} 无法定位坐标")
continue
landing = [
point[0] + _LEADER_OFFSET[0],
point[1] + _LEADER_OFFSET[1],
0.0,
]
try:
res = result_json(
ctx.tools.call(
"add_leader_annotation",
{
"text": text,
"points": [point, landing], # 至少 2 个点,首个为箭头
"text_position": landing,
"layer": config.ANNOTATE_LAYER,
},
),
{},
)
for item in (res or {}).get("created", []):
if item.get("handle"):
created.append(item["handle"])
except (McpError, McpToolError) as exc:
log(f"[批注失败] handle={handle}: {exc}")
continue
# 引线建好后再高亮原实体(失败不影响已创建的批注)
try:
ctx.tools.call("highlight_entities", {"handle": handle, "color": 1})
highlighted.append(handle)
except (McpError, McpToolError) as exc:
log(f"[高亮失败] handle={handle}: {exc}")
if not dry_run:
log(
f"[批注] 新建 {len(created)} 个批注实体、高亮 {len(highlighted)} 个原实体"
f"(层 {config.ANNOTATE_LAYER})"
)
return created, highlighted
def clear(ctx, handles=None, highlighted=None, *, log=print):
"""撤销批注:删除批注实体 + 还原被高亮的实体。"""
if ctx.tools is None:
return
if handles:
try:
ctx.tools.call("delete_entities", {"handles": list(handles)})
log(f"[批注] 已删除 {len(handles)} 个批注实体")
except (McpError, McpToolError) as exc:
log(f"[撤销失败] {exc}")
if highlighted:
try:
ctx.tools.call(
"highlight_entities", {"handles": list(highlighted), "restore": True}
)
log(f"[批注] 已还原 {len(highlighted)} 个实体的颜色")
except (McpError, McpToolError) as exc:
log(f"[还原失败] {exc}")