Files
cad-agent/verify_collisions.py
T
gjm d4a9442e30 feat(review): 几何干涉检查 find_collisions(BVH 粗筛 + 容器过滤)
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:<图框>},未误报
2026-10-16 19:46:44 +08:00

268 lines
11 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.
# -*- 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())