#!/usr/bin/env python3 """几何反推 EasyEDA 原理图当前页的网表,检查悬空引脚与孤立网络。 EasyEDA 不通过 API 暴露编译后的网表(引脚没有 getState_Net,新建导线的 getState_Net 恒为空串),所以只能按几何自己推。 用法: python verify_sch.py # 自动扫描 49620-49629 找桥接服务 python verify_sch.py --port 49620 python verify_sch.py --nets # 同时打印每个网络包含的引脚 python verify_sch.py --nc nc.json # 忽略清单里"有意不接"的引脚 python verify_sch.py --dump geom.json # 只抓几何数据存文件,不分析 退出码: 0 = 全部通过, 1 = 发现悬空引脚或单引脚网络, 2 = 连不上桥接服务 """ import argparse import json import sys import urllib.error import urllib.request from collections import defaultdict TOL = 0.6 # 单位 0.01 inch;坐标都对齐到 5 的倍数,0.6 足够宽松也不会误判 FETCH_JS = ( "const ws=await eda.sch_PrimitiveWire.getAll();" "const wires=ws.map(w=>w.getState_Line());" "const comps=await eda.sch_PrimitiveComponent.getAll();" "const parts=[];" "for(const c of comps){" " const d=c.getState_Designator();" " const pins=await c.getAllPins();" " let nn=null; try{nn=c.getState_Net?c.getState_Net():null;}catch(e){}" " parts.push({d:d===undefined?null:d,t:c.getState_ComponentType(),net:nn," " p:(pins||[]).map(q=>[q.getState_PinNumber(),Math.round(q.getState_X()),Math.round(q.getState_Y())])});" "}" "return {wires,parts};" ) def find_port(explicit=None): ports = [explicit] if explicit else range(49620, 49630) for p in ports: try: with urllib.request.urlopen(f"http://127.0.0.1:{p}/health", timeout=1) as r: if json.loads(r.read()).get("service") == "easyeda-bridge": return p except Exception: continue return None def fetch(port): req = urllib.request.Request( f"http://127.0.0.1:{port}/execute", data=json.dumps({"code": FETCH_JS}).encode(), headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(req, timeout=90) as r: body = json.loads(r.read()) if not body.get("success"): raise RuntimeError(body.get("error") or body) return body["result"] def to_segments(wires): """getState_Line() 返回的是端点对,不是折线路径 —— 每两个点一段。 当成折线解析会凭空造出不存在的斜线段,进而误报短路。 """ segs = [] for w in wires: pts = [(round(w[i]), round(w[i + 1])) for i in range(0, len(w), 2)] for i in range(0, len(pts) - 1, 2): segs.append((pts[i], pts[i + 1])) return segs def on_seg(p, a, b, tol=TOL): (px, py), (ax, ay), (bx, by) = p, a, b dx, dy = bx - ax, by - ay l2 = dx * dx + dy * dy if l2 == 0: return abs(px - ax) <= tol and abs(py - ay) <= tol t = ((px - ax) * dx + (py - ay) * dy) / l2 if t < -1e-9 or t > 1 + 1e-9: return False return abs(px - (ax + t * dx)) <= tol and abs(py - (ay + t * dy)) <= tol class DSU: def __init__(self): self.p = {} def find(self, x): self.p.setdefault(x, x) while self.p[x] != x: self.p[x] = self.p[self.p[x]] x = self.p[x] return x def union(self, a, b): ra, rb = self.find(a), self.find(b) if ra != rb: self.p[ra] = rb def analyse(data, show_nets=False, nc=None): nc = nc or {} segs = to_segments(data["wires"]) parts = data["parts"] def label(c): if c["d"]: return c["d"] return "[" + (c.get("net") or c.get("t") or "flag") + "]" floating, nc_hit = [], [] for c in parts: for n, x, y in c["p"]: if any(on_seg((x, y), a, b) for a, b in segs): continue (nc_hit if str(n) in nc.get(c["d"] or "", []) else floating).append( f"{label(c)}.{n}@({x},{y})") dsu = DSU() for a, b in segs: dsu.union(a, b) # T 型连接:某段的端点落在另一段内部才算相连;纯交叉(无端点)不连 for a, b in segs: for c, e in segs: if (c, e) == (a, b): continue for p in (a, b): if on_seg(p, c, e): dsu.union(p, c) dsu.union(p, e) nets = defaultdict(list) for c in parts: for n, x, y in c["p"]: for a, b in segs: if on_seg((x, y), a, b): tag = label(c) if c["d"] else label(c) nets[dsu.find(a)].append(tag if not c["d"] else f"{tag}.{n}") break singles = [v for v in nets.values() if len(v) == 1] # 名字层面的检查:几何连对了,网络名仍可能是错的。 # 移动 netflag / netport 会把网络名重置成符号默认值(Power -> "+5V",netport -> "")。 nameless = [f'{c["t"]}@({c["p"][0][1]},{c["p"][0][2]})' for c in parts if not c["d"] and c["p"] and not (c.get("net") or "").strip()] # 每个网络里出现的旗/端口名字必须一致,否则是两个不同名字被短在一起 conflicts = [] for _, v in nets.items(): names = {x[1:-1] for x in v if x.startswith("[") and x.endswith("]")} if len(names) > 1: conflicts.append(sorted(names)) n_parts = sum(1 for c in parts if c["d"]) print(f"元件 {n_parts} 导线段 {len(segs)} 局部网络 {len(nets)}") print(f"悬空引脚: {len(floating)}" + (f"(另有 {len(nc_hit)} 个已在 NC 清单里,有意不接)" if nc_hit else "")) for f in floating: print(f" ! {f}") print(f"单引脚网络: {len(singles)}") for s in singles: print(f" ! {s[0]}") print(f"无网络名的旗/端口: {len(nameless)}") for s in nameless: print(f" ! {s}") print(f"同一网络出现多个名字: {len(conflicts)}") for s in conflicts: print(f" ! {' <-> '.join(s)}") used = defaultdict(int) for _, v in nets.items(): for x in v: if x.startswith("[") and x.endswith("]"): used[x[1:-1]] += len(v) - 1 print("\n网络名清单(核对有没有笔误或意外的默认名,例如 +5V):") for n, cnt in sorted(used.items(), key=lambda z: (-z[1], z[0])): print(f" {n:<10s} {cnt} 个引脚") if show_nets: print("\n网络明细(按引脚数降序):") for _, v in sorted(nets.items(), key=lambda z: -len(z[1])): print(f" [{len(v):2d}] " + " ".join(sorted(v))) ok = not floating and not singles and not nameless and not conflicts print("\n" + ("PASS —— 几何连接完整。仍需人眼截图确认方向与排版。" if ok else "FAIL —— 见上面标 ! 的项。")) return ok def main(): ap = argparse.ArgumentParser() ap.add_argument("--port", type=int) ap.add_argument("--nets", action="store_true", help="打印每个网络的引脚清单") ap.add_argument("--dump", metavar="FILE", help="把几何数据存成 JSON,不做分析") ap.add_argument("--load", metavar="FILE", help="从 JSON 文件读,不连桥接服务") ap.add_argument("--nc", metavar="FILE", help='有意不接的引脚清单 JSON:{"U20": ["27","15"], ...},' "这些引脚不计入悬空") args = ap.parse_args() if args.load: data = json.load(open(args.load, encoding="utf-8")) data = data.get("result", data) else: port = find_port(args.port) if port is None: print("找不到 easyeda-bridge(已扫 49620-49629)。" "先启动 bridge-server.mjs 并在 EasyEDA 里加载 run-api-gateway 扩展。", file=sys.stderr) return 2 data = fetch(port) if args.dump: json.dump(data, open(args.dump, "w", encoding="utf-8"), ensure_ascii=False) print(f"已写入 {args.dump}") return 0 nc = json.load(open(args.nc, encoding="utf-8")) if args.nc else {} return 0 if analyse(data, args.nets, nc) else 1 if __name__ == "__main__": sys.exit(main())