"""cmp_vec.py — Altium 与 KiCad 的离散版图几何等价判定(阶段 3 的决定性证据)。 为什么这个比栅格 Gerber XOR 更权威:跨工具的 Gerber 不可能像素级一致 (aperture / 坐标格式 / 填充算法 / 字体 / 负片都不同),栅格 XOR 只能给出 「像什么程度」。而走线/过孔/焊盘是离散图元,可以逐条逐个匹配 —— 匹配上了 就是同一块板,匹配不上就是真差异,没有解释空间。 坐标变换只有两个自由参数,不是曲线拟合: kx = s*ax + tx ky = -s*ay + ty (Altium y 向上,KiCad y 向下) s 由单位固定(Altium API 返回整数 mil -> mm 就是 0.0254),(tx,ty) 由两侧过孔 质心解出。用 2 个参数去对上几千个过孔+焊盘+走线,对上了就是实证。 容差默认 30um:eda-agent 读出的 Altium 坐标是**整数 mil**,读数精度本身就有 ±12.7um,两端各一次就接近 25um。KiCad 侧是精确的(导入器直接解析二进制 PcbDoc)。 用法: python cmp_vec.py --altium-vec .../altium_vec --kicad-vec .../kicad_vec \\ --layer-map .../layer_map.json [--tol-mm 0.030] [--report r.json] 退出码 0 = 全部图元匹配;1 = 有未匹配图元。 """ from __future__ import annotations import argparse import collections import json import math import sys from pathlib import Path def load(path: Path, *containers: str): """容忍两种外壳:裸 list,或 {"vias": [...]} / {"objects": [...]}。""" data = json.loads(path.read_text(encoding="utf-8")) if isinstance(data, dict): for key in containers + ("objects", "vias", "items"): if key in data and isinstance(data[key], list): return data[key] raise SystemExit(f"[ERROR] {path} 是 dict 但找不到列表字段(试过 {containers + ('objects',)})") return data def num(v) -> float: return float(v) def match(a_pts, k_pts, tol, label, out_lines): """网格加速的最近邻一对一匹配。返回 (未匹配的A, 未匹配的K)。""" if not a_pts and not k_pts: return 0, 0 cell = max(tol * 4, 0.05) grid = collections.defaultdict(list) for i, p in enumerate(k_pts): grid[(int(p[0] / cell), int(p[1] / cell))].append(i) used, res, unmatched = set(), [], 0 for p in a_pts: gx, gy = int(p[0] / cell), int(p[1] / cell) best, bd = None, 1e9 for dx in (-1, 0, 1): for dy in (-1, 0, 1): for i in grid.get((gx + dx, gy + dy), ()): if i in used: continue d = math.hypot(k_pts[i][0] - p[0], k_pts[i][1] - p[1]) if d < bd: bd, best = d, i if best is None or bd > tol: unmatched += 1 else: used.add(best) res.append(bd) line = (f"{label}: altium={len(a_pts)} kicad={len(k_pts)} matched={len(res)} " f"unmatched_altium={unmatched} unmatched_kicad={len(k_pts) - len(used)}") if res: res.sort() line += (f"\n residual mm: max={res[-1]:.6f} " f"p99={res[min(int(len(res) * 0.99), len(res) - 1)]:.6f} " f"median={res[len(res) // 2]:.6f}") print("\n" + line) out_lines.append(line) return unmatched, len(k_pts) - len(used) def suggest_layer_map(a_tracks, k_tracks): """没给 layer_map 时,按每层走线条数给个候选映射,供人核对。""" a_cnt = collections.Counter(str(t.get("Layer", t.get("layer"))) for t in a_tracks) k_cnt = collections.Counter(t["layer"] for t in k_tracks) print("\n[layer] Altium 各层走线数: " + json.dumps(a_cnt.most_common(), ensure_ascii=False)) print("[layer] KiCad 各层走线数: " + json.dumps(k_cnt.most_common(), ensure_ascii=False)) print("[layer] 没有 --layer-map。请对照上面两组计数写一个 layer_map.json,例如\n" ' {"TopLayer": "F.Cu", "MidLayer1": "In2.Cu", "BottomLayer": "B.Cu"}\n' " 注意层序按叠层走,不要想当然 MidLayerN -> InN.Cu。") def main() -> int: ap = argparse.ArgumentParser(description="Vector geometry equivalence: Altium vs KiCad") ap.add_argument("--altium-vec", required=True) ap.add_argument("--kicad-vec", required=True) ap.add_argument("--layer-map", help="{Altium层名: KiCad层名} 的 JSON;不给则只比过孔和焊盘并给出建议") ap.add_argument("--tol-mm", type=float, default=0.030) ap.add_argument("--scale-to-mm", type=float, help="Altium 原始单位 -> mm。默认读 altium_vec/meta.json,再默认 0.0254(mil)") ap.add_argument("--report", help="把结论写成 JSON") args = ap.parse_args() av, kv = Path(args.altium_vec), Path(args.kicad_vec) scale = args.scale_to_mm if scale is None: meta_path = av / "meta.json" if meta_path.is_file(): scale = json.loads(meta_path.read_text(encoding="utf-8")).get("scale_to_mm") scale = scale or 0.0254 print(f"scale_to_mm : {scale} tol_mm : {args.tol_mm}") a_vias = load(av / "vias.json", "vias") k_vias = load(kv / "vias.json", "vias") print(f"vias: altium={len(a_vias)} kicad={len(k_vias)}") if not a_vias or not k_vias: raise SystemExit("[ERROR] 两侧都必须有过孔才能标定平移(过孔是最干净的基准)。") if len(a_vias) != len(k_vias): print("[WARN] 两侧过孔数不同 —— 质心标定会被拉偏。先查清是导入丢了过孔," "还是 Altium 侧导出漏了层,再比。") ax = sum(num(v["x"]) * scale for v in a_vias) / len(a_vias) ay = sum(num(v["y"]) * scale for v in a_vias) / len(a_vias) kx = sum(v["x"] for v in k_vias) / len(k_vias) ky = sum(v["y"] for v in k_vias) / len(k_vias) TX, TY = kx - ax, ky + ay print(f"transform : kx = ax*{scale} + {TX:.6f} ky = -ay*{scale} + {TY:.6f}") def T(o, kx_="x", ky_="y"): return (num(o[kx_]) * scale + TX, TY - num(o[ky_]) * scale) lines: list[str] = [] bad = 0 skipped_tracks = False bad += sum(match([T(v) for v in a_vias], [(v["x"], v["y"]) for v in k_vias], args.tol_mm, "VIAS", lines)) a_pads_p, k_pads_p = av / "pads.json", kv / "pads.json" if a_pads_p.is_file() and k_pads_p.is_file(): a_pads, k_pads = load(a_pads_p), load(k_pads_p) bad += sum(match([T(p, "X", "Y") for p in a_pads], [(p["x"], p["y"]) for p in k_pads], args.tol_mm, "PADS", lines)) a_tr_p, k_tr_p = av / "tracks.json", kv / "tracks.json" if a_tr_p.is_file() and k_tr_p.is_file(): a_tr, k_tr = load(a_tr_p), load(k_tr_p) if not args.layer_map: skipped_tracks = True suggest_layer_map(a_tr, k_tr) else: laymap = json.loads(Path(args.layer_map).read_text(encoding="utf-8")) a_cu = [t for t in a_tr if str(t.get("Layer")) in laymap] print(f"\naltium tracks total={len(a_tr)} on mapped copper={len(a_cu)} " f"kicad copper tracks={len(k_tr)}") def mid_a(t): p1, p2 = T(t, "X1", "Y1"), T(t, "X2", "Y2") return ((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2) for alay, klay in sorted(laymap.items()): # 逐层比,否则相邻层上位置重合的走线会互相错配 aa = [mid_a(t) for t in a_cu if str(t.get("Layer")) == alay] kk = [((t["x1"] + t["x2"]) / 2, (t["y1"] + t["y2"]) / 2) for t in k_tr if t["layer"] == klay] if not aa and not kk: continue bad += sum(match(aa, kk, args.tol_mm, f"TRACKS {alay} -> {klay}", lines)) equivalent = bad == 0 and not skipped_tracks print("\n" + "=" * 60) print(f"TOTAL unmatched primitives: {bad}" + (" (走线未比对)" if skipped_tracks else "")) print("=" * 60) if skipped_tracks: print("[INCOMPLETE] 没给 --layer-map,走线整个跳过了 —— 上面这个 0 只覆盖过孔和焊盘," "不能当作等价结论。按上面的层计数写好 layer_map.json 再跑一次。") elif equivalent: print("离散几何 100% 等价 —— 走线/过孔/焊盘逐条对上。铺铜纹理、丝印字形、" "负片内电层的差异是工具固有项,不计入本判定(理由见 references\\playbook.md)。") if args.report: Path(args.report).write_text(json.dumps({ "unmatched_total": bad, "equivalent": equivalent, "tracks_compared": not skipped_tracks, "scale_to_mm": scale, "tol_mm": args.tol_mm, "transform": {"tx": TX, "ty": TY}, "details": lines, }, ensure_ascii=False, indent=2), encoding="utf-8") print(f"[report] {args.report}") return 0 if equivalent else 1 if __name__ == "__main__": try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass sys.exit(main())