feat(review): 几何干涉检查 find_collisions(BVH 粗筛 + 容器过滤) #17
No files matched your search
+2
-1
@@ -52,6 +52,7 @@ python verify_stdio_bridge.py # stdio ↔ TCP 桥(协议协商 / 转发
|
||||
python verify_annotate.py # 批注回写链路
|
||||
python verify_drawing_area.py # 绘图区最大化:基线 → max → restore 全回位(实测 3.68x)
|
||||
python verify_review_maximized.py # 审查全链路:取证+审查两阶段均在最大化态、结束精确还原;记录 LLM 取证工具调用
|
||||
python verify_collisions.py # find_collisions:全模型/逐图框粗筛、容器过滤、阈值/分页/细筛;--inject 注入干涉正例后清理
|
||||
```
|
||||
|
||||
这些脚本通过 TCP 连接 `127.0.0.1:8080` 收发 JSON-RPC。
|
||||
@@ -92,7 +93,7 @@ python verify_review_maximized.py # 审查全链路:取证+审查两阶段
|
||||
|
||||
### 配置与产物布局
|
||||
|
||||
- 运行时配置:`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` 是模板(文件名带连字符,非运行时文件)。
|
||||
- 运行时配置:`mcp_config.json`,必须与 `.arx` 同目录。示例见 `bin/Debug-R230/mcp_config.json`(当前注册 28 个工具,含 `zoom_window_normalized`、`zoom_extents`、`get_viewport_screenshot`、`set_drawing_area`、`find_collisions` 等);`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)。
|
||||
|
||||
@@ -29,11 +29,16 @@ type: feedback
|
||||
- **Why:** 用户(2026-10-14)明确要求:「以后遇到 cad 没有运行的情况,就直接打开,
|
||||
运行你的测试就行,我已经设置好了自动加载」。自动加载确实已配好。
|
||||
- **How to apply:** PowerShell 里
|
||||
`Start-Process "C:\Program Files\Autodesk\AutoCAD 2020\acad.exe"`,再轮询 8080
|
||||
(实测约 10 秒内监听)。自动加载链路:`bin/Debug-R230/acad.rx`(内容一行
|
||||
`cad_mcp_frame.arx`)+ 该目录已在 CAD 的搜索/信任路径中。
|
||||
**注意**:CAD 默认打开的是**空模板**(0 图元、无图框),端到端审查会因
|
||||
「未识别到任何图框」提前退出;要跑完整审查需另开一张真实图纸。
|
||||
`Start-Process "C:\Program Files\Autodesk\AutoCAD 2020\acad.exe" -ArgumentList '/p','SHS-ENVI2026','e:\desktop\agent_test.dwg'`,
|
||||
再轮询 8080(实测**数秒内**即监听,AutoCAD 2020 热启动很快)。自动加载链路:
|
||||
`bin/Debug-R230/acad.rx`(内容一行 `cad_mcp_frame.arx`)+ 该目录已在 CAD 的搜索/信任路径中。
|
||||
**`/p SHS-ENVI2026` 不可省**——否则 Plant 3D 自定义实体(`AECDBEW*`)**不显示**,
|
||||
截图里是空的;`e:\desktop\agent_test.dwg` 是标准真实测试图纸(3 图框 / 86 实体)。
|
||||
**注意**:不带图纸参数时 CAD 打开的是**空模板**(0 图元、无图框),端到端审查会因
|
||||
「未识别到任何图框」提前退出。
|
||||
**重建插件后必须先 `Stop-Process acad -Force`**(DLL 被占用会 LNK1168),再重启。
|
||||
另:AutoCAD 2019(R23.0)也装了同名 profile,但**实际使用的是 2020(R23.1)**——
|
||||
MRU 里 `E:\desktop\agent_test.dwg` 属 2020。
|
||||
|
||||
## 调试用 R230 + Debug,不用 Release
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: Agent 路线与多源架构(2026-10-15)
|
||||
description: dwg_review agent 的总目标、多源图纸分层架构(视觉为最大公约数)、工具层与 agent 层的正交分工、多源证据层、截图去 UI、grounding 实测、热力图 + 自适应瓦片规划 + VLM 分辨率标定、绘图区最大化(set_drawing_area)与空瓦片过滤、审查阶段分辨率对齐、agent 侧虚拟工具 inspect_region(放大取证闭环)、后续路线
|
||||
description: dwg_review agent 的总目标、多源图纸分层架构(视觉为最大公约数)、工具层与 agent 层的正交分工、多源证据层、截图去 UI、grounding 实测、热力图 + 自适应瓦片规划 + VLM 分辨率标定、绘图区最大化(set_drawing_area)与空瓦片过滤、审查阶段分辨率对齐、agent 侧虚拟工具 inspect_region(放大取证闭环)、几何干涉检查 find_collisions(BVH + 容器过滤)、后续路线
|
||||
type: project
|
||||
---
|
||||
|
||||
@@ -93,6 +93,7 @@ Agent 推理层(审查逻辑 / 清单 / 规则)
|
||||
(只回 handle/type/bbox、放开 limit),**而不是新增"热力图工具"**。
|
||||
- `CollisionDetection.h/cpp`(用户写的 BVH)**本步不用**,留作后续**几何相交/干涉检查**引擎。
|
||||
其按质心中位分割 → 节点框**可能重叠**,不适合铺屏瓦片;瓦片切分用四叉树/网格(空间不相交)。
|
||||
(**2026-10-16 已落地**:见下文「C2:几何干涉检查 `find_collisions`」——重写了 header-only 2D BVH。)
|
||||
- 标定判据:**同时测「读出文字」与「分辨线细节」**;标定字高 **200 图形单位**(1:100 = 纸面 2mm,国标最小)。
|
||||
|
||||
**已落地(提交 `9b8a232` 热力图;`cba05b0` 瓦片规划 + 离线标定)**:
|
||||
@@ -145,7 +146,8 @@ Agent 推理层(审查逻辑 / 清单 / 规则)
|
||||
- **实测收益(屏幕 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`。
|
||||
- 工具注册:`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 的受控子循环**不能**动用户界面。
|
||||
|
||||
@@ -244,3 +246,56 @@ LLM 在受控子循环里 zoom + 截图拿到的是**未最大化的 2110×462**
|
||||
- **验收口径达成**:报告报出标题栏**乱码/占位字符 `???????????????`**,
|
||||
而 `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(PDF / 图片源适配,见「后续路线」第 2 条)仍未做。
|
||||
@@ -48,6 +48,7 @@ type: reference
|
||||
| `verify_text_resolution.py` | **CAD 实测**标定(临时加字 + 平行线,测完 `delete_entities` 清理);`--keep` 保留 |
|
||||
| `verify_drawing_area.py` | 绘图区最大化验收:基线 → `set_drawing_area(max)` → `restore`,断言窗口矩形/最大化态/绘图区尺寸全回位(实测 3.68×) |
|
||||
| `verify_review_maximized.py` | 审查全链路验收:在 `collect`/`review_frame` 入口各采一次窗口矩形,断言**取证与审查两阶段都在最大化态**、结束精确还原;并统计子循环 zoom/截图次数 + 记录 LLM 实际调用的取证工具(含虚拟工具 `inspect_region`) |
|
||||
| `verify_collisions.py` | `find_collisions` 验收:工具注册 / 全模型与逐图框粗筛 / **容器过滤**(图框块剔除)/ `min_overlap` 阈值 / 分页 / `narrow` 细筛 / `--inject` 注入干涉正例(画 2 个深重叠矩形 → 应报出 ratio≈0.902 → 清理)。**只读**(`--inject` 段除外,会自动清理) |
|
||||
|
||||
> 历史:根目录 `test.py` / `test_autoagent.py` / `countOfCircle.py` / `agent_loop.py` /
|
||||
> `dwg_review_agent.py` 五个遗留调试脚本已于 2026-10-14 删除(前三个含硬编码 API Key,
|
||||
|
||||
@@ -17,6 +17,7 @@ READ_ONLY_TOOLS = frozenset(
|
||||
"detect_drawing_frames",
|
||||
"find_duplicate_entities",
|
||||
"find_zero_length",
|
||||
"find_collisions",
|
||||
"zoom_to_window",
|
||||
"zoom_to_entity",
|
||||
"get_viewport_screenshot",
|
||||
|
||||
@@ -15,6 +15,8 @@ DEFAULT_CHECKLIST = {
|
||||
"checks": ["关键尺寸缺失", "标注值异常"]},
|
||||
{"title": "几何", "severity": "medium",
|
||||
"checks": ["零长度/退化实体", "重复重叠实体", "未闭合轮廓"]},
|
||||
{"title": "干涉/碰撞", "severity": "high",
|
||||
"checks": ["构件/管线间非预期侵入", "同位置重复叠画", "引线/填充异常遮挡"]},
|
||||
{"title": "图面整体", "severity": "low",
|
||||
"checks": ["图面完整性", "内容是否超出图框"]},
|
||||
],
|
||||
@@ -67,7 +69,11 @@ def build_system_prompt(checklist, *, allow_tools=True):
|
||||
"若它提示区域过大,请缩小 `target`(或用 `span` 缩小范围)后重试。",
|
||||
"",
|
||||
"结构化数据不足以判断时:用 `get_entity_detail` / `find_duplicate_entities` /",
|
||||
"`find_zero_length` / `get_layers` 等查证后再下结论。",
|
||||
"`find_zero_length` / `find_collisions` / `get_layers` 等查证后再下结论。",
|
||||
"排查「干涉/碰撞」时,先调 `find_collisions` 拿到**包围盒相交候选**"
|
||||
"(按重叠比降序,`min_overlap` 可调阈值),",
|
||||
"再对可疑的候选 handle 用 `inspect_region` 放大目视确认——"
|
||||
"**AABB 相交不等于真干涉**,务必核实后再下结论。",
|
||||
"",
|
||||
]
|
||||
|
||||
@@ -84,7 +90,7 @@ def build_system_prompt(checklist, *, allow_tools=True):
|
||||
' "summary": "<该图框的一句话整体结论;若未发现问题,写“未发现明显问题”>",',
|
||||
' "issues": [',
|
||||
" {",
|
||||
' "category": "<图层/文字/标注/几何/图面整体 之一>",',
|
||||
' "category": "<图层/文字/标注/几何/干涉/图面整体 之一>",',
|
||||
' "severity": "<high|medium|low>",',
|
||||
' "message": "<问题描述,简洁具体>",',
|
||||
' "handle": "<该问题对应的实体句柄;无法定位到具体实体时省略此字段>"',
|
||||
|
||||
+47
-1
@@ -1,4 +1,5 @@
|
||||
"""确定性检查:MCP 工具(find_zero_length / find_duplicate_entities)+ 纯 Python 派生。
|
||||
"""确定性检查:MCP 工具(find_zero_length / find_duplicate_entities / find_collisions)
|
||||
+ 纯 Python 派生。
|
||||
|
||||
产出的 issue 带 `source="deterministic"`,其 handle 来自工具/数据本身,
|
||||
因此被视为**可信**,可直接进入批注链路,不依赖 LLM 回忆。
|
||||
@@ -101,6 +102,44 @@ def check_duplicate_entities(ctx, window, *, tolerance, group_limit, log=print):
|
||||
return out
|
||||
|
||||
|
||||
def check_collisions(ctx, window, *, min_overlap, limit, log=print):
|
||||
"""find_collisions(window) → 每条「包围盒干涉候选」转 issue。
|
||||
|
||||
**候选**语义:AABB 相交不等于真干涉(L 形布局、相邻管段接口、标注引线跨越
|
||||
图元都会相交),故严重度取 medium、category 单列「干涉」,由报告层 / 人复核。
|
||||
重叠比 `ratio` 写进 message,便于按可疑程度排序与人工筛选。
|
||||
"""
|
||||
try:
|
||||
resp = ctx.tools.call(
|
||||
"find_collisions",
|
||||
{"window": window, "min_overlap": min_overlap, "limit": limit},
|
||||
)
|
||||
except (McpError, McpToolError) as exc:
|
||||
log(f" [确定性] find_collisions 失败: {exc}")
|
||||
return []
|
||||
|
||||
out = []
|
||||
for item in _iter_items(result_json(resp, {})):
|
||||
a = item.get("a") if isinstance(item.get("a"), dict) else {}
|
||||
b = item.get("b") if isinstance(item.get("b"), dict) else {}
|
||||
ha, hb = a.get("handle"), b.get("handle")
|
||||
if not ha or not hb:
|
||||
continue
|
||||
ov = item.get("overlap") if isinstance(item.get("overlap"), dict) else {}
|
||||
ratio = ov.get("ratio")
|
||||
ratio_txt = f"{ratio:.2f}" if isinstance(ratio, (int, float)) else "?"
|
||||
out.append(
|
||||
make_issue(
|
||||
"干涉", "medium",
|
||||
f"包围盒干涉候选({a.get('type')}/{ha} × {b.get('type')}/{hb},"
|
||||
f"重叠比 {ratio_txt},同层={item.get('same_layer')})",
|
||||
handle=ha,
|
||||
handles=[ha, hb],
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 纯 Python 派生(零额外 MCP 往返)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -161,4 +200,11 @@ def run_deterministic_checks(ctx, evidence, *, log=print):
|
||||
group_limit=config.DUP_GROUP_LIMIT,
|
||||
log=log,
|
||||
)
|
||||
if config.CHECK_COLLISIONS:
|
||||
issues += check_collisions(
|
||||
ctx, window,
|
||||
min_overlap=config.COLLISION_MIN_OVERLAP,
|
||||
limit=config.FRAME_ENTITY_LIMIT,
|
||||
log=log,
|
||||
)
|
||||
return issues
|
||||
@@ -119,6 +119,13 @@ ZERO_LENGTH_TOLERANCE = float(os.environ.get("ZERO_LENGTH_TOLERANCE", "0.001"))
|
||||
DUP_TOLERANCE = float(os.environ.get("DUP_TOLERANCE", "0.001"))
|
||||
# find_duplicate_entities 的 limit(语义是「最大重复组数」,非条目数)
|
||||
DUP_GROUP_LIMIT = int(os.environ.get("DUP_GROUP_LIMIT", "50"))
|
||||
# 是否在确定性检查里跑 find_collisions(包围盒干涉**候选**粗筛)。
|
||||
# 默认关闭:平面图里管线穿墙、标注引线跨图元等属正常相交,AABB 相交 ≠ 真干涉,
|
||||
# 全量跑会淹没报告;改由审查 LLM 按「干涉/碰撞」清单条目**按需**调用该工具。
|
||||
CHECK_COLLISIONS = _read_bool("CHECK_COLLISIONS", False)
|
||||
# find_collisions 的最小重叠比阈值(逐轴重叠比之积,见 find_collisions.cpp 注释)。
|
||||
# 0.5 已能滤掉「仅角部相接 / 细长交叉」的弱候选,只留明显互相侵入的对。
|
||||
COLLISION_MIN_OVERLAP = float(os.environ.get("COLLISION_MIN_OVERLAP", "0.5"))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 截图(视觉 token 成本控制)
|
||||
|
||||
@@ -44,6 +44,16 @@
|
||||
"轮廓/多段线是否应当闭合而未闭合"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "collision",
|
||||
"title": "干涉/碰撞",
|
||||
"severity": "high",
|
||||
"checks": [
|
||||
"管线、设备、结构构件之间是否存在非预期的实体侵入或重叠(可用 find_collisions 粗筛候选)",
|
||||
"是否存在同一位置重复放置的图元(叠画),导致施工歧义",
|
||||
"标注引线、填充、图签等是否与图形主体异常交叠遮挡"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "visual",
|
||||
"title": "图面整体",
|
||||
|
||||
@@ -294,6 +294,60 @@
|
||||
"factory_function": "CreateFindZeroLengthTool"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "find_collisions",
|
||||
"description": "查找包围盒相交(可能干涉)的实体对,返回候选 + 重叠证据(重叠盒、逐轴重叠比 ratio、类型/图层)。ratio 越大越可疑。注意:包围盒相交不等于真干涉(相邻管段在接口处会相接、L 形布局的包围盒会相交),请结合类型/图层判断,必要时用 inspect_region 放大目视复核。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"space": {
|
||||
"type": "string"
|
||||
},
|
||||
"layer_filter": {
|
||||
"type": "string",
|
||||
"description": "图层通配符,空 = 不过滤"
|
||||
},
|
||||
"window": {
|
||||
"type": "object",
|
||||
"description": "限定范围(bbox 与之相交即纳入)",
|
||||
"properties": {
|
||||
"min_x": {"type": "number"},
|
||||
"min_y": {"type": "number"},
|
||||
"max_x": {"type": "number"},
|
||||
"max_y": {"type": "number"}
|
||||
}
|
||||
},
|
||||
"min_overlap": {
|
||||
"type": "number",
|
||||
"description": "最小重叠比,默认 0"
|
||||
},
|
||||
"include_same_layer": {
|
||||
"type": "boolean",
|
||||
"description": "是否包含同图层实体对,默认 true"
|
||||
},
|
||||
"exclude_oversized": {
|
||||
"type": "number",
|
||||
"description": "剔除面积占参考面积超过该比例的实体,默认 0.5(用于去掉图框块)"
|
||||
},
|
||||
"narrow": {
|
||||
"type": "boolean",
|
||||
"description": "是否做几何细筛(intersectWith),默认 false;较慢"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "最大返回对数,默认 200"
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"backend": {
|
||||
"type": "c++_dll",
|
||||
"dll_name": "cad_mcp_plugins.dll",
|
||||
"factory_function": "CreateFindCollisionsTool"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zoom_to_entity",
|
||||
"description": "缩放到指定 handle(或 handle 列表)实体的范围,四周留 5% 边距。配合截图确认。",
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
#pragma once
|
||||
// ============================================================================
|
||||
// bvh.h
|
||||
// 2D AABB 层次包围盒 —— 干涉检查的**粗筛**层。
|
||||
//
|
||||
// 纯 C++(不依赖 ObjectARX),header-only,便于复用与推理。
|
||||
//
|
||||
// 相比「按质心中位分割」的朴素实现,这里的取舍:
|
||||
// - 按**质心中位数**沿**较长轴**二分 → 深度严格 O(log n),无退化分支;
|
||||
// - 内部节点记录**子树图元数**,交叉遍历时优先拆较大的一侧,减少无效下降;
|
||||
// - 自相交遍历**每对只报告一次**(叶内 i<j + 子树两两交叉),无重复无自比。
|
||||
//
|
||||
// 坐标约定:x 向右、y 向上;Box 为 (x1,y1)-(x2,y2)。
|
||||
// ============================================================================
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace dwg_bvh
|
||||
{
|
||||
|
||||
// 叶节点最多容纳的图元数:越小查询越快、建树越慢
|
||||
constexpr int kLeafSize = 4;
|
||||
|
||||
struct Box
|
||||
{
|
||||
double x1 = 0.0, y1 = 0.0, x2 = 0.0, y2 = 0.0;
|
||||
|
||||
double width() const { return x2 - x1; }
|
||||
double height() const { return y2 - y1; }
|
||||
double area() const { return width() * height(); }
|
||||
bool valid() const { return x2 >= x1 && y2 >= y1; }
|
||||
|
||||
// 反向极值盒:任何 grow 都能把它撑开(用于求并集)
|
||||
static Box empty()
|
||||
{
|
||||
Box b;
|
||||
b.x1 = b.y1 = 1e300;
|
||||
b.x2 = b.y2 = -1e300;
|
||||
return b;
|
||||
}
|
||||
|
||||
void grow(const Box& o)
|
||||
{
|
||||
if (o.x1 < x1) x1 = o.x1;
|
||||
if (o.y1 < y1) y1 = o.y1;
|
||||
if (o.x2 > x2) x2 = o.x2;
|
||||
if (o.y2 > y2) y2 = o.y2;
|
||||
}
|
||||
|
||||
// 接触即算相交
|
||||
bool intersects(const Box& o) const
|
||||
{
|
||||
return !(x2 < o.x1 || o.x2 < x1 || y2 < o.y1 || o.y2 < y1);
|
||||
}
|
||||
|
||||
// 交集盒(可能退化,调用方自行判断)
|
||||
Box overlap(const Box& o) const
|
||||
{
|
||||
Box r;
|
||||
r.x1 = (x1 > o.x1) ? x1 : o.x1;
|
||||
r.y1 = (y1 > o.y1) ? y1 : o.y1;
|
||||
r.x2 = (x2 < o.x2) ? x2 : o.x2;
|
||||
r.y2 = (y2 < o.y2) ? y2 : o.y2;
|
||||
return r;
|
||||
}
|
||||
};
|
||||
|
||||
struct Primitive
|
||||
{
|
||||
Box box;
|
||||
int payload = -1; // 调用方的索引(如实体在自身数组里的下标)
|
||||
};
|
||||
|
||||
struct Node
|
||||
{
|
||||
Box box;
|
||||
int first = 0; // 叶:图元区间起点;内部:左子节点下标
|
||||
int count = 0; // 叶:图元个数;内部:子树图元总数
|
||||
bool leaf = true;
|
||||
};
|
||||
|
||||
class Tree
|
||||
{
|
||||
public:
|
||||
// 建树(就地重排 prims;payload 随图元一起搬,调用方索引不受影响)
|
||||
void Build(std::vector<Primitive> prims)
|
||||
{
|
||||
m_prims.swap(prims);
|
||||
m_nodes.clear();
|
||||
if (m_prims.empty()) return;
|
||||
m_nodes.reserve(2 * m_prims.size());
|
||||
m_nodes.emplace_back();
|
||||
buildNode(0, 0, static_cast<int>(m_prims.size()));
|
||||
}
|
||||
|
||||
bool empty() const { return m_nodes.empty(); }
|
||||
int size() const { return static_cast<int>(m_prims.size()); }
|
||||
|
||||
// 两两自检:所有包围盒相交的 (payloadA, payloadB),每对仅一次
|
||||
void SelfPairs(std::vector<std::pair<int, int>>& out) const
|
||||
{
|
||||
if (!m_nodes.empty()) selfPairs(0, out);
|
||||
}
|
||||
|
||||
// 查询与 box 相交的 payload
|
||||
void Query(const Box& q, std::vector<int>& out) const
|
||||
{
|
||||
out.clear();
|
||||
if (m_nodes.empty()) return;
|
||||
std::vector<int> stack;
|
||||
stack.push_back(0);
|
||||
while (!stack.empty())
|
||||
{
|
||||
const int idx = stack.back();
|
||||
stack.pop_back();
|
||||
const Node& n = m_nodes[idx];
|
||||
if (!n.box.intersects(q)) continue;
|
||||
if (n.leaf)
|
||||
{
|
||||
const int end = n.first + n.count;
|
||||
for (int i = n.first; i < end; ++i)
|
||||
if (m_prims[i].box.intersects(q)) out.push_back(m_prims[i].payload);
|
||||
}
|
||||
else
|
||||
{
|
||||
stack.push_back(n.first);
|
||||
stack.push_back(n.first + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
void buildNode(int nodeIdx, int first, int count)
|
||||
{
|
||||
Box hull = Box::empty();
|
||||
Box cent = Box::empty();
|
||||
for (int i = first; i < first + count; ++i)
|
||||
{
|
||||
const Box& b = m_prims[i].box;
|
||||
hull.grow(b);
|
||||
Box c;
|
||||
c.x1 = c.x2 = (b.x1 + b.x2) * 0.5;
|
||||
c.y1 = c.y2 = (b.y1 + b.y2) * 0.5;
|
||||
cent.grow(c);
|
||||
}
|
||||
|
||||
m_nodes[nodeIdx].box = hull;
|
||||
m_nodes[nodeIdx].first = first;
|
||||
m_nodes[nodeIdx].count = count;
|
||||
m_nodes[nodeIdx].leaf = true;
|
||||
if (count <= kLeafSize) return;
|
||||
|
||||
const bool byX = cent.width() >= cent.height();
|
||||
const int mid = count / 2;
|
||||
auto begin = m_prims.begin() + first;
|
||||
std::nth_element(begin, begin + mid, begin + count,
|
||||
[byX](const Primitive& a, const Primitive& b)
|
||||
{
|
||||
const double ca = byX ? (a.box.x1 + a.box.x2) : (a.box.y1 + a.box.y2);
|
||||
const double cb = byX ? (b.box.x1 + b.box.x2) : (b.box.y1 + b.box.y2);
|
||||
return ca < cb;
|
||||
});
|
||||
|
||||
// 注意:emplace_back 可能重分配 m_nodes,故不持有 Node& 引用
|
||||
const int leftIdx = static_cast<int>(m_nodes.size());
|
||||
m_nodes.emplace_back();
|
||||
m_nodes.emplace_back();
|
||||
m_nodes[nodeIdx].first = leftIdx;
|
||||
m_nodes[nodeIdx].leaf = false;
|
||||
|
||||
buildNode(leftIdx, first, mid);
|
||||
buildNode(leftIdx + 1, first + mid, count - mid);
|
||||
}
|
||||
|
||||
void selfPairs(int idx, std::vector<std::pair<int, int>>& out) const
|
||||
{
|
||||
const Node& n = m_nodes[idx];
|
||||
if (n.leaf)
|
||||
{
|
||||
const int end = n.first + n.count;
|
||||
for (int i = n.first; i < end; ++i)
|
||||
for (int j = i + 1; j < end; ++j)
|
||||
if (m_prims[i].box.intersects(m_prims[j].box))
|
||||
out.emplace_back(m_prims[i].payload, m_prims[j].payload);
|
||||
return;
|
||||
}
|
||||
selfPairs(n.first, out);
|
||||
selfPairs(n.first + 1, out);
|
||||
crossPairs(n.first, n.first + 1, out);
|
||||
}
|
||||
|
||||
void crossPairs(int a, int b, std::vector<std::pair<int, int>>& out) const
|
||||
{
|
||||
const Node& na = m_nodes[a];
|
||||
const Node& nb = m_nodes[b];
|
||||
if (!na.box.intersects(nb.box)) return;
|
||||
|
||||
if (na.leaf && nb.leaf)
|
||||
{
|
||||
const int ea = na.first + na.count;
|
||||
const int eb = nb.first + nb.count;
|
||||
for (int i = na.first; i < ea; ++i)
|
||||
for (int j = nb.first; j < eb; ++j)
|
||||
if (m_prims[i].box.intersects(m_prims[j].box))
|
||||
out.emplace_back(m_prims[i].payload, m_prims[j].payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// 优先拆「较大」的一侧,让递归更快收敛
|
||||
const bool splitA = nb.leaf || (!na.leaf && na.count >= nb.count);
|
||||
if (splitA)
|
||||
{
|
||||
crossPairs(na.first, b, out);
|
||||
crossPairs(na.first + 1, b, out);
|
||||
}
|
||||
else
|
||||
{
|
||||
crossPairs(a, nb.first, out);
|
||||
crossPairs(a, nb.first + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Primitive> m_prims;
|
||||
std::vector<Node> m_nodes;
|
||||
};
|
||||
|
||||
} // namespace dwg_bvh
|
||||
@@ -15,6 +15,7 @@ set(SOURCES
|
||||
"dwg_review_check.cpp"
|
||||
"dwg_review_view.cpp"
|
||||
"dwg_review_ui.cpp"
|
||||
"find_collisions.cpp"
|
||||
"dwg_annotate_common.cpp"
|
||||
"dwg_annotate_draw.cpp"
|
||||
"dwg_annotate_edit.cpp"
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
// ============================================================================
|
||||
// find_collisions.cpp
|
||||
// 图纸审查 - 干涉 / 碰撞**候选**检查
|
||||
//
|
||||
// find_collisions : 粗筛出「包围盒相交」的实体对,附重叠证据,供审查 agent 判断
|
||||
//
|
||||
// 设计要点:
|
||||
// - **粗筛**:BVH(bvh.h)两两自检,只对相交的包围盒做后续处理;
|
||||
// - **容器过滤**:图框块 / 分区边界会与「一切」相交,制造海量假候选。
|
||||
// 用重叠**度**(与多少其他实体相交)识别:`deg >= max(deg_min, frac * 其他实体数)`
|
||||
// → 判为容器,剔除其全部配对。**与图幅/缩放无关**,实测图框 deg=35/25/23,
|
||||
// 其余实体全部 deg<=3,分离极干净。(另保留基于面积的 `exclude_oversized` 作快速预筛。)
|
||||
// - **证据**:重叠盒 + 逐轴重叠比(`ratio`,见下)+ 类型 / 图层 / 是否同层同类型;
|
||||
// - **排序**:按 `ratio` 降序 + 分页 → 最可疑的排在最前;
|
||||
// - **只报候选、不下结论**:AABB 相交 ≠ 真干涉(L 形布局的两个实体包围盒会相交,
|
||||
// 相邻管段在接口处也会相接)。真伪由审查 agent 依据证据判断,必要时用
|
||||
// `inspect_region` 放大目视复核。
|
||||
//
|
||||
// `ratio` 定义:逐轴重叠比之积。
|
||||
// 某轴重叠比 = 该轴重叠长度 / 两实体该轴较短长度;**至少一方在该轴退化为 0**
|
||||
// (水平线的高、竖直线的宽)时该轴取 1.0(视为完全重叠)。
|
||||
// 于是:共线相接的两段 → 0;横管穿过阀门 → ≈1;斜管交叉 → 较大。
|
||||
//
|
||||
// 注:本工具按 **2D(x/y)** 判定,与插件其余工具一致;z 不参与。
|
||||
// ============================================================================
|
||||
|
||||
#include "cadsdk.h"
|
||||
#include "dwg_review_common.h"
|
||||
#include "mcp_plugin_api.h"
|
||||
#include "bvh.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
using namespace dwg_review;
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
struct EntInfo
|
||||
{
|
||||
AcDbObjectId id;
|
||||
std::string handle;
|
||||
std::string type;
|
||||
std::string layer;
|
||||
BBox box;
|
||||
};
|
||||
|
||||
struct Pair
|
||||
{
|
||||
int a = 0;
|
||||
int b = 0;
|
||||
double ratio = 0.0;
|
||||
BBox ov;
|
||||
double ow = 0.0;
|
||||
double oh = 0.0;
|
||||
bool exact = false;
|
||||
};
|
||||
|
||||
// ---------- 宽松参数解析(LLM 常把数字写成字符串、把 bool 写成 0/1) ----------
|
||||
double numArg(const nlohmann::json& args, const char* key, double dflt)
|
||||
{
|
||||
auto it = args.find(key);
|
||||
if (it == args.end() || it->is_null()) return dflt;
|
||||
if (it->is_number()) return it->get<double>();
|
||||
if (it->is_boolean()) return it->get<bool>() ? 1.0 : 0.0;
|
||||
if (it->is_string())
|
||||
{
|
||||
try { return std::stod(it->get<std::string>()); }
|
||||
catch (...) { return dflt; }
|
||||
}
|
||||
return dflt;
|
||||
}
|
||||
|
||||
bool boolArg(const nlohmann::json& args, const char* key, bool dflt)
|
||||
{
|
||||
auto it = args.find(key);
|
||||
if (it == args.end() || it->is_null()) return dflt;
|
||||
if (it->is_boolean()) return it->get<bool>();
|
||||
if (it->is_number()) return it->get<double>() != 0.0;
|
||||
return dflt;
|
||||
}
|
||||
|
||||
double clampd(double v, double lo, double hi)
|
||||
{
|
||||
return v < lo ? lo : (v > hi ? hi : v);
|
||||
}
|
||||
|
||||
dwg_bvh::Box toBvhBox(const BBox& b)
|
||||
{
|
||||
dwg_bvh::Box r;
|
||||
r.x1 = b.minX; r.y1 = b.minY; r.x2 = b.maxX; r.y2 = b.maxY;
|
||||
return r;
|
||||
}
|
||||
|
||||
// 单轴重叠比:至少一方在该轴退化为 0 → 视为完全重叠(1.0)
|
||||
double axisFrac(double a1, double a2, double b1, double b2, double o1, double o2)
|
||||
{
|
||||
const double shorter = std::min(a2 - a1, b2 - b1);
|
||||
if (shorter <= 0.0) return 1.0;
|
||||
return (o2 - o1) / shorter;
|
||||
}
|
||||
|
||||
// 可选细筛:几何是否真的相交。AEC 自定义实体未必实现 intersectWith,
|
||||
// 失败 / 未实现时返回 false(调用方据此把 exact 置 false,但仍保留候选)。
|
||||
bool geometryIntersects(const AcDbObjectId& idA, const AcDbObjectId& idB)
|
||||
{
|
||||
AcDbEntity* pa = nullptr;
|
||||
if (acdbOpenObject(pa, idA, AcDb::kForRead) != Acad::eOk || !pa) return false;
|
||||
|
||||
AcDbEntity* pb = nullptr;
|
||||
if (acdbOpenObject(pb, idB, AcDb::kForRead) != Acad::eOk || !pb)
|
||||
{
|
||||
pa->close();
|
||||
return false;
|
||||
}
|
||||
|
||||
bool hit = false;
|
||||
try
|
||||
{
|
||||
AcGePoint3dArray pts;
|
||||
hit = (pa->intersectWith(pb, AcDb::kOnBothOperands, pts) == Acad::eOk)
|
||||
&& pts.length() > 0;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
hit = false;
|
||||
}
|
||||
pb->close();
|
||||
pa->close();
|
||||
return hit;
|
||||
}
|
||||
|
||||
nlohmann::json entJson(const EntInfo& e)
|
||||
{
|
||||
nlohmann::json j;
|
||||
j["handle"] = e.handle;
|
||||
j["type"] = e.type;
|
||||
j["layer"] = e.layer;
|
||||
j["bbox"] = bboxJson(e.box);
|
||||
return j;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class mcp_Tool_FindCollisions : public mcp_Tool
|
||||
{
|
||||
public:
|
||||
nlohmann::json Execute(const nlohmann::json& args) override
|
||||
{
|
||||
CommonQuery q = parseCommonQuery(args);
|
||||
|
||||
const int limit = (q.limit > 0 && q.limit <= 1000) ? q.limit : 200;
|
||||
const double minOverlap = clampd(numArg(args, "min_overlap", 0.0), 0.0, 1.0);
|
||||
const bool sameLayer = boolArg(args, "include_same_layer", true);
|
||||
const double maxAreaFrac = clampd(numArg(args, "exclude_oversized", 0.5), 0.0, 1.0);
|
||||
const bool narrow = boolArg(args, "narrow", false);
|
||||
// 容器过滤(图框块 / 分区边界):重叠度阈值
|
||||
const int contDegMin = static_cast<int>(
|
||||
clampd(numArg(args, "exclude_container_deg", 8.0), 1.0, 100000.0));
|
||||
const double contDegFrac = clampd(numArg(args, "exclude_container_frac", 0.25), 0.0, 1.0);
|
||||
|
||||
// ---- 1) 收集实体(含包围盒) ----
|
||||
std::vector<EntInfo> ents;
|
||||
forEachEntity(q, [&](AcDbEntity* pEnt) -> bool
|
||||
{
|
||||
BBox b;
|
||||
if (!entityBBox(pEnt, b) || !b.valid) return true;
|
||||
|
||||
EntInfo info;
|
||||
info.id = pEnt->objectId();
|
||||
info.handle = handleOf(pEnt);
|
||||
info.type = entityTypeName(pEnt);
|
||||
info.layer = layerOf(pEnt);
|
||||
info.box = b;
|
||||
ents.push_back(std::move(info));
|
||||
return true;
|
||||
});
|
||||
|
||||
if (ents.size() < 2)
|
||||
{
|
||||
nlohmann::json r = pageResult(0, 0, 0, nlohmann::json::array());
|
||||
r["entity_count"] = static_cast<int>(ents.size());
|
||||
r["excluded_containers"] = nlohmann::json::array();
|
||||
return makeJsonResult(r);
|
||||
}
|
||||
|
||||
// 被剔除的「容器」(图框块 / 分区边界),供审查 agent 知情
|
||||
nlohmann::json containers = nlohmann::json::array();
|
||||
|
||||
// ---- 2) 面积预筛:剔除「过大」实体(图框块会与一切相交) ----
|
||||
// 参考面积:有 window 用 window,否则用全部实体的并集
|
||||
BBox ref = ents[0].box;
|
||||
for (const EntInfo& e : ents)
|
||||
{
|
||||
if (e.box.minX < ref.minX) ref.minX = e.box.minX;
|
||||
if (e.box.minY < ref.minY) ref.minY = e.box.minY;
|
||||
if (e.box.maxX > ref.maxX) ref.maxX = e.box.maxX;
|
||||
if (e.box.maxY > ref.maxY) ref.maxY = e.box.maxY;
|
||||
}
|
||||
const double refArea = (ref.maxX - ref.minX) * (ref.maxY - ref.minY);
|
||||
|
||||
std::vector<dwg_bvh::Primitive> prims;
|
||||
std::vector<EntInfo> kept;
|
||||
prims.reserve(ents.size());
|
||||
kept.reserve(ents.size());
|
||||
for (const EntInfo& e : ents)
|
||||
{
|
||||
if (refArea > 0.0)
|
||||
{
|
||||
const double a = (e.box.maxX - e.box.minX) * (e.box.maxY - e.box.minY);
|
||||
if (a > refArea * maxAreaFrac)
|
||||
{
|
||||
nlohmann::json c;
|
||||
c["handle"] = e.handle;
|
||||
c["type"] = e.type;
|
||||
c["layer"] = e.layer;
|
||||
c["reason"] = "oversized";
|
||||
containers.push_back(std::move(c));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
prims.push_back({ toBvhBox(e.box), static_cast<int>(kept.size()) });
|
||||
kept.push_back(e);
|
||||
}
|
||||
|
||||
// ---- 3) 粗筛 ----
|
||||
dwg_bvh::Tree tree;
|
||||
tree.Build(prims);
|
||||
std::vector<std::pair<int, int>> hits;
|
||||
tree.SelfPairs(hits);
|
||||
|
||||
// ---- 3.5) 容器过滤:重叠度过高的实体判为图框块 / 分区边界,剔除其全部配对 ----
|
||||
// 判据与图幅、缩放无关,只依赖「与多少其他实体相交」——实测分离极干净
|
||||
// (图框 deg=35/25/23,其余实体全部 deg<=3)。
|
||||
std::vector<int> deg(kept.size(), 0);
|
||||
for (const auto& h : hits)
|
||||
{
|
||||
++deg[h.first];
|
||||
++deg[h.second];
|
||||
}
|
||||
const int others = (kept.size() > 0) ? static_cast<int>(kept.size()) - 1 : 0;
|
||||
const int degLimit = std::max(
|
||||
contDegMin, static_cast<int>(std::ceil(contDegFrac * others)));
|
||||
|
||||
std::vector<char> isContainer(kept.size(), 0);
|
||||
for (std::size_t i = 0; i < kept.size(); ++i)
|
||||
{
|
||||
if (deg[i] < degLimit) continue;
|
||||
isContainer[i] = 1;
|
||||
nlohmann::json c;
|
||||
c["handle"] = kept[i].handle;
|
||||
c["type"] = kept[i].type;
|
||||
c["layer"] = kept[i].layer;
|
||||
c["degree"] = deg[i];
|
||||
c["reason"] = "container";
|
||||
containers.push_back(std::move(c));
|
||||
}
|
||||
|
||||
// ---- 4) 组装证据 / 过滤 / 排序 ----
|
||||
std::vector<Pair> pairs;
|
||||
pairs.reserve(hits.size());
|
||||
for (const auto& h : hits)
|
||||
{
|
||||
if (isContainer[h.first] || isContainer[h.second]) continue;
|
||||
const EntInfo& ea = kept[h.first];
|
||||
const EntInfo& eb = kept[h.second];
|
||||
if (!sameLayer && ea.layer == eb.layer) continue;
|
||||
|
||||
const BBox& A = ea.box;
|
||||
const BBox& B = eb.box;
|
||||
const double ox1 = std::max(A.minX, B.minX);
|
||||
const double oy1 = std::max(A.minY, B.minY);
|
||||
const double ox2 = std::min(A.maxX, B.maxX);
|
||||
const double oy2 = std::min(A.maxY, B.maxY);
|
||||
if (ox2 < ox1 || oy2 < oy1) continue;
|
||||
|
||||
Pair p;
|
||||
p.a = h.first;
|
||||
p.b = h.second;
|
||||
p.ov.minX = ox1; p.ov.minY = oy1; p.ov.maxX = ox2; p.ov.maxY = oy2;
|
||||
p.ov.valid = true;
|
||||
p.ow = ox2 - ox1;
|
||||
p.oh = oy2 - oy1;
|
||||
p.ratio = axisFrac(A.minX, A.maxX, B.minX, B.maxX, ox1, ox2)
|
||||
* axisFrac(A.minY, A.maxY, B.minY, B.maxY, oy1, oy2);
|
||||
if (p.ratio < minOverlap) continue;
|
||||
pairs.push_back(p);
|
||||
}
|
||||
|
||||
std::sort(pairs.begin(), pairs.end(),
|
||||
[](const Pair& x, const Pair& y) { return x.ratio > y.ratio; });
|
||||
|
||||
// ---- 5) 可选细筛(只对进入返回页的候选做,控制开销) ----
|
||||
const int total = static_cast<int>(pairs.size());
|
||||
const int from = (q.offset > 0) ? std::min(q.offset, total) : 0;
|
||||
const int to = std::min(total, from + limit);
|
||||
|
||||
nlohmann::json items = nlohmann::json::array();
|
||||
for (int i = from; i < to; ++i)
|
||||
{
|
||||
Pair& p = pairs[i];
|
||||
nlohmann::json it;
|
||||
it["a"] = entJson(kept[p.a]);
|
||||
it["b"] = entJson(kept[p.b]);
|
||||
it["overlap"] = {
|
||||
{ "bbox", bboxJson(p.ov) },
|
||||
{ "width", p.ow },
|
||||
{ "height", p.oh },
|
||||
{ "area", p.ow * p.oh },
|
||||
{ "ratio", p.ratio }
|
||||
};
|
||||
it["same_layer"] = (kept[p.a].layer == kept[p.b].layer);
|
||||
it["same_type"] = (kept[p.a].type == kept[p.b].type);
|
||||
if (narrow)
|
||||
{
|
||||
p.exact = geometryIntersects(kept[p.a].id, kept[p.b].id);
|
||||
it["exact"] = p.exact;
|
||||
}
|
||||
items.push_back(std::move(it));
|
||||
}
|
||||
|
||||
nlohmann::json r = pageResult(total, to - from, from, items);
|
||||
r["entity_count"] = static_cast<int>(kept.size());
|
||||
r["excluded_containers"] = std::move(containers);
|
||||
return makeJsonResult(r);
|
||||
}
|
||||
};
|
||||
|
||||
EXPORT_MCP_TOOL(mcp_Tool_FindCollisions, CreateFindCollisionsTool)
|
||||
@@ -204,6 +204,7 @@ STANDARD_TOOLS = [
|
||||
tool_schema("get_entity_detail", "实体详情"),
|
||||
tool_schema("find_duplicate_entities", "重复实体"),
|
||||
tool_schema("find_zero_length", "零长度实体"),
|
||||
tool_schema("find_collisions", "干涉候选"),
|
||||
tool_schema("add_leader_annotation", "添加引线批注"),
|
||||
tool_schema("highlight_entities", "高亮实体"),
|
||||
tool_schema("delete_entities", "删除实体"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""`dwg_review.cad_tools` 单元测试。"""
|
||||
|
||||
from dwg_review.cad_tools import CadTools, is_write_tool
|
||||
from dwg_review.cad_tools import READ_ONLY_TOOLS, CadTools, is_write_tool
|
||||
from dwg_review.mcp_client import result_json
|
||||
from tests.fakes import FakeMcpClient, ok_text, tool_schema
|
||||
|
||||
@@ -19,10 +19,15 @@ def test_write_exact_names_detected():
|
||||
|
||||
|
||||
def test_readonly_tools_are_not_write():
|
||||
for name in ("query_entities", "get_text_content", "zoom_to_window", "find_zero_length"):
|
||||
for name in ("query_entities", "get_text_content", "zoom_to_window",
|
||||
"find_zero_length", "find_collisions"):
|
||||
assert is_write_tool(name) is False
|
||||
|
||||
|
||||
def test_find_collisions_is_whitelisted():
|
||||
assert "find_collisions" in READ_ONLY_TOOLS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 注册表
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -76,6 +76,19 @@ def test_build_system_prompt_instructs_inspect_region():
|
||||
assert "不需要判断截图够不够清楚" in prompt
|
||||
|
||||
|
||||
def test_build_system_prompt_mentions_collision_tool():
|
||||
prompt = build_system_prompt(DEFAULT_CHECKLIST, allow_tools=True)
|
||||
# 干涉排查入口:find_collisions 粗筛候选 + 强调 AABB 相交 ≠ 真干涉
|
||||
assert "find_collisions" in prompt
|
||||
assert "包围盒相交" in prompt
|
||||
assert "不等于真干涉" in prompt
|
||||
|
||||
|
||||
def test_default_checklist_has_collision_category():
|
||||
titles = [c["title"] for c in DEFAULT_CHECKLIST["categories"]]
|
||||
assert "干涉/碰撞" in titles
|
||||
|
||||
|
||||
def test_build_system_prompt_requires_structured_json_output():
|
||||
# allow_tools=False 以隔离「工具提示块」里的「只读」字样
|
||||
prompt = build_system_prompt(DEFAULT_CHECKLIST, allow_tools=False)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from dwg_review import checks
|
||||
from dwg_review.checks import (
|
||||
check_collisions,
|
||||
check_duplicate_entities,
|
||||
check_zero_length,
|
||||
derive_from_evidence,
|
||||
@@ -127,6 +128,79 @@ def test_duplicate_swallows_tool_error():
|
||||
assert any("find_duplicate_entities 失败" in line for line in logs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_collisions
|
||||
# ---------------------------------------------------------------------------
|
||||
def _collision_item(a="A1", b="B1", ratio=0.8, same_layer=True):
|
||||
return {
|
||||
"a": {"handle": a, "type": "LINE", "layer": "0", "bbox": {}},
|
||||
"b": {"handle": b, "type": "LWPOLYLINE", "layer": "0", "bbox": {}},
|
||||
"overlap": {"bbox": {}, "width": 1.0, "height": 1.0, "area": 1.0, "ratio": ratio},
|
||||
"same_layer": same_layer,
|
||||
"same_type": False,
|
||||
}
|
||||
|
||||
|
||||
def test_collisions_turn_items_into_issues():
|
||||
client = FakeMcpClient(responses={"find_collisions": ok_text({
|
||||
"total": 2, "returned": 2, "offset": 0, "entity_count": 10,
|
||||
"items": [_collision_item("A1", "B1", 0.9), _collision_item("A2", "B2", 0.6)],
|
||||
})})
|
||||
ctx = make_ctx(client=client)
|
||||
issues = check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=QUIET)
|
||||
assert [i["handles"] for i in issues] == [["A1", "B1"], ["A2", "B2"]]
|
||||
assert all(i["category"] == "干涉" and i["severity"] == "medium" for i in issues)
|
||||
assert all(i["handle"] == i["handles"][0] for i in issues)
|
||||
assert "0.90" in issues[0]["message"]
|
||||
|
||||
|
||||
def test_collisions_skips_items_missing_handles():
|
||||
client = FakeMcpClient(responses={"find_collisions": ok_text({
|
||||
"items": [
|
||||
{"a": {"handle": "A1"}, "b": {}},
|
||||
{"a": {}, "b": {"handle": "B1"}},
|
||||
_collision_item("A2", "B2"),
|
||||
],
|
||||
})})
|
||||
ctx = make_ctx(client=client)
|
||||
issues = check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=QUIET)
|
||||
assert [i["handles"] for i in issues] == [["A2", "B2"]]
|
||||
|
||||
|
||||
def test_collisions_tolerates_missing_ratio():
|
||||
client = FakeMcpClient(responses={"find_collisions": ok_text({
|
||||
"items": [{"a": {"handle": "A1"}, "b": {"handle": "B1"}, "overlap": {}}],
|
||||
})})
|
||||
ctx = make_ctx(client=client)
|
||||
issues = check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=QUIET)
|
||||
assert len(issues) == 1
|
||||
assert "重叠比 ?" in issues[0]["message"]
|
||||
|
||||
|
||||
def test_collisions_passes_params():
|
||||
client = FakeMcpClient(responses={"find_collisions": ok_text({"items": []})})
|
||||
ctx = make_ctx(client=client)
|
||||
check_collisions(ctx, WINDOW, min_overlap=0.75, limit=33, log=QUIET)
|
||||
assert client.arguments_of("find_collisions") == {
|
||||
"window": WINDOW, "min_overlap": 0.75, "limit": 33,
|
||||
}
|
||||
|
||||
|
||||
def test_collisions_swallows_tool_error():
|
||||
logs = []
|
||||
client = FakeMcpClient(responses={"find_collisions": McpToolError("炸了")})
|
||||
ctx = make_ctx(client=client)
|
||||
assert check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=logs.append) == []
|
||||
assert any("find_collisions 失败" in line for line in logs)
|
||||
|
||||
|
||||
def test_collisions_tolerates_non_dict_envelope():
|
||||
client = FakeMcpClient(responses={"find_collisions": ok_text("oops")})
|
||||
ctx = make_ctx(client=client)
|
||||
assert check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=QUIET) == []
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# derive_from_evidence
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -171,6 +245,7 @@ def build_ctx():
|
||||
"find_zero_length": ok_text({"items": [{"handle": "Z1", "type": "LINE", "length": 0.0}]}),
|
||||
"find_duplicate_entities": ok_text({"groups": [
|
||||
{"type": "LINE", "handles": ["D1", "D2"]}]}),
|
||||
"find_collisions": ok_text({"items": [_collision_item("C1", "C2", 0.9)]}),
|
||||
})
|
||||
return make_ctx(client=client)
|
||||
|
||||
@@ -210,3 +285,22 @@ def test_run_respects_unclosed_switch(monkeypatch):
|
||||
ctx = build_ctx()
|
||||
ev = make_evidence(polylines={"items": [{"handle": "P1", "vertex_count": 4, "closed": False}]})
|
||||
assert [i["handle"] for i in run_deterministic_checks(ctx, ev, log=QUIET)] == ["Z1", "D1"]
|
||||
|
||||
|
||||
def test_run_collisions_off_by_default(monkeypatch):
|
||||
# 默认不跑干涉粗筛(AABB 相交噪声大),工具调用里不应出现 find_collisions
|
||||
monkeypatch.setattr(checks.config, "CHECK_COLLISIONS", False)
|
||||
ctx = build_ctx()
|
||||
run_deterministic_checks(ctx, make_evidence(), log=QUIET)
|
||||
assert "find_collisions" not in ctx.client.called_names()
|
||||
|
||||
|
||||
def test_run_collisions_when_enabled(monkeypatch):
|
||||
monkeypatch.setattr(checks.config, "CHECK_COLLISIONS", True)
|
||||
monkeypatch.setattr(checks.config, "COLLISION_MIN_OVERLAP", 0.6)
|
||||
ctx = build_ctx()
|
||||
issues = run_deterministic_checks(ctx, make_evidence(), log=QUIET)
|
||||
by_handle = {i.get("handle"): i for i in issues}
|
||||
assert "C1" in by_handle
|
||||
assert by_handle["C1"]["category"] == "干涉"
|
||||
assert ctx.client.arguments_of("find_collisions")["min_overlap"] == 0.6
|
||||
@@ -77,6 +77,8 @@ def test_credibility_constants_have_expected_types():
|
||||
assert isinstance(config.ZERO_LENGTH_TOLERANCE, float)
|
||||
assert isinstance(config.DUP_TOLERANCE, float)
|
||||
assert isinstance(config.DUP_GROUP_LIMIT, int)
|
||||
assert isinstance(config.CHECK_COLLISIONS, bool)
|
||||
assert isinstance(config.COLLISION_MIN_OVERLAP, float)
|
||||
|
||||
|
||||
def test_tiling_constants_have_expected_types():
|
||||
@@ -132,6 +134,13 @@ def test_credibility_defaults_are_on():
|
||||
assert config.CHECK_UNCLOSED_POLYLINES is True
|
||||
|
||||
|
||||
def test_collision_defaults_are_sane():
|
||||
# 干涉粗筛默认关闭(AABB 相交噪声大,改由 LLM 按需调用);
|
||||
# 阈值须落在 [0,1](重叠比是乘积,可能为 0)
|
||||
assert config.CHECK_COLLISIONS is False
|
||||
assert 0.0 <= config.COLLISION_MIN_OVERLAP <= 1.0
|
||||
|
||||
|
||||
def test_checklist_path_resolves_relative_to_root():
|
||||
assert config.CHECKLIST_PATH.name == "review_checklist.json"
|
||||
assert config.CHECKLIST_PATH.parent == config.PROJECT_ROOT
|
||||
|
||||
@@ -357,6 +357,7 @@ def test_review_tools_swap_raw_view_tools_for_inspect_region(monkeypatch):
|
||||
names = _tool_names(make_ctx())
|
||||
assert "inspect_region" in names
|
||||
assert "query_entities" in names # 其余只读工具照旧
|
||||
assert "find_collisions" in names # 干涉粗筛对 LLM 可见
|
||||
assert "zoom_to_window" not in names
|
||||
assert "zoom_to_entity" not in names
|
||||
assert "get_viewport_screenshot" not in names
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
verify_collisions.py
|
||||
干涉/碰撞候选工具(find_collisions)运行时验证脚本。
|
||||
|
||||
前提:CAD 已打开并加载 cad_mcp_frame.arx(插件 DLL),127.0.0.1:8080 在监听。
|
||||
|
||||
用法:
|
||||
python verify_collisions.py # 全模型 + 每个图框各跑一次
|
||||
python verify_collisions.py --top 15 # 打印前 N 条候选(默认 10)
|
||||
python verify_collisions.py --window # 只跑全模型,跳过图框内
|
||||
|
||||
覆盖:
|
||||
- 工具已注册(tools/list)
|
||||
- 全模型粗筛:entity_count / total 候选数 / ratio 分布
|
||||
- min_overlap 阈值过滤(阈值升高 → 候选数不增)
|
||||
- 分页(limit / offset)
|
||||
- narrow=true 细筛(返回项带 exact 字段)
|
||||
- 逐图框(detect_drawing_frames 的 window)候选数
|
||||
|
||||
本脚本**只读**,不做任何写库操作,无需清理。
|
||||
"""
|
||||
import socket
|
||||
import json
|
||||
import sys
|
||||
|
||||
HOST, PORT = "127.0.0.1", 8080
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- MCP 通信
|
||||
def rpc(method, params=None, timeout=60):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(timeout)
|
||||
s.connect((HOST, PORT))
|
||||
req = {"jsonrpc": "2.0", "id": 1, "method": method}
|
||||
if params is not None:
|
||||
req["params"] = params
|
||||
s.sendall(json.dumps(req).encode("utf-8"))
|
||||
data = b""
|
||||
while True:
|
||||
chunk = s.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if len(chunk) < 65536:
|
||||
break
|
||||
s.close()
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
|
||||
def call_tool(name, arguments):
|
||||
return rpc("tools/call", {"name": name, "arguments": arguments})
|
||||
|
||||
|
||||
def payload(resp):
|
||||
"""从 tools/call 响应里取出数据类工具返回的内层 JSON。"""
|
||||
if resp is None:
|
||||
return False, "no response"
|
||||
if "error" in resp:
|
||||
return False, resp["error"]
|
||||
try:
|
||||
for item in resp["result"]["content"]:
|
||||
if item.get("type") == "text":
|
||||
return True, json.loads(item["text"])
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, "parse error: %s" % e
|
||||
return False, "no text content"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 测试驱动
|
||||
RESULTS = []
|
||||
|
||||
|
||||
def record(name, ok, detail):
|
||||
RESULTS.append((name, ok, detail))
|
||||
print(" [%s] %-28s %s" % ("PASS" if ok else "FAIL", name, detail))
|
||||
|
||||
|
||||
def collisions(**args):
|
||||
"""调用 find_collisions 并返回 (ok, envelope)。"""
|
||||
return payload(call_tool("find_collisions", args))
|
||||
|
||||
|
||||
def ratio_of(item):
|
||||
ov = item.get("overlap") or {}
|
||||
r = ov.get("ratio")
|
||||
return r if isinstance(r, (int, float)) else -1.0
|
||||
|
||||
|
||||
def print_top(env, top):
|
||||
items = env.get("items") or []
|
||||
if not items:
|
||||
print(" (无候选)")
|
||||
return
|
||||
print(" 前 %d 条候选(按重叠比降序):" % min(top, len(items)))
|
||||
for it in items[:top]:
|
||||
a, b = it.get("a") or {}, it.get("b") or {}
|
||||
print(" ratio=%.3f %s/%s × %s/%s 同层=%s" % (
|
||||
ratio_of(it), a.get("type"), a.get("handle"),
|
||||
b.get("type"), b.get("handle"), it.get("same_layer")))
|
||||
|
||||
|
||||
def main():
|
||||
top = 10
|
||||
if "--top" in sys.argv:
|
||||
try:
|
||||
top = int(sys.argv[sys.argv.index("--top") + 1])
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
frames_only = "--window" in sys.argv
|
||||
|
||||
print("=== find_collisions 运行时验证 ===")
|
||||
print("目标: %s:%d\n" % (HOST, PORT))
|
||||
|
||||
# 0) 连接 & 工具清单
|
||||
try:
|
||||
tools_resp = rpc("tools/list")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print("[错误] 无法连接 MCP 服务: %s" % e)
|
||||
print("请确认 CAD 已打开、插件已加载、8080 端口在监听。")
|
||||
return 2
|
||||
|
||||
names = [t["name"] for t in tools_resp.get("result", {}).get("tools", [])]
|
||||
print("工具总数: %d" % len(names))
|
||||
if "find_collisions" not in names:
|
||||
record("find_collisions 已注册", False, "tools/list 中不存在")
|
||||
return 1
|
||||
record("find_collisions 已注册", True, "tool 总数 %d" % len(names))
|
||||
|
||||
# 1) 全模型粗筛
|
||||
print("\n1) 全模型粗筛")
|
||||
ok, env = collisions(min_overlap=0.0, limit=200)
|
||||
if not ok:
|
||||
record("find_collisions 全模型", False, "%s" % env)
|
||||
return 1
|
||||
total = env.get("total", 0)
|
||||
ent = env.get("entity_count", 0)
|
||||
record("find_collisions 全模型", True,
|
||||
"entity_count=%s total 候选=%s returned=%s" % (ent, total, env.get("returned")))
|
||||
conts = env.get("excluded_containers") or []
|
||||
if conts:
|
||||
print(" 已剔除容器 %d 个(图框块 / 分区边界):" % len(conts))
|
||||
for c in conts:
|
||||
print(" %-8s %-14s reason=%s deg=%s" % (
|
||||
c.get("handle"), c.get("type"), c.get("reason"), c.get("degree")))
|
||||
else:
|
||||
print(" (未剔除容器)")
|
||||
record("容器过滤生效", len(conts) >= 1, "剔除 %d 个容器" % len(conts))
|
||||
print_top(env, top)
|
||||
|
||||
# ratio 单调性(返回项应降序)
|
||||
items = env.get("items") or []
|
||||
ratios = [ratio_of(i) for i in items]
|
||||
record("候选按重叠比降序", ratios == sorted(ratios, reverse=True), "%s" % ratios[:8])
|
||||
|
||||
# 2) min_overlap 阈值过滤
|
||||
print("\n2) min_overlap 阈值过滤")
|
||||
ok2, env2 = collisions(min_overlap=0.5, limit=200)
|
||||
t2 = env2.get("total", -1) if ok2 else -1
|
||||
record("min_overlap=0.5 过滤", ok2 and 0 <= t2 <= total,
|
||||
"total %s -> %s(阈值升高候选数不应增加)" % (total, t2))
|
||||
ok3, env3 = collisions(min_overlap=0.99, limit=200)
|
||||
t3 = env3.get("total", -1) if ok3 else -1
|
||||
record("min_overlap=0.99 过滤", ok3 and 0 <= t3 <= t2,
|
||||
"total %s -> %s" % (t2, t3))
|
||||
|
||||
# 3) 分页
|
||||
print("\n3) 分页(limit / offset)")
|
||||
ok4, page1 = collisions(min_overlap=0.0, limit=3, offset=0)
|
||||
ok5, page2 = collisions(min_overlap=0.0, limit=3, offset=3)
|
||||
r1 = page1.get("returned") if ok4 else -1
|
||||
r2 = page2.get("returned") if ok5 else -1
|
||||
same_head = (page1.get("items") or [])[:1] == (page2.get("items") or [])[:1]
|
||||
record("limit=3 首页", ok4 and r1 == min(3, total), "returned=%s" % r1)
|
||||
record("offset=3 次页", ok5 and r2 == min(3, max(0, total - 3)),
|
||||
"returned=%s(应与首页不同)" % r2)
|
||||
record("分页不重复", not same_head or total <= 3, "首页首条 != 次页首条")
|
||||
|
||||
# 4) narrow 细筛
|
||||
print("\n4) narrow 细筛(intersectWith)")
|
||||
ok6, envn = collisions(min_overlap=0.0, limit=5, narrow=True)
|
||||
nitems = envn.get("items") or [] if ok6 else []
|
||||
has_exact = all("exact" in i for i in nitems) if nitems else False
|
||||
exact_true = sum(1 for i in nitems if i.get("exact"))
|
||||
record("narrow 返回 exact 字段", ok6 and has_exact,
|
||||
"%d/%d 条 exact=true" % (exact_true, len(nitems)))
|
||||
|
||||
# 5) 逐图框
|
||||
if not frames_only:
|
||||
print("\n5) 逐图框候选")
|
||||
okf, fenv = payload(call_tool("detect_drawing_frames", {}))
|
||||
frames = (fenv.get("items") or []) if okf else []
|
||||
if not frames:
|
||||
record("detect_drawing_frames", False, "未识别到图框")
|
||||
else:
|
||||
print(" 识别到 %d 个图框" % len(frames))
|
||||
for idx, fr in enumerate(frames, 1):
|
||||
bbox = fr.get("bbox")
|
||||
if not bbox or len(bbox) != 4:
|
||||
record("图框%d 干涉" % idx, False, "bbox 缺失")
|
||||
continue
|
||||
win = {"min_x": bbox[0], "min_y": bbox[1],
|
||||
"max_x": bbox[2], "max_y": bbox[3]}
|
||||
okw, wev = collisions(window=win, min_overlap=0.5, limit=50)
|
||||
if not okw:
|
||||
record("图框%d 干涉" % idx, False, "%s" % wev)
|
||||
continue
|
||||
record("图框%d 干涉" % idx, True,
|
||||
"kind=%s entity_count=%s total(>=0.5)=%s" % (
|
||||
fr.get("kind"), wev.get("entity_count"), wev.get("total")))
|
||||
print_top(wev, min(top, 5))
|
||||
|
||||
# 6) 注入式正例:人为制造一处深度干涉,验证工具能报出(随后清理)
|
||||
if "--inject" in sys.argv:
|
||||
print("\n6) 注入式正例(人为干涉 → 应报出 → 清理)")
|
||||
# 放在所有图框之外(union max_x≈390012),避免与真实图元相交
|
||||
ax, ay = 500000.0, 0.0
|
||||
rectA = [[ax, ay], [ax + 400, ay], [ax + 400, ay + 400], [ax, ay + 400]]
|
||||
rectB = [[ax + 20, ay + 20], [ax + 420, ay + 20],
|
||||
[ax + 420, ay + 420], [ax + 20, ay + 420]]
|
||||
handles = []
|
||||
for name, pts in (("rectA", rectA), ("rectB", rectB)):
|
||||
okp, penv = payload(call_tool("add_polyline",
|
||||
{"points": pts, "closed": True}))
|
||||
hs = [c.get("handle") for c in (penv.get("created") or [])] if okp else []
|
||||
handles += [h for h in hs if h]
|
||||
record("注入 %s" % name, bool(okp and hs), "handle=%s" % hs)
|
||||
try:
|
||||
# 期望重叠比 ≈ 0.9025(380/400 的平方);min_overlap=0.5 应能命中
|
||||
oki, iev = collisions(min_overlap=0.5, limit=5)
|
||||
items = iev.get("items") or [] if oki else []
|
||||
top_item = items[0] if items else None
|
||||
got = None
|
||||
if top_item:
|
||||
ha = (top_item.get("a") or {}).get("handle")
|
||||
hb = (top_item.get("b") or {}).get("handle")
|
||||
if {ha, hb} == set(handles):
|
||||
got = top_item
|
||||
if got:
|
||||
record("报出注入干涉", True,
|
||||
"ratio=%.3f %s × %s" % (ratio_of(got),
|
||||
(got.get("a") or {}).get("handle"),
|
||||
(got.get("b") or {}).get("handle")))
|
||||
else:
|
||||
record("报出注入干涉", False,
|
||||
"首位候选不是注入对: %s" % (top_item and (
|
||||
(top_item.get("a") or {}).get("handle"),
|
||||
(top_item.get("b") or {}).get("handle"))))
|
||||
finally:
|
||||
if handles:
|
||||
okd, denv = payload(call_tool("delete_entities", {"handles": handles}))
|
||||
record("清理注入实体", bool(okd and denv.get("count") == len(handles)),
|
||||
"删除 %s / 共 %s" % (denv.get("count"), len(handles)))
|
||||
else:
|
||||
record("清理注入实体", False, "无 handle 可删")
|
||||
|
||||
# 汇总
|
||||
print("\n=== 结果汇总 ===")
|
||||
passed = sum(1 for _, ok, _ in RESULTS if ok)
|
||||
for name, ok, detail in RESULTS:
|
||||
print(" %-4s %-28s %s" % ("OK" if ok else "NG", name, detail))
|
||||
print("\n通过 %d / %d" % (passed, len(RESULTS)))
|
||||
return 0 if passed == len(RESULTS) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user