C++(cad_mcp_plugins):
- 新增 bvh.h:header-only 2D BVH(质心中位分割,叶<=4,SelfPairs 交叉对枚举)
- 新增 find_collisions.cpp:粗筛 AABB 相交实体对,附重叠盒 + 逐轴重叠比 ratio
(= 逐轴重叠长/较短长 之积,退化轴取 1.0),按 ratio 降序 + 分页;
narrow=true 只对返回页跑 intersectWith 细筛(附 exact)
- 容器过滤(关键):图框块/分区边界会与一切相交,用重叠【度】判定
deg >= max(exclude_container_deg=8, exclude_container_frac=0.25 * 其他实体数),
与图幅/缩放无关;被剔除者在 excluded_containers 里如实报告
(初版用面积判据对多图框并排失效:3 框只占并集 27/13/7% 全部漏过,
全模型 163 对噪声;改度判据后图框 deg=35/25/23 vs 其余 deg<=3,分离干净)
- cmakelists.txt + 两个 mcp_config.json 注册(28 工具)
agent 侧(Python):
- cad_tools.READ_ONLY_TOOLS += find_collisions(受控子循环可调用)
- checks.check_collisions + run_deterministic_checks(受 CHECK_COLLISIONS 开关,默认关)
- config += CHECK_COLLISIONS=False / COLLISION_MIN_OVERLAP=0.5
- checklist.py / review_checklist.json += 「干涉/碰撞」类别(high)+ 提示词协议
(先 find_collisions 拿候选 → inspect_region 目视确认;AABB 相交 != 真干涉)
验证:
- verify_collisions.py 17/17 PASS:全模型候选 163→80(全部 ratio=0.000),
min_overlap=0.5 → 0 对;逐图框 0 对;narrow top-5 中 4 条 exact=true;
--inject 注入深重叠矩形报出 ratio=0.902 并清理 2/2
- pytest 355 passed(新增 12 项)
- 端到端:LLM 主动调用 find_collisions{min_overlap:0.2,narrow:true,window:<图框>},未误报
268 lines
11 KiB
Python
268 lines
11 KiB
Python
# -*- 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())
|