支持直接审查 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 丢图框块,离线侧暂无等价手段),留待后续打磨。
125 lines
4.4 KiB
Python
125 lines
4.4 KiB
Python
"""批注回写:把审查问题标到 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}")
|