#!/usr/bin/env python3 """从 EasyEDA 几何数据生成 KiCad 原理图工程(每块板一个工程,层级页)。 坐标变换(三条实测依据,见 easyeda-sch-geometry skill): · EasyEDA 原理图 Y 轴**向上**,KiCad .kicad_sch 页面 Y 轴**向下** -> 放置时翻 Y; 而 .kicad_sym 和 EasyEDA 符号同为 Y 向上 -> 符号内部图形不翻。 两者合起来使得「按同一坐标摆符号 + 画同样导线」得到同样的连接性。 · 旋转是 Y-up 下的**逆时针**,KiCad 角度 = EasyEDA 角度(503/508 个元件实测)。 · 镜像是在旋转**之后**翻 X,等价于 KiCad 的 (mirror y) + 角度取 (360-θ)。 元素映射: part -> (symbol) lib_id "PhoneRail:<符号名>" netflag -> (symbol) 电源符号 引脚在原点,rot 1:1 netport -> (global_label) KiCad 靠同名全局标签跨页连接,正是 netport 的语义 wire -> (wire) + 必要的 (junction) Text -> (text) """ import json, os, re, sys, uuid, collections sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import k_labels ROOT = os.path.dirname(os.path.abspath(__file__)) SRC = os.path.join(ROOT, "eda_src") OUT = os.path.join(os.path.dirname(ROOT), "kicad") LIB = "PhoneRail" MM = 0.254 FONT_K = 0.15875 # EasyEDA 字号 8 -> KiCad 默认 1.27 mm MARGIN = 12.7 NS = uuid.UUID("6f1d5a0e-9c3b-4a71-8e2d-phonerail00".replace("phonerail00", "0f1e2d3c4b5a")) PAPERS = [("A4", 297, 210), ("A3", 420, 297), ("A2", 594, 420), ("A1", 841, 594), ("A0", 1189, 841)] PROJ = {"Board1": "PhoneRail-Board1", "Board2": "PhoneRail-Board2"} # netport 的 dir + rotation -> 标签朝向(skill §8b 实测:IN 和 BI/OUT 相反) ADD_NC = "--nc" in sys.argv PORT_SHAPE = {"Netport-BI": "bidirectional", "Netport-OUT": "output", "Netport-IN": "input"} def U(*parts): """确定性 UUID:重跑生成器不会打乱 KiCad 的实例路径""" return str(uuid.uuid5(NS, "|".join(str(p) for p in parts))) def q(s): return '"' + str(s if s is not None else "").replace("\\", "\\\\").replace('"', '\\"') + '"' def f(v): x = round(float(v), 4) return (f"{x:.4f}".rstrip("0").rstrip(".")) or "0" # ---------- 从生成好的符号库里取出各符号定义,内嵌进每页的 lib_symbols ---------- def load_lib_symbols(): p = os.path.join(OUT, "lib", LIB + ".kicad_sym") s = open(p, encoding="utf-8").read() out = {} for m in re.finditer(r'^\t\(symbol "([^"]+)"', s, re.M): name = m.group(1) i = m.start(); d = 0 for j in range(i, len(s)): if s[j] == "(": d += 1 elif s[j] == ")": d -= 1 if d == 0: break out[name] = s[i:j + 1] return out def embed(block, name): """把库里的 (symbol "NAME" ...) 改成页面 lib_symbols 里的 (symbol "LIB:NAME" ...) 并多缩进一层""" # 只有**最外层**符号名带库前缀;内层 NAME_0_1 / NAME_1_1 必须保持原样, # 加了前缀 KiCad 会直接「加载原理图失败」(2026-09-07 实测) block = block.replace(f'(symbol "{name}"', f'(symbol "{LIB}:{name}"', 1) return "\n".join("\t" + ln for ln in block.splitlines()) # ---------- 页面数据 ---------- def page_data(board, page): return json.load(open(os.path.join(SRC, board, page + ".geom.json"), encoding="utf-8")) def netlist_props(board): """位号 -> 解析后的属性(Name/MPN/厂商/LCSC/Datasheet)。用编译后的网表,公式已求值。""" d = json.load(open(os.path.join(SRC, board, "netlist.json"), encoding="utf-8")) out = {} for _, c in d["components"].items(): p = c.get("props", {}) if p.get("Designator"): out[p["Designator"]] = p return out def segments(line): return [((line[i], line[i + 1]), (line[i + 2], line[i + 3])) for i in range(0, len(line) - 3, 4) if (line[i], line[i + 1]) != (line[i + 2], line[i + 3])] def transform_for(g): xs, ys = [], [] for c in (g["prims"]["Component"] or []): if c["ComponentType"] == "sheet": continue # 图框不参与,KiCad 用自己的图框 xs.append(c["X"]); ys.append(c["Y"]) for p in (c.get("__pins") or []): xs.append(p["X"]); ys.append(p["Y"]) for w in (g["prims"]["Wire"] or []): for a, b in segments(w.get("Line") or []): xs += [a[0], b[0]]; ys += [a[1], b[1]] for t in (g["prims"]["Text"] or []): xs.append(t["X"]); ys.append(t["Y"]) if not xs: xs, ys = [0], [0] x0 = (min(xs) // 5) * 5 y1 = -((-max(ys)) // 5) * 5 # 向上取到 5 的倍数,保持 1.27 mm 栅格 w = (max(xs) - x0) * MM + 2 * MARGIN h = (y1 - min(ys)) * MM + 2 * MARGIN paper = next((p for p in PAPERS if w <= p[1] and h <= p[2]), PAPERS[-1]) return (x0, y1), paper[0], (w, h) def mk(off): x0, y1 = off return (lambda x: round((x - x0) * MM + MARGIN, 4), lambda y: round((y1 - y) * MM + MARGIN, 4)) # ---------- 各元素的 S-expression ---------- def sx_wire(a, b, uid): return (f'\t(wire (pts (xy {f(a[0])} {f(a[1])}) (xy {f(b[0])} {f(b[1])}))\n' f'\t\t(stroke (width 0) (type default))\n\t\t(uuid {q(uid)})\n\t)') def sx_junction(p, uid): return (f'\t(junction (at {f(p[0])} {f(p[1])}) (diameter 0) (color 0 0 0 0)\n' f'\t\t(uuid {q(uid)})\n\t)') def sx_text(t, tx, ty, uid): size = max(round(float(t.get("FontSize") or 8) * FONT_K, 3), 0.5) return (f'\t(text {q(t["Content"])}\n' f'\t\t(at {f(tx(t["X"]))} {f(ty(t["Y"]))} {int(t.get("Rotation") or 0)})\n' f'\t\t(effects (font (size {f(size)} {f(size)})) (justify left bottom))\n' f'\t\t(uuid {q(uid)})\n\t)') def sx_glabel(c, tx, ty, uid): shape = PORT_SHAPE.get((c.get("Component") or {}).get("name"), "bidirectional") rot = int(c.get("Rotation") or 0) % 360 # IN 的符号体在引脚背面(skill §8b),映射到 KiCad 就是角度反过来 ang = (rot + 180) % 360 if shape == "input" else rot just = "right" if ang == 180 else "left" return (f'\t(global_label {q(c.get("Net"))}\n' f'\t\t(shape {shape})\n' f'\t\t(at {f(tx(c["X"]))} {f(ty(c["Y"]))} {ang})\n' f'\t\t(fields_autoplaced yes)\n' f'\t\t(effects (font (size 1.27 1.27)) (justify {just}))\n' f'\t\t(uuid {q(uid)})\n\t)') def sx_symbol(lib_id, x, y, rot, mirror, uid, props, path, ref, unit=1): # KiCad 的字段渲染角 = 存储角 + 元件旋转(对照官方 demo:rot=270 的电容 Reference # 存的就是 90,净得 0 -> 水平;rot=180 的存 0)。存 0 会让 90/270 的标签竖排互压, # 而 180 上补 180 会让文字镜像 —— 所以只对 90/270 补角。 fang = (360 - rot) % 360 if rot in (90, 270) else 0 L = [f'\t(symbol', f'\t\t(lib_id {q(lib_id)})', f'\t\t(at {f(x)} {f(y)} {rot})'] if mirror: L.append('\t\t(mirror y)') L += [f'\t\t(unit {unit})', '\t\t(exclude_from_sim no)', '\t\t(in_bom yes)', '\t\t(on_board yes)', '\t\t(dnp no)', f'\t\t(uuid {q(uid)})'] for k, v, hide, pos in props: h = " (hide yes)" if hide else "" # pos 是数字 = 相对 y 偏移;是二元组 = 绝对坐标。用 EasyEDA 里标签的真实位置, # 否则密集区的位号会叠成一团(P1B-5V 的 C731/C733 第一版就是这样)。 ax, ay = (x, y + pos) if not isinstance(pos, tuple) else pos # EasyEDA 的标签锚点是左下,KiCad 默认居中;不对齐的话位号会压在符号体上 just = ' (justify left bottom)' if isinstance(pos, tuple) else '' L.append(f'\t\t(property {q(k)} {q(v)}\n' f'\t\t\t(at {f(ax)} {f(ay)} {fang})\n' f'\t\t\t(effects (font (size 1.27 1.27)){h}{just})\n\t\t)') L += ['\t\t(instances', f'\t\t\t(project {q(path[0])}', f'\t\t\t\t(path {q(path[1])}', f'\t\t\t\t\t(reference {q(ref)})', f'\t\t\t\t\t(unit {unit})', '\t\t\t\t)', '\t\t\t)', '\t\t)', '\t)'] return "\n".join(L) def junctions(segs): """KiCad 里导线端点落在另一条导线**中间**时必须有节点才导通;三线共点也补上。""" cnt = collections.Counter() for a, b in segs: cnt[a] += 1; cnt[b] += 1 js = {p for p, n in cnt.items() if n >= 3} pts = set(cnt) for a, b in segs: ax, ay = a; bx, by = b for p in pts: if p == a or p == b: continue px, py = p if abs(ax - bx) < 1e-6: # 竖直 if abs(px - ax) < 1e-6 and min(ay, by) < py < max(ay, by): js.add(p) elif abs(ay - by) < 1e-6: # 水平 if abs(py - ay) < 1e-6 and min(ax, bx) < px < max(ax, bx): js.add(p) else: t = (px - ax) / (bx - ax) if 0 < t < 1 and abs(ay + t * (by - ay) - py) < 1e-6: js.add(p) return sorted(js) # ---------- 生成一页 ---------- def gen_page(board, page, g, symnames, powers, mirrors, libsyms, proj, rootuid, sheetuid, props, pwr): off, paper, _ = transform_for(g) tx, ty = mk(off) labels = k_labels.page_labels(board, page) used, body = set(), [] segs = [] for w in (g["prims"]["Wire"] or []): for a, b in segments(w.get("Line") or []): segs.append(((tx(a[0]), ty(a[1])), (tx(b[0]), ty(b[1])))) for i, (a, b) in enumerate(segs): body.append(sx_wire(a, b, U(board, page, "w", i))) for i, p in enumerate(junctions(segs)): body.append(sx_junction(p, U(board, page, "j", i))) for c in (g["prims"]["Component"] or []): t = c["ComponentType"] pid = c["PrimitiveId"] if t == "sheet": continue if t == "netport": body.append(sx_glabel(c, tx, ty, U(board, page, "gl", pid))) continue rot = int(c.get("Rotation") or 0) % 360 mirror = bool(c.get("Mirror")) if mirror: # EasyEDA 的镜像 = 先按 θ 旋转、再翻 X。由 flipX∘R(θ) = R(-θ)∘flipX, # 等价于「用镜像过的符号 + 转 (360-θ)」。不用 KiCad 的 (mirror y), # 那条路 2026-09-07 实测会把 U24~U28 的引脚摆到错误位置。 rot = (360 - rot) % 360 x, y = tx(c["X"]), ty(c["Y"]) if t == "netflag": name = powers.get(c.get("Net")) if not name: continue used.add(name) # KiCad 的电源符号也必须有唯一位号,否则整个工程算「未批注」, # 网表导出会直接被拒(2026-09-07 实测:报「原理图存在批注错误」且 0 元件) pwr[0] += 1 ref = f"#PWR{pwr[0]:04d}" body.append(sx_symbol(f"{LIB}:{name}", x, y, rot, mirror, U(board, page, "pf", pid), [("Reference", ref, True, 0), ("Value", c.get("Net"), False, -3.81 if rot == 0 else 3.81)], (proj, f"/{rootuid}/{sheetuid}"), ref)) continue # 元件 suuid = (c.get("Symbol") or {}).get("uuid") name = (mirrors.get(suuid) if mirror else None) or symnames.get(suuid) if mirror and not mirrors.get(suuid): raise SystemExit(f"!! {board}/{page} {c.get('Designator')} 是镜像件但库里没有镜像变体") if not name: raise SystemExit(f"!! {board}/{page} {c.get('Designator')} 找不到符号 {c.get('Symbol')}") used.add(name) ref = c.get("Designator") or "?" pr = props.get(ref, {}) val = pr.get("Name") or c.get("ManufacturerId") or name fp = (c.get("Footprint") or {}).get("name") or "" lab = labels.get(pid, {}) dpos = lab.get("Designator") npos = lab.get("Name") rpos = (tx(dpos[0]), ty(dpos[1])) if dpos else -5.08 vpos = (tx(npos[0]), ty(npos[1])) if npos else 5.08 plist = [("Reference", ref, False, rpos), ("Value", val, not (npos[2] if npos else True), vpos), ("Footprint", f"{LIB}:{fp}" if fp else "", True, 0), ("Datasheet", pr.get("Datasheet", ""), True, 0), ("Description", pr.get("Description", ""), True, 0)] for k, src in (("Manufacturer", "Manufacturer"), ("MPN", "Manufacturer Part"), ("LCSC", "Supplier Part")): if pr.get(src): plist.append((k, pr[src], True, 0)) body.append(sx_symbol(f"{LIB}:{name}", x, y, rot, False, U(board, page, "sym", pid), plist, (proj, f"/{rootuid}/{sheetuid}"), ref)) if ADD_NC: # 未连接引脚打 KiCad 的 no_connect 标记。**默认关闭**:EasyEDA 侧这 236 个引脚 # 的 NoConnected 全是 false,设计者从未声明「故意不接」,替他断言是不对的。 # 想让 ERC 干净时用 --nc 打开。 occ = collections.Counter() at = {} for a, b2 in segs: occ[a] += 1; occ[b2] += 1 for c in (g["prims"]["Component"] or []): if c["ComponentType"] == "sheet": continue for qq in (c.get("__pins") or []): k = (tx(qq["X"]), ty(qq["Y"])) occ[k] += 1 at.setdefault(k, 0) at[k] += 1 for k, nplaced in at.items(): if occ[k] == 1 and nplaced == 1: body.append(f' (no_connect (at {f(k[0])} {f(k[1])}) (uuid {q(U(board, page, "nc", k))}))') for t in (g["prims"]["Text"] or []): body.append(sx_text(t, tx, ty, U(board, page, "t", t["PrimitiveId"]))) head = ["(kicad_sch", "\t(version 20250610)", f"\t(generator {q('phonerail-eda2kicad')})", '\t(generator_version "10.0")', f'\t(uuid {q(U(board, page, "doc"))})', f"\t(paper {q(paper)})", "\t(title_block", f"\t\t(title {q(page)})", f'\t\t(comment 1 {q("由 EasyEDA 工程自动转换")})', "\t)", "\t(lib_symbols"] for nm in sorted(used): head.append(embed(libsyms[nm], nm)) head.append("\t)") return "\n".join(head + body + ["\t(embedded_fonts no)", ")"]) + "\n", paper # ---------- 根图纸与工程文件 ---------- def gen_root(board, proj, pages, rootuid, sheetuids): body = [] for i, p in enumerate(pages): col, row = i % 3, i // 3 x, y = 25.4 + col * 88.9, 25.4 + row * 38.1 su = sheetuids[p] body.append("\n".join([ "\t(sheet", f"\t\t(at {f(x)} {f(y)})", "\t\t(size 76.2 25.4)", "\t\t(exclude_from_sim no)", "\t\t(in_bom yes)", "\t\t(on_board yes)", "\t\t(dnp no)", "\t\t(stroke (width 0.1524) (type solid))", "\t\t(fill (color 0 0 0 0.0000))", f"\t\t(uuid {q(su)})", f'\t\t(property "Sheetname" {q(p)}\n\t\t\t(at {f(x)} {f(y - 0.7)} 0)\n' f'\t\t\t(effects (font (size 1.27 1.27)) (justify left bottom))\n\t\t)', f'\t\t(property "Sheetfile" {q(p + ".kicad_sch")}\n\t\t\t(at {f(x)} {f(y + 26.1)} 0)\n' f'\t\t\t(effects (font (size 1 1)) (justify left top))\n\t\t)', "\t\t(instances", f"\t\t\t(project {q(proj)}", f'\t\t\t\t(path {q("/" + rootuid)}', f'\t\t\t\t\t(page {q(str(i + 2))})', "\t\t\t\t)", "\t\t\t)", "\t\t)", "\t)"])) head = ["(kicad_sch", "\t(version 20250610)", f"\t(generator {q('phonerail-eda2kicad')})", '\t(generator_version "10.0")', f"\t(uuid {q(rootuid)})", '\t(paper "A4")', "\t(title_block", f"\t\t(title {q(proj)})", f'\t\t(comment 1 {q("由 EasyEDA 工程「五路手机直供电源板」自动转换")})', "\t)", "\t(lib_symbols", "\t)"] tail = ["\t(sheet_instances", '\t\t(path "/" (page "1"))', "\t)", "\t(embedded_fonts no)", ")"] return "\n".join(head + body + tail) + "\n" KICAD_PRO = { "board": {"design_settings": {}, "layer_presets": [], "viewports": []}, "boards": [], "libraries": {"pinned_footprint_libs": [], "pinned_symbol_libs": []}, "meta": {"filename": "", "version": 3}, "net_settings": {"classes": [{"name": "Default", "clearance": 0.2, "track_width": 0.25, "via_diameter": 0.6, "via_drill": 0.3, "wire_width": 6, "bus_width": 12}]}, "pcbnew": {"page_layout_descr_file": ""}, "schematic": {"legacy_lib_dir": "", "legacy_lib_list": []}, "sheets": [], "text_variables": {}, } def main(): tree = json.load(open(os.path.join(SRC, "_tree.json"), encoding="utf-8")) names = json.load(open(os.path.join(SRC, "_kicad_symnames.json"), encoding="utf-8")) libsyms = load_lib_symbols() for b in tree["boards"]: board = b["board"]; proj = PROJ[board] d = os.path.join(OUT, proj) os.makedirs(d, exist_ok=True) props = netlist_props(board) pages = [p["name"] for p in b["pages"]] pwr = [0] rootuid = U(board, "root") sheetuids = {p: U(board, p, "sheet") for p in pages} print(f"[{board}] -> {proj} {len(pages)} 页") for p in pages: g = page_data(board, p) txt, paper = gen_page(board, p, g, names["symbols"], names["powers"], names.get("mirrors", {}), libsyms, proj, rootuid, sheetuids[p], props, pwr) fp = os.path.join(d, p + ".kicad_sch") open(fp, "w", encoding="utf-8", newline="\n").write(txt) print(f" {p:12s} {paper} {os.path.getsize(fp):>8,d} B") open(os.path.join(d, proj + ".kicad_sch"), "w", encoding="utf-8", newline="\n") \ .write(gen_root(board, proj, pages, rootuid, sheetuids)) pro = dict(KICAD_PRO) pro["meta"] = {"filename": proj + ".kicad_pro", "version": 3} pro["sheets"] = [[rootuid, "Root"]] + [[sheetuids[p], p] for p in pages] json.dump(pro, open(os.path.join(d, proj + ".kicad_pro"), "w", encoding="utf-8"), ensure_ascii=False, indent=2) open(os.path.join(d, "sym-lib-table"), "w", encoding="utf-8", newline="\n").write( "(sym_lib_table\n\t(version 7)\n" f'\t(lib (name {q(LIB)})(type "KiCad")(uri "${{KIPRJMOD}}/../lib/{LIB}.kicad_sym")' '(options "")(descr "PhoneRail 由 EasyEDA 转换"))\n)\n') open(os.path.join(d, "fp-lib-table"), "w", encoding="utf-8", newline="\n").write( "(fp_lib_table\n\t(version 7)\n" f'\t(lib (name {q(LIB)})(type "KiCad")(uri "${{KIPRJMOD}}/../lib/{LIB}.pretty")' '(options "")(descr "PhoneRail 由 EasyEDA 转换"))\n)\n') print(" 根图纸 + .kicad_pro + sym-lib-table + fp-lib-table 已写") if __name__ == "__main__": main()