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