文件

419 行
19 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/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()