Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.
"""Pure-stdlib SVG rendering of the normalized schematic model (ROADMAP v0.10).
Draws a **reviewable, connectivity-true** picture — component bodies with
refdes/value, pin tips, wires, buses, junctions, labels, power ports and
No-ERC marks — from the same normalized model every check runs on, so an
Altium ``.SchDoc`` renders as readily as a ``.kicad_sch``, with **no KiCad
install**. With an :mod:`akcli.render_art` provider (KiCad sources),
component bodies are the real library artwork; otherwise they are
synthesized from pin geometry. Not a reproduction of either tool's canvas
(fonts, exact text metrics, sheet decorations). Hierarchical designs render
one titled block per sheet.
Coordinates are canonical mils (origin top-left, +Y down) which is exactly
SVG's frame, so geometry maps 1:1. Output is deterministic: same input bytes,
same SVG bytes.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from xml.sax.saxutils import escape
from . import contract
from .model import Component, NetPrimitives, Schematic
if TYPE_CHECKING: # no runtime KiCad dependency
from .render_art import SymbolArt
RENDER_VERSION = contract.SCHEMA_VERSIONS["render"]
_MARGIN_MIL = 200.0
_SHEET_GAP_MIL = 600.0
_PIN_DOT_R = 8.0
_JUNCTION_R = 15.0
_BODY_MIN_MIL = 60.0
_BODY_INSET_MIL = 75.0
# One place for every color/width so the drawing reads as one system.
_STYLE = (
"text{font-family:monospace}"
".wire{stroke:#0a7d2c;stroke-width:6;fill:none;stroke-linecap:round}"
".bus{stroke:#00489e;stroke-width:16;fill:none;stroke-linecap:round}"
".busentry{stroke:#00489e;stroke-width:6;fill:none}"
".body{fill:#fff3d6;stroke:#8b1a1a;stroke-width:5}"
".sym{fill:none;stroke:#8b1a1a;stroke-width:5;stroke-linejoin:round}"
".sym-fill{fill:#fff3d6}"
".pinstub{stroke:#8b1a1a;stroke-width:4}"
".pinnum{fill:#a04a1a;font-size:35px}"
".pinname{fill:#0a6b6b;font-size:40px}"
".symtext{fill:#555;font-size:45px}"
".pin{fill:#8b1a1a}"
".junction{fill:#0a7d2c}"
".label{fill:#003b8e;font-size:50px}"
".label-global{fill:#7a0d7a;font-size:50px}"
".label-power{fill:#8b1a1a;font-size:45px}"
".refdes{fill:#333;font-size:55px;font-weight:bold}"
".value{fill:#555;font-size:45px}"
".sheet-title{fill:#000;font-size:70px;font-weight:bold}"
".noerc{stroke:#c00;stroke-width:6}"
".frame{fill:none;stroke:#bbb;stroke-width:3;stroke-dasharray:20,20}"
".grid{stroke:#d5d5e0;stroke-width:2;stroke-dasharray:8,16}"
".gridlabel{fill:#9a9ab0;font-size:38px}"
".origin{stroke:#c08;stroke-width:5}"
)
_GRID_STEP_MIL = 500.0 # major gridline pitch (10x the 50-mil pin grid)
_GRID_LABEL_EVERY = 1000.0 # coordinate captions on every 1000-mil line
@dataclass
class _Bounds:
min_x: float = float("inf")
min_y: float = float("inf")
max_x: float = float("-inf")
max_y: float = float("-inf")
def add(self, x: float, y: float) -> None:
self.min_x = min(self.min_x, x)
self.min_y = min(self.min_y, y)
self.max_x = max(self.max_x, x)
self.max_y = max(self.max_y, y)
@property
def empty(self) -> bool:
return self.min_x > self.max_x
@property
def width(self) -> float:
return 0.0 if self.empty else self.max_x - self.min_x
@property
def height(self) -> float:
return 0.0 if self.empty else self.max_y - self.min_y
def _fmt(v: float) -> str:
"""Trim trailing zeros so output stays byte-stable and compact."""
return f"{v:.1f}".rstrip("0").rstrip(".")
@dataclass
class _SheetScene:
"""Everything drawn for one sheet, in canonical coordinates."""
name: str
components: list[Component] = field(default_factory=list)
prims: NetPrimitives = field(default_factory=NetPrimitives)
bounds: _Bounds = field(default_factory=_Bounds)
def measure(self, art: "SymbolArt | None" = None) -> None:
for c in self.components:
self.bounds.add(c.x_mil, c.y_mil)
for p in c.pins:
self.bounds.add(p.x_mil, p.y_mil)
box = art.world_bbox(c) if art is not None else None
if box is not None:
self.bounds.add(box[0], box[1])
self.bounds.add(box[2], box[3])
for w in list(self.prims.wires) + list(self.prims.buses):
self.bounds.add(*w.a)
self.bounds.add(*w.b)
for e in self.prims.bus_entries:
self.bounds.add(*e.a)
self.bounds.add(*e.b)
for j in self.prims.junctions:
self.bounds.add(*j.at)
for lab in self.prims.labels:
self.bounds.add(*lab.at)
for pt in self.prims.no_erc:
self.bounds.add(*pt)
def _split_sheets(sch: Schematic, prims: NetPrimitives,
art: "SymbolArt | None" = None) -> list[_SheetScene]:
names: list[str] = []
seen: set[str] = set()
for obj_sheet in ([c.sheet for c in sch.components]
+ [w.sheet for w in prims.wires]
+ [lab.sheet for lab in prims.labels]):
if obj_sheet not in seen:
seen.add(obj_sheet)
names.append(obj_sheet)
if not names:
names = [""]
scenes = {name: _SheetScene(name=name) for name in names}
for c in sch.components:
scenes.get(c.sheet, scenes[names[0]]).components.append(c)
def _route(items: list, into: str) -> None:
for item in items:
scene = scenes.get(item.sheet, scenes[names[0]])
getattr(scene.prims, into).append(item)
_route(prims.wires, "wires")
_route(prims.buses, "buses")
_route(prims.bus_entries, "bus_entries")
_route(prims.junctions, "junctions")
_route(prims.labels, "labels")
# no_erc points carry no sheet field; attach them to the root scene
scenes[names[0]].prims.no_erc.extend(prims.no_erc)
ordered = [scenes[n] for n in sorted(names)]
for s in ordered:
s.measure(art)
return [s for s in ordered if not s.bounds.empty] or ordered[:1]
def _body_rect(c: Component) -> tuple[float, float, float, float]:
"""Synthesized body: pin-tip bbox inset by the nominal pin length."""
if not c.pins:
half = _BODY_MIN_MIL / 2
return c.x_mil - half, c.y_mil - half, _BODY_MIN_MIL, _BODY_MIN_MIL
b = _Bounds()
for p in c.pins:
b.add(p.x_mil, p.y_mil)
x0, y0 = b.min_x, b.min_y
w, h = b.width, b.height
# inset each axis that has pin spread (tips must sit OUTSIDE the body);
# inflate a spread-less axis so a 2-pin part still gets a visible box
if w > 2 * _BODY_INSET_MIL:
x0 += _BODY_INSET_MIL
w -= 2 * _BODY_INSET_MIL
else:
x0 -= (_BODY_MIN_MIL - w) / 2
w = _BODY_MIN_MIL
if h > 2 * _BODY_INSET_MIL:
y0 += _BODY_INSET_MIL
h -= 2 * _BODY_INSET_MIL
else:
y0 -= (_BODY_MIN_MIL - h) / 2
h = _BODY_MIN_MIL
return x0, y0, w, h
def _render_grid(scene: _SheetScene, x: Callable[[float], str],
y: Callable[[float], str], out: list[str]) -> None:
"""World-coordinate gridlines + captions so an agent can READ positions.
The whole authoring model is coordinate-driven (mils, 50-mil grid), but a
plain render gives a multimodal agent no way to tell where (1000, 1600)
is. Major lines every 500 mil, coordinate captions every 1000, and an
origin cross when (0, 0) is in view — all in world mils, exactly the
numbers op-lists use.
"""
import math
b = scene.bounds
if b.empty:
return
pad = 100.0
x0, x1 = b.min_x - pad, b.max_x + pad
y0, y1 = b.min_y - pad, b.max_y + pad
gx = math.floor(x0 / _GRID_STEP_MIL) * _GRID_STEP_MIL
while gx <= x1:
out.append(f'<line class="grid" x1="{x(gx)}" y1="{y(y0)}" '
f'x2="{x(gx)}" y2="{y(y1)}"/>')
if gx % _GRID_LABEL_EVERY == 0:
out.append(f'<text class="gridlabel" x="{x(gx + 10)}" '
f'y="{y(y0 - 15)}">{_fmt(gx)}</text>')
gx += _GRID_STEP_MIL
gy = math.floor(y0 / _GRID_STEP_MIL) * _GRID_STEP_MIL
while gy <= y1:
out.append(f'<line class="grid" x1="{x(x0)}" y1="{y(gy)}" '
f'x2="{x(x1)}" y2="{y(gy)}"/>')
if gy % _GRID_LABEL_EVERY == 0:
out.append(f'<text class="gridlabel" x="{x(x0 - 90)}" '
f'y="{y(gy - 10)}">{_fmt(gy)}</text>')
gy += _GRID_STEP_MIL
if x0 <= 0 <= x1 and y0 <= 0 <= y1:
out.append(f'<path class="origin" d="M {x(-60)} {y(0)} L {x(60)} {y(0)} '
f'M {x(0)} {y(-60)} L {x(0)} {y(60)}"/>')
def _render_sheet(scene: _SheetScene, dx: float, dy: float,
out: list[str], *, grid: bool = False,
art: "SymbolArt | None" = None) -> None:
def x(v: float) -> str:
return _fmt(v + dx)
def y(v: float) -> str:
return _fmt(v + dy)
b = scene.bounds
out.append(f'<g data-sheet="{escape(scene.name or "/", {chr(34): """})}">')
if grid:
_render_grid(scene, x, y, out)
if scene.name:
out.append(f'<text class="sheet-title" x="{x(b.min_x)}" '
f'y="{y(b.min_y - 80)}">{escape(scene.name)}</text>')
out.append(f'<rect class="frame" x="{x(b.min_x - 60)}" y="{y(b.min_y - 60)}" '
f'width="{_fmt(b.width + 120)}" height="{_fmt(b.height + 120)}"/>')
for w in scene.prims.buses:
out.append(f'<line class="bus" x1="{x(w.a[0])}" y1="{y(w.a[1])}" '
f'x2="{x(w.b[0])}" y2="{y(w.b[1])}"/>')
for e in scene.prims.bus_entries:
out.append(f'<line class="busentry" x1="{x(e.a[0])}" y1="{y(e.a[1])}" '
f'x2="{x(e.b[0])}" y2="{y(e.b[1])}"/>')
for w in scene.prims.wires:
out.append(f'<line class="wire" x1="{x(w.a[0])}" y1="{y(w.a[1])}" '
f'x2="{x(w.b[0])}" y2="{y(w.b[1])}"/>')
for c in sorted(scene.components, key=lambda c: c.designator):
out.append(f'<g data-ref="{escape(c.designator, {chr(34): """})}">')
frags = art.emit(c, x, y, _fmt) if art is not None else None
if art is not None and frags is not None:
out.extend(frags)
box = art.world_bbox(c)
bx, by = (box[0], box[1]) if box else (c.x_mil, c.y_mil)
bh = (box[3] - box[1]) if box else 0.0
else:
bx, by, bw, bh = _body_rect(c)
out.append(f'<rect class="body" x="{x(bx)}" y="{y(by)}" '
f'width="{_fmt(bw)}" height="{_fmt(bh)}" rx="10"/>')
for p in c.pins:
out.append(f'<circle class="pin" cx="{x(p.x_mil)}" '
f'cy="{y(p.y_mil)}" r="{_fmt(_PIN_DOT_R)}"/>')
out.append(f'<text class="refdes" x="{x(bx)}" y="{y(by - 20)}">'
f'{escape(c.designator)}</text>')
if c.value:
out.append(f'<text class="value" x="{x(bx)}" y="{y(by + bh + 55)}">'
f'{escape(c.value)}</text>')
out.append("</g>")
for j in scene.prims.junctions:
out.append(f'<circle class="junction" cx="{x(j.at[0])}" '
f'cy="{y(j.at[1])}" r="{_fmt(_JUNCTION_R)}"/>')
for lab in sorted(scene.prims.labels, key=lambda item: (item.at, item.text)):
cls = {"global": "label-global", "power": "label-power"}.get(
lab.scope, "label")
out.append(f'<text class="{cls}" x="{x(lab.at[0] + 15)}" '
f'y="{y(lab.at[1] - 15)}">{escape(lab.text)}</text>')
for pt in scene.prims.no_erc:
out.append(f'<path class="noerc" d="M {x(pt[0] - 20)} {y(pt[1] - 20)} '
f'L {x(pt[0] + 20)} {y(pt[1] + 20)} '
f'M {x(pt[0] - 20)} {y(pt[1] + 20)} '
f'L {x(pt[0] + 20)} {y(pt[1] - 20)}"/>')
out.append("</g>")
def render(sch: Schematic, prims: NetPrimitives, *, grid: bool = False,
art: "SymbolArt | None" = None) -> str:
"""Render the schematic + its primitives to an SVG document string.
``grid=True`` overlays world-mil gridlines, coordinate captions and an
origin cross (see :func:`_render_grid`) — for coordinate-driven review.
``art`` is an optional :class:`akcli.render_art.SymbolArt`: components it
can draw get their faithful library artwork; everything else falls back
to the synthesized pin-box body.
"""
scenes = _split_sheets(sch, prims, art)
total_w = max((s.bounds.width for s in scenes), default=0.0)
offsets: list[tuple[float, float]] = []
cursor_y = _MARGIN_MIL
for s in scenes:
title_pad = 150.0 if s.name else 0.0
offsets.append((_MARGIN_MIL - s.bounds.min_x,
cursor_y + title_pad - s.bounds.min_y))
cursor_y += s.bounds.height + title_pad + _SHEET_GAP_MIL
height = cursor_y - _SHEET_GAP_MIL + _MARGIN_MIL
width = total_w + 2 * _MARGIN_MIL
body: list[str] = []
for scene, (dx, dy) in zip(scenes, offsets):
_render_sheet(scene, dx, dy, body, grid=grid, art=art)
return "\n".join([
f'<svg xmlns="http://www.w3.org/2000/svg" '
f'viewBox="0 0 {_fmt(width)} {_fmt(height)}" '
f'data-akcli-render="{RENDER_VERSION}">',
f"<style>{_STYLE}</style>",
'<rect width="100%" height="100%" fill="#fdfdf8"/>',
*body,
"</svg>",
]) + "\n"