"""写 .kicad_sch 的底层:符号提取、外框、旋转变换、页面拼装。 符号来源用注册制:KiCad 自带库按库名直接找;工程自己的库用 register_lib(name, path) 登记(例如 register_lib("myboard", r"...\\lib\\myboard.kicad_sym"))。 """ import io import os import re import hashlib import math from .env import kicad_symbol_dir _LIBS = {} # 库名 -> .kicad_sym 路径(工程库) _cache = {} _FP_FILTER = {} # lib_id -> 覆盖后的 ki_fp_filters def set_fp_filter(lib_id, flt): """覆盖某个符号的 ki_fp_filters(封装选择器的"Apply footprint filters"用它)。 KiCad 通用连接器符号自带 Connector*:*_1x??_* 之类的过滤,工程自己的封装库 (如从参考设计提取的 VH3.96-2P)会被它藏掉,看起来像"没有封装"。flt 用空格分隔多个模式。""" _FP_FILTER[lib_id] = flt def register_lib(name, path): _LIBS[name] = path _cache.pop(name, None) _bbox_cache.clear() def _read(libname): if libname in _cache: return _cache[libname] p = _LIBS.get(libname) or os.path.join(kicad_symbol_dir(), libname + ".kicad_sym") _cache[libname] = io.open(p, encoding="utf-8").read() return _cache[libname] def balanced(text, start): """从 text[start] 的 '(' 起取配平的整段 s-expression(跳过字符串里的括号)。""" d, j = 0, start while j < len(text): c = text[j] if c == '"': j += 1 while j < len(text) and not (text[j] == '"' and text[j - 1] != '\\'): j += 1 elif c == '(': d += 1 elif c == ')': d -= 1 if d == 0: return text[start:j + 1] j += 1 raise ValueError("unbalanced") def symbol_def(lib_id): """取出 (symbol "Lib:Name" ...) 定义,名字改成 lib_id 形式供 lib_symbols 用。 KiCad 自带库里的 (extends "X") 派生符号会被展开成完整定义。""" lib, name = lib_id.split(":", 1) t = _read(lib) i = t.find('(symbol "%s"' % name) if i < 0: raise KeyError(lib_id) blk = balanced(t, i) m = re.search(r'\(extends "([^"]+)"\)', blk) if m: # 派生符号:拿父符号的图形/引脚,属性用子符号的(属性块有嵌套括号,必须按配平取) base = balanced(t, t.find('(symbol "%s"' % m.group(1))) body = re.sub(r'\(symbol "%s_' % re.escape(m.group(1)), '(symbol "%s_' % name, base) body = body.replace('(symbol "%s"' % m.group(1), '(symbol "%s"' % name, 1) for pm in re.finditer(r'\(property "([^"]*)"', blk): pr = balanced(blk, pm.start()) k = body.find('(property "%s"' % pm.group(1)) if k >= 0: old = balanced(body, k) body = body[:k] + pr + body[k + len(old):] else: # 父符号没有的属性(如 ki_fp_filters)插在最后一个属性后 k = body.rfind('(property "') old = balanced(body, k) body = body[:k + len(old)] + "\n\t\t" + pr + body[k + len(old):] blk = body blk = blk.replace('(symbol "%s"' % name, '(symbol "%s"' % lib_id, 1) if lib_id in _FP_FILTER: k = blk.find('(property "ki_fp_filters"') if k >= 0: old = balanced(blk, k) blk = blk[:k] + re.sub(r'\(property "ki_fp_filters" "[^"]*"', lambda _: '(property "ki_fp_filters" "%s"' % _FP_FILTER[lib_id], old, count=1) + blk[k + len(old):] return blk def uid(*parts): """确定性 uuid:同样的输入每次生成同样的 uuid,重跑脚本不会让 git diff 满屏。""" h = hashlib.md5(("|".join(str(p) for p in parts)).encode()).hexdigest() return "%s-%s-%s-%s-%s" % (h[0:8], h[8:12], h[12:16], h[16:20], h[20:32]) def _unit_blocks(blk, unit): """取出属于某个 unit 的子符号。KiCad 把各 unit 共用图形放在 NAME_0_x, 引脚放在 NAME__x,两边都要,只取后者会丢掉整个本体图形(G-0019)。""" if unit is None: return blk subs = [] for m in re.finditer(r'\(symbol "([^"]*_(\d+)_\d+)"', blk): if int(m.group(2)) in (0, unit): subs.append(balanced(blk, m.start())) return "\n".join(subs) if subs else blk def pin_positions(lib_id, unit=None): """{pin_number: (dx, dy, rot)},相对符号原点;符号坐标 y 向上为正。""" blk = _unit_blocks(symbol_def(lib_id), unit) res = {} for m in re.finditer(r'\(pin\s+\S+\s+\S+\s*\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)', blk): x, y, rot = float(m.group(1)), float(m.group(2)), float(m.group(3)) seg = blk[m.end():m.end() + 400] nm = re.search(r'\(number "([^"]*)"', seg) if nm: res[nm.group(1)] = (x, y, rot) return res GRID = 1.27 def snap(v): """吸附到 1.27mm 连接网格,否则 ERC 报 off-grid、线也连不上。""" return round(float(v) / GRID) * GRID _bbox_cache = {} def symbol_bbox(lib_id, unit=None): """符号外框 (minx, miny, maxx, maxy),符号坐标系(y 向上),含引脚端点。""" key = (lib_id, unit) if key in _bbox_cache: return _bbox_cache[key] blk = _unit_blocks(symbol_def(lib_id), unit) xs, ys = [], [] N = r'(-?\d+(?:\.\d+)?)' for m in re.finditer(r'\((rectangle|polyline|circle|arc|bezier)\b', blk): g = balanced(blk, m.start()) for mm in re.finditer(r'\((?:start|end|mid|xy|center)\s+' + N + r'\s+' + N, g): xs.append(float(mm.group(1))); ys.append(float(mm.group(2))) cm = re.search(r'\(center\s+' + N + r'\s+' + N + r'\s*\)\s*\(radius\s+' + N, g) if cm: cx, cy, r = float(cm.group(1)), float(cm.group(2)), float(cm.group(3)) xs += [cx - r, cx + r]; ys += [cy - r, cy + r] for m in re.finditer(r'\(pin\b', blk): g = balanced(blk, m.start()) am = re.search(r'\(at\s+' + N + r'\s+' + N, g) if am: xs.append(float(am.group(1))); ys.append(float(am.group(2))) if not xs: xs, ys = [-2.54, 2.54], [-2.54, 2.54] bb = (min(xs), min(ys), max(xs), max(ys)) _bbox_cache[key] = bb return bb def rot_pt(dx, dy, rot): a = math.radians(rot) c, s = math.cos(a), math.sin(a) return (dx * c - dy * s, dx * s + dy * c) def rot_bbox(bb, rot): xs, ys = [], [] for x in (bb[0], bb[2]): for y in (bb[1], bb[3]): px, py = rot_pt(x, y, rot) xs.append(px); ys.append(py) return (min(xs), min(ys), max(xs), max(ys)) def placed_pins(lib_id, unit=None, rot=0): """{pin: (dx_sheet, dy_sheet, rot_sheet)}:已含旋转与 y 翻转,加到放置坐标上即图纸坐标。""" out = {} for k, (dx, dy, prot) in pin_positions(lib_id, unit).items(): rx, ry = rot_pt(dx, dy, rot) out[k] = (rx, -ry, (prot + rot) % 360) return out def pin_dir(rot_sheet): """引脚出线方向(图纸坐标,y 向下)。引脚 angle 指向本体内侧,出线取反向。""" a = math.radians(rot_sheet) return (-math.cos(a), math.sin(a)) def esc(s): return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") class Sheet: """一页 .kicad_sch 的内容。子图不写 sheet_instances(那是根图独有的)。""" def __init__(self, title, project, paper="A3", rev="A", company="", sheet_uuid=None, root_uuid=None): # sheet_uuid 必须等于根图里该页 sheet 符号的 uuid,否则 KiCad 判为批注错误 self.sheet_uuid = sheet_uuid or uid("root", title) self.root_uuid = root_uuid self.title, self.project, self.paper = title, project, paper self.rev, self.company = rev, company self.items, self.libs, self.n = [], {}, 0 def _need(self, lib_id): if lib_id not in self.libs: self.libs[lib_id] = symbol_def(lib_id) def _path(self): return "/%s/%s" % (self.root_uuid, self.sheet_uuid) if self.root_uuid \ else "/%s" % self.sheet_uuid def comp(self, lib_id, ref, value, x, y, rot=0, unit=1, footprint="", hide_ref=False, hide_val=False, ref_at=None, val_at=None, dnp=False, fields=None, in_bom=True): """放置元件。ref_at/val_at = (绝对x, 绝对y, 对齐),由调用方按外框算好。 fields: 额外属性 {名字: 值}(如 MPN),一律隐藏。""" x, y = snap(x), snap(y) self._need(lib_id) bb = rot_bbox(symbol_bbox(lib_id, unit), rot) if ref_at is None: ref_at = (x, y - bb[3] - 2.032, "center") if val_at is None: val_at = (x, y - bb[1] + 2.032, "center") rxa, rya, rj = ref_at vxa, vya, vj = val_at # justify 只认 left/right/top/bottom/mirror;写 center 会让整页静默解析失败(G-0018) rj = "" if rj == "center" else " (justify %s)" % rj vj = "" if vj == "center" else " (justify %s)" % vj hidr = " hide" if hide_ref else "" hidv = " hide" if hide_val else "" # 90/270 放置时 KiCad 把元件角度叠到字段上,反向补偿;180 走镜像(G-0019) fa = {0: 0, 90: 270, 180: 0, 270: 90}[rot % 360] self.n += 1 u = uid(self.title, ref, unit, self.n) extra = "" for k, v in (fields or {}).items(): extra += (' (property "%s" "%s" (at %.2f %.2f 0)' ' (effects (font (size 1.27 1.27)) hide))\n' % (esc(k), esc(v), x, y)) self.items.append( ' (symbol (lib_id "%s") (at %.2f %.2f %d) (unit %d)\n' ' (exclude_from_sim no) (in_bom %s) (on_board yes) (dnp %s)\n' ' (uuid "%s")\n' ' (property "Reference" "%s" (at %.2f %.2f %d)' ' (effects (font (size 1.27 1.27))%s%s))\n' ' (property "Value" "%s" (at %.2f %.2f %d)' ' (effects (font (size 1.27 1.27))%s%s))\n' ' (property "Footprint" "%s" (at %.2f %.2f 0) (effects (font (size 1.27 1.27)) hide))\n' ' (property "Datasheet" "" (at %.2f %.2f 0) (effects (font (size 1.27 1.27)) hide))\n' '%s' ' (instances (project "%s" (path "%s" (reference "%s") (unit %d))))\n' ' )' % (lib_id, x, y, rot, unit, ("yes" if in_bom else "no"), ("yes" if dnp else "no"), u, ref, rxa, rya, fa, rj, hidr, esc(value), vxa, vya, fa, vj, hidv, footprint, x, y, x, y, extra, self.project, self._path(), ref, unit)) return (x, y) def wire(self, x1, y1, x2, y2): x1, y1, x2, y2 = snap(x1), snap(y1), snap(x2), snap(y2) self.n += 1 self.items.append( ' (wire (pts (xy %.2f %.2f) (xy %.2f %.2f))\n' ' (stroke (width 0) (type default)) (uuid "%s"))' % (x1, y1, x2, y2, uid(self.title, "w", x1, y1, x2, y2, self.n))) # 标签文字角度只有 0/90,朝向由 justify 决定;写 180/270 会反向压在元件上(G-0019) GLROT = {0: (0, "left"), 180: (0, "right"), 90: (90, "left"), 270: (90, "right")} def glabel(self, name, x, y, direction=0, shape="bidirectional"): """direction: 标签体伸出方向,0=右 90=上 180=左 270=下。""" x, y = snap(x), snap(y) ang, just = self.GLROT[direction % 360] self.n += 1 self.items.append( ' (global_label "%s" (shape %s) (at %.2f %.2f %d) (fields_autoplaced yes)\n' ' (effects (font (size 1.27 1.27)) (justify %s)) (uuid "%s"))' % (name, shape, x, y, ang, just, uid(self.title, "gl", name, x, y, self.n))) def text(self, s, x, y, size=1.6): self.n += 1 self.items.append( ' (text "%s" (at %.2f %.2f 0)\n' ' (effects (font (size %.2f %.2f)) (justify left bottom)) (uuid "%s"))' % (esc(s.replace('"', "'")), x, y, size, size, uid(self.title, "t", s, x, y, self.n))) def rect(self, x0, y0, x1, y1, dash=True): """虚线框:圈出一个子电路(例如"留位不贴"的一组件)。""" self.n += 1 self.items.append( ' (rectangle (start %.2f %.2f) (end %.2f %.2f)\n' ' (stroke (width 0.2) (type %s)) (fill (type none)) (uuid "%s"))' % (x0, y0, x1, y1, "dash" if dash else "default", uid(self.title, "rect", x0, y0, self.n))) def junction(self, x, y): """丁字接点:一条线的端点落在另一条线中段上,必须放 junction 才导通(G-0036)。""" x, y = snap(x), snap(y) self.n += 1 self.items.append( ' (junction (at %.2f %.2f) (diameter 0) (color 0 0 0 0)' ' (uuid "%s"))' % (x, y, uid(self.title, "j", x, y, self.n))) def nc(self, x, y): x, y = snap(x), snap(y) self.n += 1 self.items.append(' (no_connect (at %.2f %.2f) (uuid "%s"))' % (x, y, uid(self.title, "nc", x, y, self.n))) def raw(self, sexpr): self.items.append(sexpr) def dump(self, path, generator="kicad_flow_schgen"): out = ['(kicad_sch (version 20231120) (generator "%s") (generator_version "8.0")' % generator, ' (uuid "%s")' % uid("sheetfile", self.title), ' (paper "%s")' % self.paper, ' (title_block (title "%s") (rev "%s") (company "%s"))' % (esc(self.title), self.rev, esc(self.company)), ' (lib_symbols'] for k in sorted(self.libs): out.append(" " + self.libs[k].replace("\n", "\n ")) out.append(' )') out.extend(self.items) out.append(')') io.open(path, "w", encoding="utf-8").write("\n".join(out) + "\n") return path