#!/usr/bin/env python3 """生成 KiCad 封装库 kicad/lib/PhoneRail.pretty/*.kicad_mod。 来源:EasyEDA 工程包里的 .efoo。**必须**从工程包转,不能用 easyeda2kicad 按 LCSC 拉 —— 本工程有一批自制封装(触点盘 PAD-D1.5_MI8-KEY、BM24、BAT-FPC 等)根本没有 LCSC 料号。 单位:EasyEDA PCB 用 **mil**(实测 C0402 焊盘 19.685 mil = 0.500 mm、间距 0.84 mm)。 Y 轴:PCB 侧两边都是 Y 向下,**不翻**(只有原理图才需要翻,见 k_gen_sch.py)。 字段布局(实测): ["PAD", id, ?, net, layer, number, x, y, rot, 孔, 形状, ...] 孔 ["ROUND", d, d] / ["SLOT", w, h] 形状 ["RECT", w, h, r] / ["OVAL", w, h] / ["ELLIPSE", w, h] / ["POLY", 路径] ["POLY", id, ?, "", layer, width, 路径, ?] ["FILL", id, ?, "", layer, width, ?, [路径 或 ["CIRCLE",x,y,r]], ?] 路径: [x, y, "L", x2, y2, ..., "ARC", 扫掠角, x2, y2] 或 ["CIRCLE", x, y, r] """ import json, glob, math, os, re, sys ROOT = os.path.dirname(os.path.abspath(__file__)) SRC = os.path.join(ROOT, "eda_src", "epro", "FOOTPRINT") OUTDIR = os.path.join(os.path.dirname(ROOT), "kicad", "lib", "PhoneRail.pretty") MIL = 0.0254 # EasyEDA 层号 -> KiCad 层。49/50/51 是 EasyEDA 的装配层装饰(引脚焊接标记等), # KiCad 里没有对应概念,带过去只会让封装变脏,故丢弃。 LAYER = {1: "F.Cu", 2: "B.Cu", 3: "F.SilkS", 4: "B.SilkS", 5: "F.Mask", 6: "B.Mask", 7: "F.Paste", 8: "B.Paste", 9: "F.Fab", 10: "B.Fab", 11: "Edge.Cuts", 13: "Dwgs.User", 14: "Cmts.User", 48: "F.CrtYd"} DROP = {49, 50, 51, 12, 52} def q(s): return '"' + str(s if s is not None else "").replace("\\", "\\\\").replace('"', '\\"') + '"' def mm(v): x = round(float(v) * MIL, 5) return (f"{x:.5f}".rstrip("0").rstrip(".")) or "0" def arc_mid(ax, ay, bx, by, sweep): """EasyEDA 的「起点 + 扫掠角 + 终点」-> KiCad 的三点弧,返回弧中点(原始 mil 单位)。""" th = math.radians(abs(sweep)) c = math.hypot(bx - ax, by - ay) if c < 1e-9 or th < 1e-9: return ((ax + bx) / 2, (ay + by) / 2) R = c / (2 * math.sin(th / 2)) if math.sin(th / 2) > 1e-9 else c h = math.sqrt(max(R * R - (c / 2) ** 2, 0.0)) mx, my = (ax + bx) / 2, (ay + by) / 2 nx, ny = -(by - ay) / c, (bx - ax) / c best = None for sign in (1, -1): cx, cy = mx + sign * nx * h, my + sign * ny * h a0 = math.atan2(ay - cy, ax - cx) a1 = a0 + math.radians(sweep) if abs((cx + R * math.cos(a1)) - bx) < 1e-3 and abs((cy + R * math.sin(a1)) - by) < 1e-3: am = a0 + math.radians(sweep) / 2 best = (cx + R * math.cos(am), cy + R * math.sin(am)) break return best or ((ax + bx) / 2, (ay + by) / 2) def walk_path(path): """路径 -> [('line',x1,y1,x2,y2) | ('arc',x1,y1,mx,my,x2,y2) | ('circle',x,y,r)]""" if path and path[0] == "CIRCLE": return [("circle", path[1], path[2], path[3])] out, i = [], 0 cx, cy = path[0], path[1] i = 2 while i < len(path): t = path[i] if t == "L": i += 1 while i + 1 < len(path) and not isinstance(path[i], str): out.append(("line", cx, cy, path[i], path[i + 1])) cx, cy = path[i], path[i + 1]; i += 2 elif t == "ARC": sweep, nx, ny = path[i + 1], path[i + 2], path[i + 3] mx, my = arc_mid(cx, cy, nx, ny, sweep) out.append(("arc", cx, cy, mx, my, nx, ny)) cx, cy = nx, ny; i += 4 else: i += 1 return out def gr(kind, layer, width, seg): w = mm(width) if width else "0.12" if seg[0] == "line": return (f'\t({kind}_line (start {mm(seg[1])} {mm(seg[2])}) (end {mm(seg[3])} {mm(seg[4])})\n' f'\t\t(stroke (width {w}) (type solid)) (layer {q(layer)})\n\t)') if seg[0] == "arc": return (f'\t({kind}_arc (start {mm(seg[1])} {mm(seg[2])}) (mid {mm(seg[3])} {mm(seg[4])}) ' f'(end {mm(seg[5])} {mm(seg[6])})\n' f'\t\t(stroke (width {w}) (type solid)) (layer {q(layer)})\n\t)') if seg[0] == "circle": return (f'\t({kind}_circle (center {mm(seg[1])} {mm(seg[2])}) ' f'(end {mm(seg[1] + seg[3])} {mm(seg[2])})\n' f'\t\t(stroke (width {w}) (type solid)) (fill no) (layer {q(layer)})\n\t)') return "" def pad_sexp(r): layer, num, x, y, rot = r[4], str(r[5]), r[6], r[7], r[8] or 0 hole, shape = r[9], r[10] th = (layer == 12) kind = "thru_hole" if th else "smd" layers = '"*.Cu" "*.Mask"' if th else '"F.Cu" "F.Paste" "F.Mask"' sh = shape[0] if isinstance(shape, list) else "RECT" extra, prims = "", "" if sh == "RECT": w, h = shape[1], shape[2] rad = shape[3] if len(shape) > 3 else 0 if rad: form = "roundrect" extra = f" (roundrect_rratio {round(min(rad / min(w, h), 0.5), 4)})" else: form = "rect" size = f"(size {mm(w)} {mm(h)})" elif sh in ("ELLIPSE", "OVAL"): w, h = shape[1], shape[2] form = "circle" if abs(w - h) < 1e-6 else "oval" size = f"(size {mm(w)} {mm(h)})" elif sh == "POLY": form = "custom" pts = shape[1] seq = [v for v in pts if not isinstance(v, str)] xs = seq[0::2]; ys = seq[1::2] size = f"(size {mm(max(1.0, (max(xs)-min(xs))/8))} {mm(max(1.0, (max(ys)-min(ys))/8))})" poly = " ".join(f"(xy {mm(px - x)} {mm(py - y)})" for px, py in zip(xs, ys)) extra = " (options (clearance outline) (anchor rect))" prims = (f'\n\t\t(primitives\n\t\t\t(gr_poly (pts {poly})\n' f'\t\t\t\t(width 0) (fill yes)\n\t\t\t)\n\t\t)') else: form = "rect"; size = "(size 0.5 0.5)" drill = "" if th and isinstance(hole, list): if hole[0] == "SLOT": drill = f" (drill oval {mm(hole[1])} {mm(hole[2])})" elif hole[1]: drill = f" (drill {mm(hole[1])})" at = f"(at {mm(x)} {mm(y)}{'' if not rot else ' ' + str(int(rot))})" return (f'\t(pad {q(num)} {kind} {form} {at} {size}{drill} (layers {layers}){extra}{prims}\n\t)') def convert(path): rows = [json.loads(l) for l in open(path, encoding="utf-8") if l.strip()] head = next((r[1] for r in rows if r[0] == "HEAD"), {}) name = head.get("title") or os.path.basename(path)[:-5] body, npad, warn = [], 0, [] has_th = False for r in rows: t = r[0] if t == "PAD": body.append(pad_sexp(r)); npad += 1 has_th |= (r[4] == 12) elif t == "POLY": lay = LAYER.get(r[4]) if r[4] in DROP or not lay: continue for seg in walk_path(r[6]): body.append(gr("fp", lay, r[5], seg)) elif t == "FILL": lay = LAYER.get(r[4]) if r[4] in DROP or not lay: continue for shp in (r[7] or []): for seg in walk_path(shp): body.append(gr("fp", lay, r[5], seg)) out = ["(footprint " + q(name), "\t(version 20260206)", f"\t(generator {q('phonerail-eda2kicad')})", '\t(generator_version "10.0")', '\t(layer "F.Cu")', f'\t(descr {q("由 EasyEDA 工程「五路手机直供电源板」转换")})', f'\t(attr {"through_hole" if has_th else "smd"})', f'\t(property "Reference" "REF**"\n\t\t(at 0 -2 0)\n\t\t(layer "F.SilkS")\n' f'\t\t(effects (font (size 1 1) (thickness 0.15)))\n\t)', f'\t(property "Value" {q(name)}\n\t\t(at 0 2 0)\n\t\t(layer "F.Fab")\n' f'\t\t(effects (font (size 1 1) (thickness 0.15)))\n\t)'] return name, "\n".join(out + body + [")"]) + "\n", npad def main(): os.makedirs(OUTDIR, exist_ok=True) seen, n = {}, 0 for f in sorted(glob.glob(os.path.join(SRC, "*.efoo"))): name, txt, npad = convert(f) safe = re.sub(r'[<>:"/\|?*\r\n]', "_", name) if safe in seen: continue # 同名封装在两块板里各存了一份,内容一致 seen[safe] = npad open(os.path.join(OUTDIR, safe + ".kicad_mod"), "w", encoding="utf-8", newline="\n").write(txt) n += 1 print(f"封装 {n} 个 -> {OUTDIR}") return seen if __name__ == "__main__": main()