# -*- 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())