Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
97c6f2bbeThis file is pinned to the selected release. Download the original to open it in your design tool.
"""Faithful symbol artwork for `akcli render` (KiCad sources).
Walks each placed component's library symbol graphics — the raw
``(symbol ...)`` S-expression preserved on ``SymbolDef.body_sexpr`` —
and emits SVG fragments in world coordinates: rectangles, circles, arcs,
polylines, beziers, graphic text, pin stubs and (when the symbol does not
hide them) pin numbers/names. Every point goes through
:func:`akcli.writers.geometry.transform_point` — the same Y-flip +
rotate-then-mirror chain the net engine uses for pins — so artwork can never
disagree with connectivity.
Honest limits (fallback = the synthesized pin-box body):
* multi-unit parts (``part_count > 1``): the normalized model keeps ONE
component per designator and only the first instance's placement, so the
extra units' bodies cannot be positioned — synthesized body instead;
* symbols missing from the sheet's embedded ``lib_symbols`` (or Altium
sources, which carry no KiCad artwork);
* pin name/number text is drawn horizontally at an approximate anchor
(reviewable, not a font-metric reproduction).
Deterministic: same input bytes → same fragments (stable walk order, fixed
float formatting).
"""
from __future__ import annotations
import math
from collections.abc import Callable
from xml.sax.saxutils import escape
from . import units
from .errors import AkcliError
from .kicad_escape import unescape_string
from .model import Component, Library, SymbolDef
from .readers import kicad_lib
from .readers.kicad_lib import _atom_value, _fnum_atom, _mm_to_mil, _sub_unit_style
from .readers.sexpr import SNode
from .writers import geometry
_PIN_NUM_NUDGE_MIL = 25.0 # pin number: offset from the stub midpoint
_PIN_NAME_GAP_MIL = 30.0 # pin name: past the stub's inner (body) end
_PIN_NAME_OFFSET_MM = 0.508 # KiCad default when (pin_names) carries no (offset)
_Fmt = Callable[[float], str]
def _atom(node: SNode | None, idx: int) -> str | None:
"""None-safe :func:`_atom_value` — ``SNode.find`` returns ``None`` freely.
Optional sub-nodes are the norm, not the exception, in real symbol
libraries: ``(pin_names hide)`` carries no ``(offset)``, a pin may have no
``(name)``/``(number)``/``(length)``, ``(fill)`` no ``(type)``. Handing a
raw ``find`` result to ``_atom_value`` (which dereferences
``node.children``) is therefore an ``AttributeError`` waiting to happen —
it crashed ``akcli render`` on 59% of the standard KiCad libraries. Numbers
go through :func:`_fnum_atom`, which is None-safe the same way.
"""
return _atom_value(node, idx) if node is not None else None
def _fill_class(g: SNode) -> str:
fill = g.find("fill")
kind = _atom(fill.find("type"), 1) if fill is not None else None
return "sym sym-fill" if kind == "background" else "sym"
def _hide_flag(node: SNode | None) -> bool:
"""True for ``(x ... hide)`` / ``(x ... (hide yes))`` (KiCad 6–9 forms)."""
if node is None:
return False
for c in node.children or []:
if c.is_list and c.tag == "hide":
return (_atom(c, 1) or "yes") == "yes"
if c.is_atom and c.text == "hide":
return True
return False
class SymbolArt:
"""Per-render artwork provider over a sheet's embedded symbol library."""
def __init__(self, library: Library):
self._lib = library
self._cache: dict[str, SymbolDef | None] = {}
# ------------------------------------------------------------------ #
# symbol lookup
# ------------------------------------------------------------------ #
def _symdef(self, comp: Component) -> SymbolDef | None:
lib_id = comp.library_ref or ""
if lib_id not in self._cache:
try:
self._cache[lib_id] = kicad_lib.resolve(lib_id, [self._lib])
except AkcliError:
self._cache[lib_id] = None
return self._cache[lib_id]
def _bodies(self, symdef: SymbolDef) -> list[SNode]:
"""Body roots to draw: the base symbol first for an (extends) part."""
# `SymbolDef.body_sexpr` is typed `object | None` (model.py cannot
# import the reader) — narrow it here instead of trusting it.
out: list[SNode] = []
if symdef.extends:
try:
base = kicad_lib.resolve(symdef.extends, [self._lib])
if isinstance(base.body_sexpr, SNode):
out.append(base.body_sexpr)
except AkcliError:
pass
if isinstance(symdef.body_sexpr, SNode):
out.append(symdef.body_sexpr)
return out
def _drawable(self, comp: Component) -> SymbolDef | None:
symdef = self._symdef(comp)
if symdef is None or symdef.part_count > 1:
return None # multi-unit: placement not modeled
return symdef if self._bodies(symdef) else None
# ------------------------------------------------------------------ #
# geometry
# ------------------------------------------------------------------ #
@staticmethod
def _pt(comp: Component, lx_mil: float, ly_mil: float) -> tuple[float, float]:
"""Lib-frame (mil, +Y up) point → world mils (+Y down)."""
local = (units.mil_to_nm(lx_mil), -units.mil_to_nm(ly_mil))
origin = (units.mil_to_nm(comp.x_mil), units.mil_to_nm(comp.y_mil))
wx, wy = geometry.transform_point(local, comp.rotation, comp.mirror,
origin)
return units.nm_to_mil(wx), units.nm_to_mil(wy)
def world_bbox(
self, comp: Component,
) -> tuple[float, float, float, float] | None:
"""World-mil AABB of the drawn body (no pins), or ``None``."""
symdef = self._drawable(comp)
if symdef is None:
return None
exts = [e for e in (kicad_lib.body_extent_mil(b_def, unit=1)
for b_def in self._extent_defs(symdef))
if e is not None]
if not exts:
return None
ext = (min(e[0] for e in exts), min(e[1] for e in exts),
max(e[2] for e in exts), max(e[3] for e in exts))
origin = (units.mil_to_nm(comp.x_mil), units.mil_to_nm(comp.y_mil))
box = geometry.world_box_from_extent(ext, comp.rotation, comp.mirror,
origin)
return (units.nm_to_mil(box[0]), units.nm_to_mil(box[1]),
units.nm_to_mil(box[2]), units.nm_to_mil(box[3]))
def _extent_defs(self, symdef: SymbolDef) -> list[SymbolDef]:
"""The symdefs whose bodies contribute drawn extent (base + derived)."""
out = [symdef]
if symdef.extends:
try:
out.insert(0, kicad_lib.resolve(symdef.extends, [self._lib]))
except AkcliError:
pass
return out
# ------------------------------------------------------------------ #
# SVG emission
# ------------------------------------------------------------------ #
def emit(self, comp: Component, x: _Fmt, y: _Fmt,
fmt: _Fmt) -> list[str] | None:
"""SVG fragments for the component's faithful body, or ``None``.
``x``/``y`` map world-mil coordinates to offset page strings (the
renderer's per-sheet formatters); ``fmt`` formats scalar lengths.
"""
symdef = self._drawable(comp)
if symdef is None:
return None
power = kicad_lib.is_power_symbol(symdef)
out: list[str] = []
for body in self._bodies(symdef):
hide_numbers = _hide_flag(body.find("pin_numbers"))
names_node = body.find("pin_names")
hide_names = _hide_flag(names_node)
# `(pin_names hide)` — the majority form in the standard libraries
# — has no `(offset)` child at all: `find` returns None.
offset_node = (names_node.find("offset")
if names_node is not None else None)
name_offset_mil = _mm_to_mil(
_fnum_atom(offset_node, 1, _PIN_NAME_OFFSET_MM))
for holder in self._holders(body):
for g in holder.children or []:
if not g.is_list:
continue
self._emit_graphic(comp, g, x, y, fmt, out,
hide_numbers=hide_numbers or power,
hide_names=hide_names or power,
name_offset_mil=name_offset_mil)
return out or None
@staticmethod
def _holders(body: SNode) -> list[SNode]:
"""Body + the unit-1/common sub-symbols, style ≤ 1 (no DeMorgan)."""
holders = [body]
for sub in body.find_all("symbol"):
u, style = _sub_unit_style(_atom(sub, 1))
if style is not None and style >= 2:
continue
if u is None or u in (0, 1):
holders.append(sub)
return holders
def _emit_graphic(self, comp: Component, g: SNode, x: _Fmt, y: _Fmt,
fmt: _Fmt, out: list[str], *, hide_numbers: bool,
hide_names: bool, name_offset_mil: float) -> None:
pt = self._pt
def take(node: SNode | None) -> tuple[float, float] | None:
if node is None:
return None
return pt(comp, _mm_to_mil(_fnum_atom(node, 1)),
_mm_to_mil(_fnum_atom(node, 2)))
if g.tag == "rectangle":
a, b = take(g.find("start")), take(g.find("end"))
if a and b:
x0, x1 = min(a[0], b[0]), max(a[0], b[0])
y0, y1 = min(a[1], b[1]), max(a[1], b[1])
out.append(f'<rect class="{_fill_class(g)}" x="{x(x0)}" '
f'y="{y(y0)}" width="{fmt(x1 - x0)}" '
f'height="{fmt(y1 - y0)}"/>')
elif g.tag == "circle":
c = take(g.find("center"))
if c is not None:
r = _mm_to_mil(_fnum_atom(g.find("radius"), 1))
out.append(f'<circle class="{_fill_class(g)}" cx="{x(c[0])}" '
f'cy="{y(c[1])}" r="{fmt(r)}"/>')
elif g.tag == "arc":
s, m, e = (take(g.find("start")), take(g.find("mid")),
take(g.find("end")))
if s and m and e:
out.append(self._arc_path(g, s, m, e, x, y, fmt))
elif g.tag in ("polyline", "bezier"):
pts_node = g.find("pts")
pts = [p for p in (take(xy) for xy in
(pts_node.find_all("xy")
if pts_node is not None else []))
if p is not None]
if len(pts) < 2:
return
if g.tag == "bezier" and len(pts) == 4:
d = (f"M {x(pts[0][0])} {y(pts[0][1])} "
f"C {x(pts[1][0])} {y(pts[1][1])} "
f"{x(pts[2][0])} {y(pts[2][1])} "
f"{x(pts[3][0])} {y(pts[3][1])}")
out.append(f'<path class="{_fill_class(g)}" d="{d}"/>')
else:
coords = " ".join(f"{x(px)},{y(py)}" for px, py in pts)
out.append(f'<polyline class="{_fill_class(g)}" '
f'points="{coords}"/>')
elif g.tag == "text":
at = take(g.find("at"))
value = unescape_string(_atom(g, 1)) or ""
if at is not None and value:
out.append(f'<text class="symtext" x="{x(at[0])}" '
f'y="{y(at[1])}">{escape(value)}</text>')
elif g.tag == "pin":
self._emit_pin(comp, g, x, y, out, hide_numbers=hide_numbers,
hide_names=hide_names,
name_offset_mil=name_offset_mil)
def _emit_pin(self, comp: Component, g: SNode, x: _Fmt, y: _Fmt,
out: list[str], *, hide_numbers: bool, hide_names: bool,
name_offset_mil: float) -> None:
at = g.find("at")
if at is None or _hide_flag(g):
return
tipx = _mm_to_mil(_fnum_atom(at, 1))
tipy = _mm_to_mil(_fnum_atom(at, 2))
orient = _fnum_atom(at, 3)
length = _mm_to_mil(_fnum_atom(g.find("length"), 1))
dx = {0.0: 1.0, 180.0: -1.0}.get(orient % 360, 0.0)
dy = {90.0: 1.0, 270.0: -1.0}.get(orient % 360, 0.0)
innx, inny = tipx + length * dx, tipy + length * dy
if length:
a = self._pt(comp, tipx, tipy)
b = self._pt(comp, innx, inny)
out.append(f'<line class="pinstub" x1="{x(a[0])}" y1="{y(a[1])}" '
f'x2="{x(b[0])}" y2="{y(b[1])}"/>')
if not hide_numbers:
number = _atom(g.find("number"), 1) or ""
if number:
mid = self._pt(comp, (tipx + innx) / 2,
(tipy + inny) / 2 + _PIN_NUM_NUDGE_MIL)
out.append(f'<text class="pinnum" text-anchor="middle" '
f'x="{x(mid[0])}" y="{y(mid[1])}">'
f'{escape(number)}</text>')
if not hide_names:
name = unescape_string(_atom(g.find("name"), 1)) or ""
if name and name != "~":
gap = length + name_offset_mil + _PIN_NAME_GAP_MIL
anchor = self._pt(comp, tipx + gap * dx, tipy + gap * dy)
out.append(f'<text class="pinname" text-anchor="middle" '
f'x="{x(anchor[0])}" y="{y(anchor[1])}">'
f'{escape(name)}</text>')
@staticmethod
def _arc_path(g: SNode, s: tuple[float, float], m: tuple[float, float],
e: tuple[float, float], x: _Fmt, y: _Fmt, fmt: _Fmt) -> str:
"""Three-point arc → SVG A-path (falls back to a polyline chord)."""
ax, ay = s
bx, by = m
cx_, cy_ = e
d = 2 * (ax * (by - cy_) + bx * (cy_ - ay) + cx_ * (ay - by))
if abs(d) < 1e-9: # collinear: degenerate arc
return (f'<polyline class="{_fill_class(g)}" '
f'points="{x(ax)},{y(ay)} {x(cx_)},{y(cy_)}"/>')
ux = ((ax * ax + ay * ay) * (by - cy_) + (bx * bx + by * by)
* (cy_ - ay) + (cx_ * cx_ + cy_ * cy_) * (ay - by)) / d
uy = ((ax * ax + ay * ay) * (cx_ - bx) + (bx * bx + by * by)
* (ax - cx_) + (cx_ * cx_ + cy_ * cy_) * (bx - ax)) / d
r = math.hypot(ax - ux, ay - uy)
def ang(px: float, py: float) -> float:
return math.atan2(py - uy, px - ux)
tau = 2 * math.pi
d_m = (ang(bx, by) - ang(ax, ay)) % tau
d_e = (ang(cx_, cy_) - ang(ax, ay)) % tau
sweep = 1 if d_m <= d_e else 0 # mid on the increasing-angle path?
span = d_e if sweep else tau - d_e
large = 1 if span > math.pi else 0
return (f'<path class="{_fill_class(g)}" d="M {x(ax)} {y(ay)} '
f'A {fmt(r)} {fmt(r)} 0 {large} {sweep} '
f'{x(cx_)} {y(cy_)}"/>')