Files
2026-09-09 09:51:06 +08:00

201 lines
9.0 KiB
Python
Raw Permalink 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.
"""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())