sync: HP_BOWEI 2026-09-09

This commit is contained in:
Myfro9
2026-09-09 09:51:06 +08:00
parent 01b7a00b12
commit 170f4eb53f
10 changed files with 1926 additions and 5 deletions
+200
View File
@@ -0,0 +1,200 @@
"""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())