app
adom-layout-viewer
Public Made by Adomby adom
Interactive PCB layout viewer: your EDA's own render plus live net/trace highlighting and per-pad stock, price and wiki lookup
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
//! Self-render a whole `.kicad_sch` to a themed, INTERACTIVE SVG — the sibling of
//! pcb_render, for schematics. Same payoff: kicad-cli's `.svg` export is flat and
//! anonymous, so we parse the sheet ourselves and tag every wire with
//! `data-net` and every symbol instance with `data-ref`, which is what makes
//! net highlighting + per-component hover possible.
//!
//! Symbol bodies are drawn from their `lib_symbols` definition (rectangles /
//! polylines / circles / arcs / pins) transformed to each instance's placement.
//!
//! Net grouping (label-based / connectivity v1): union-find over wire endpoints
//! (+ junctions), then clusters that share a power-symbol or label NAME are
//! merged so e.g. every GND stub highlights together. Component pins whose tip
//! lands on a cluster point inherit that net.
use crate::sexpr::{blocks, esc, f};
use regex::Regex;
use serde::Serialize;
use std::collections::HashMap;
use std::fmt::Write as _;
// schematic grid is Y-DOWN already (page mm), same as SVG → no global flip.
#[derive(Clone)]
struct Prim { kind: u8, pts: Vec<(f64, f64)>, r: f64 } // kind: 0 poly/rect,1 circle,2 pin,3 arc
struct LibSym { prims: Vec<Prim> }
#[derive(Serialize, Clone)]
pub struct Comp {
pub reference: String,
pub value: String,
pub footprint: String,
pub mpn: String,
pub lcsc: String,
pub x: f64,
pub y: f64,
pub bbox: [f64; 4],
pub pin_count: usize,
}
struct Wire { x1: f64, y1: f64, x2: f64, y2: f64, net: usize }
struct Label { x: f64, y: f64, text: String, power: bool }
pub struct Sheet {
libs: HashMap<String, LibSym>,
comps: Vec<Comp>,
comp_svgs: Vec<String>, // pre-rendered <g data-ref> body per comp
wires: Vec<Wire>,
junctions: Vec<(f64, f64)>,
labels: Vec<Label>,
net_names: HashMap<usize, String>,
}
// ── union-find ──
struct Uf { p: Vec<usize> }
impl Uf {
fn new(n: usize) -> Self { Uf { p: (0..n).collect() } }
fn find(&mut self, a: usize) -> usize { let mut a = a; while self.p[a] != a { self.p[a] = self.p[self.p[a]]; a = self.p[a]; } a }
fn union(&mut self, a: usize, b: usize) { let (ra, rb) = (self.find(a), self.find(b)); if ra != rb { self.p[ra] = rb; } }
}
fn key(x: f64, y: f64) -> (i64, i64) { ((x * 100.0).round() as i64, (y * 100.0).round() as i64) }
/// Rotate+mirror a lib point into sheet coords. Lib frame is Y-UP; the sheet is
/// Y-DOWN, so base transform negates Y, then rotation (CW on screen for KiCad's
/// `at` angle) and optional mirror compose, then translate to (px,py).
fn xf(lx: f64, ly: f64, px: f64, py: f64, rot: f64, mirror: u8) -> (f64, f64) {
// lib Y-up → screen Y-down
let (mut x, mut y) = (lx, -ly);
// mirror: 1 = mirror across Y axis (x), 2 = across X axis (y)
if mirror == 1 { x = -x; }
if mirror == 2 { y = -y; }
// rotation (KiCad rotates the symbol CCW by `rot`; on Y-down screen that is a
// clockwise matrix). Use standard CCW matrix with -rot for screen.
let a = (-rot).to_radians();
let (c, s) = (a.cos(), a.sin());
let (rx, ry) = (x * c - y * s, x * s + y * c);
(px + rx, py + ry)
}
pub fn parse(content: &str) -> Sheet {
let at3 = Regex::new(r"\(at\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s+(-?[\d.]+))?\)").unwrap();
let se = Regex::new(r"\(start\s+(-?[\d.]+)\s+(-?[\d.]+)\)\s*\(end\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
let xy = Regex::new(r"\(xy\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
let center = Regex::new(r"\(center\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
let radius = Regex::new(r"\(radius\s+(-?[\d.]+)\)").unwrap();
let length = Regex::new(r"\(length\s+(-?[\d.]+)\)").unwrap();
let prop = Regex::new(r##"\(property\s+"([^"]+)"\s+"([^"]*)""##).unwrap();
let mirror_re = Regex::new(r"\(mirror\s+(x|y)\)").unwrap();
let mut sh = Sheet {
libs: HashMap::new(), comps: vec![], comp_svgs: vec![], wires: vec![],
junctions: vec![], labels: vec![], net_names: HashMap::new(),
};
// ── lib_symbols definitions ──
if let Some(libblk) = blocks(content, "lib_symbols").into_iter().next() {
// top-level defs: (symbol "Lib:Name" … nested (symbol "Name_x_y" graphics))
for def in top_symbols(libblk) {
let name = quoted(def).unwrap_or_default();
if name.is_empty() { continue; }
let mut prims = vec![];
for r in blocks(def, "rectangle") {
if let Some(c) = se.captures(r) {
let (x1, y1, x2, y2) = (f(c.get(1)), f(c.get(2)), f(c.get(3)), f(c.get(4)));
prims.push(Prim { kind: 0, pts: vec![(x1, y1), (x2, y1), (x2, y2), (x1, y2), (x1, y1)], r: 0.0 });
}
}
for p in blocks(def, "polyline") {
let pts: Vec<(f64, f64)> = xy.captures_iter(p).map(|c| (f(c.get(1)), f(c.get(2)))).collect();
if pts.len() >= 2 { prims.push(Prim { kind: 0, pts, r: 0.0 }); }
}
for c in blocks(def, "circle") {
if let (Some(ce), Some(ra)) = (center.captures(c), radius.captures(c)) {
prims.push(Prim { kind: 1, pts: vec![(f(ce.get(1)), f(ce.get(2)))], r: f(ra.get(1)) });
}
}
for pin in blocks(def, "pin") {
if let Some(a) = at3.captures(pin) {
let (lx, ly, ang) = (f(a.get(1)), f(a.get(2)), f(a.get(3)));
let len = length.captures(pin).map(|c| f(c.get(1))).unwrap_or(2.54);
let (dx, dy) = (ang.to_radians().cos() * len, ang.to_radians().sin() * len);
prims.push(Prim { kind: 2, pts: vec![(lx, ly), (lx + dx, ly + dy)], r: 0.0 });
}
}
sh.libs.insert(name, LibSym { prims });
}
}
// ── wires ──
let content_wo_lib = strip_first_block(content, "lib_symbols");
for w in blocks(&content_wo_lib, "wire") {
let pts: Vec<(f64, f64)> = xy.captures_iter(w).map(|c| (f(c.get(1)), f(c.get(2)))).collect();
if pts.len() >= 2 { sh.wires.push(Wire { x1: pts[0].0, y1: pts[0].1, x2: pts[1].0, y2: pts[1].1, net: 0 }); }
}
for j in blocks(&content_wo_lib, "junction") {
if let Some(a) = at3.captures(j) { sh.junctions.push((f(a.get(1)), f(a.get(2)))); }
}
for l in blocks(&content_wo_lib, "label").into_iter()
.chain(blocks(&content_wo_lib, "global_label"))
.chain(blocks(&content_wo_lib, "hierarchical_label")) {
if let Some(a) = at3.captures(l) {
let text = quoted(l).unwrap_or_default();
if !text.is_empty() { sh.labels.push(Label { x: f(a.get(1)), y: f(a.get(2)), text, power: false }); }
}
}
// ── symbol instances (top-level symbols outside lib_symbols) ──
for inst in top_symbols(&content_wo_lib) {
let lib_id = match capture1(inst, r##"\(lib_id\s+"([^"]+)"\)"##) { Some(s) => s, None => continue };
let a = match at3.captures(inst) { Some(a) => a, None => continue };
let (px, py, rot) = (f(a.get(1)), f(a.get(2)), f(a.get(3)));
let mirror = mirror_re.captures(inst).map(|c| if &c[1] == "x" { 2u8 } else { 1u8 }).unwrap_or(0);
let mut reference = String::new();
let mut value = String::new();
let mut footprint = String::new();
let mut mpn = String::new();
let mut lcsc = String::new();
for c in prop.captures_iter(inst) {
match &c[1] {
"Reference" => reference = c[2].to_string(),
"Value" => value = c[2].to_string(),
"Footprint" => footprint = c[2].to_string(),
k if k.eq_ignore_ascii_case("MPN") => mpn = c[2].to_string(),
k if k.eq_ignore_ascii_case("LCSC") || k.eq_ignore_ascii_case("JLCPCB") => lcsc = c[2].to_string(),
_ => {}
}
}
let is_power = lib_id.starts_with("power:");
// power symbols name their net (e.g. power:GND value "GND")
if is_power && !value.is_empty() { sh.labels.push(Label { x: px, y: py, text: value.clone(), power: true }); }
let lib = sh.libs.get(&lib_id);
let mut body = String::new();
let mut minx = f64::INFINITY; let mut miny = f64::INFINITY; let mut maxx = f64::NEG_INFINITY; let mut maxy = f64::NEG_INFINITY;
let mut pin_n = 0usize;
if let Some(lib) = lib {
for pr in &lib.prims {
let tp: Vec<(f64, f64)> = pr.pts.iter().map(|&(x, y)| xf(x, y, px, py, rot, mirror)).collect();
for &(x, y) in &tp { minx = minx.min(x); miny = miny.min(y); maxx = maxx.max(x); maxy = maxy.max(y); }
match pr.kind {
1 => { let _ = write!(body, r##"<circle cx="{:.3}" cy="{:.3}" r="{:.3}" fill="none" stroke="#d7c690" stroke-width="0.15"/>"##, tp[0].0, tp[0].1, pr.r); }
2 => { pin_n += 1; let _ = write!(body, r##"<line x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="#7fd9b0" stroke-width="0.15"/>"##, tp[0].0, tp[0].1, tp[1].0, tp[1].1); }
_ => { let _ = write!(body, r##"<path d="{}" fill="none" stroke="#d7c690" stroke-width="0.15"/>"##, poly_d(&tp)); }
}
}
}
if !minx.is_finite() { minx = px - 1.27; miny = py - 1.27; maxx = px + 1.27; maxy = py + 1.27; }
// reference + value labels
if !is_power && !reference.is_empty() {
let _ = write!(body, r##"<text x="{:.3}" y="{:.3}" font-size="1.3" fill="#f2d060">{}</text>"##, maxx + 0.4, miny + 1.2, esc(&reference));
if !value.is_empty() { let _ = write!(body, r##"<text x="{:.3}" y="{:.3}" font-size="1.1" fill="#9fb0a8">{}</text>"##, maxx + 0.4, miny + 2.7, esc(&value)); }
}
let g = format!(r##"<g data-ref="{}">{}</g>"##, esc(&reference), body);
sh.comp_svgs.push(g);
if !is_power {
sh.comps.push(Comp { reference, value, footprint, mpn, lcsc, x: px, y: py, bbox: [minx, miny, maxx, maxy], pin_count: pin_n });
}
}
// ── net grouping: union-find over wire endpoints (+ junctions) ──
let n = sh.wires.len();
let mut uf = Uf::new(n.max(1));
let mut endpoint_owner: HashMap<(i64, i64), usize> = HashMap::new();
for (i, w) in sh.wires.iter().enumerate() {
for k in [key(w.x1, w.y1), key(w.x2, w.y2)] {
if let Some(&j) = endpoint_owner.get(&k) { uf.union(i, j); } else { endpoint_owner.insert(k, i); }
}
}
// merge clusters that share a label/power NAME
let mut name_to_cluster: HashMap<String, usize> = HashMap::new();
// first assign each label to the nearest wire endpoint cluster
let labels_snapshot: Vec<Label> = sh.labels.iter().map(|l| Label { x: l.x, y: l.y, text: l.text.clone(), power: l.power }).collect();
for l in &labels_snapshot {
if let Some(&wi) = endpoint_owner.get(&key(l.x, l.y)) {
let c = uf.find(wi);
match name_to_cluster.get(&l.text) { Some(&other) => uf.union(c, other), None => { name_to_cluster.insert(l.text.clone(), c); } }
}
}
// assign a compact net id per root, remember names
let mut root_id: HashMap<usize, usize> = HashMap::new();
let mut next = 1usize;
for i in 0..n {
let r = uf.find(i);
let id = *root_id.entry(r).or_insert_with(|| { let v = next; next += 1; v });
sh.wires[i].net = id;
}
for l in &labels_snapshot {
if let Some(&wi) = endpoint_owner.get(&key(l.x, l.y)) {
let id = *root_id.get(&uf.find(wi)).unwrap_or(&0);
if id != 0 { sh.net_names.entry(id).or_insert_with(|| l.text.clone()); }
}
}
sh
}
pub fn render(sh: &Sheet) -> (String, RenderMeta) {
let mut pts: Vec<(f64, f64)> = vec![];
for w in &sh.wires { pts.push((w.x1, w.y1)); pts.push((w.x2, w.y2)); }
for c in &sh.comps { pts.push((c.bbox[0], c.bbox[1])); pts.push((c.bbox[2], c.bbox[3])); }
if pts.is_empty() { pts.push((0.0, 0.0)); pts.push((100.0, 100.0)); }
let margin = 5.0;
let min_x = pts.iter().map(|p| p.0).fold(f64::INFINITY, f64::min) - margin;
let min_y = pts.iter().map(|p| p.1).fold(f64::INFINITY, f64::min) - margin;
let max_x = pts.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max) + margin;
let max_y = pts.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max) + margin;
let (w, h) = (max_x - min_x, max_y - min_y);
let mut s = String::new();
let _ = write!(s, r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{:.3} {:.3} {:.3} {:.3}" preserveAspectRatio="xMidYMid meet" font-family="ui-sans-serif,system-ui,sans-serif">"##, min_x, min_y, w, h);
let _ = write!(s, r##"<rect class="adom-board-sub" x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="#0c130f"/>"##, min_x, min_y, w, h);
// wires
s.push_str(r##"<g class="adom-layer-copper">"##);
for wi in &sh.wires {
let _ = write!(s, r##"<line x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" data-net="{}" stroke="#4f8f6f" stroke-width="0.18" stroke-linecap="round"/>"##, wi.x1, wi.y1, wi.x2, wi.y2, wi.net);
}
s.push_str("</g>");
// junctions
s.push_str(r##"<g class="adom-layer-via">"##);
for &(x, y) in &sh.junctions { let _ = write!(s, r##"<circle cx="{:.3}" cy="{:.3}" r="0.4" fill="#7fd9b0"/>"##, x, y); }
s.push_str("</g>");
// symbol bodies
s.push_str(r##"<g class="adom-layer-pad">"##);
for g in &sh.comp_svgs { s.push_str(g); }
s.push_str("</g>");
// component hotspots
s.push_str(r##"<g class="adom-layer-hotspot">"##);
for c in &sh.comps {
let [x0, y0, x1, y1] = c.bbox;
let _ = write!(s, r##"<rect class="adom-hotspot" x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" data-ref="{}" fill="transparent"/>"##, x0 - 0.6, y0 - 0.6, (x1 - x0) + 1.2, (y1 - y0) + 1.2, esc(&c.reference));
}
s.push_str("</g>");
s.push_str("</svg>");
let net_names: HashMap<String, String> = sh.net_names.iter().map(|(k, v)| (k.to_string(), v.clone())).collect();
let meta = RenderMeta {
width_mm: w, height_mm: h, view_box: [min_x, min_y, w, h],
net_names, components: sh.comps.clone(),
track_count: sh.wires.len(), via_count: sh.junctions.len(), pad_count: 0,
};
(s, meta)
}
#[derive(Serialize)]
pub struct RenderMeta {
pub width_mm: f64, pub height_mm: f64, pub view_box: [f64; 4],
pub net_names: HashMap<String, String>, pub components: Vec<Comp>,
pub track_count: usize, pub via_count: usize, pub pad_count: usize,
}
// ── helpers ──
fn poly_d(pts: &[(f64, f64)]) -> String {
let mut d = String::new();
for (i, &(x, y)) in pts.iter().enumerate() { let _ = write!(d, "{}{:.3} {:.3} ", if i == 0 { "M" } else { "L" }, x, y); }
d
}
fn quoted(s: &str) -> Option<String> {
let q1 = s.find('"')?; let rest = &s[q1 + 1..]; let q2 = rest.find('"')?; Some(rest[..q2].to_string())
}
fn capture1(s: &str, re: &str) -> Option<String> { Regex::new(re).ok()?.captures(s).map(|c| c[1].to_string()) }
/// Top-level `(symbol …)` blocks (not nested). Used for both lib defs and
/// instances — callers pass the right slice.
fn top_symbols(s: &str) -> Vec<&str> {
let bytes = s.as_bytes();
let mut out = vec![];
let mut i = 0;
while let Some(rel) = s[i..].find("(symbol") {
let start = i + rel;
let after = start + 7;
if bytes.get(after).map(|c| !c.is_ascii_whitespace()).unwrap_or(true) { i = after; continue; }
// slice this balanced block
let mut depth = 0i32; let mut end = start;
for j in start..s.len() { match bytes[j] { b'(' => depth += 1, b')' => { depth -= 1; if depth == 0 { end = j; break; } } _ => {} } }
out.push(&s[start..=end.min(s.len() - 1)]);
i = end + 1;
}
out
}
/// Remove the first `(<tag> …)` block so instance scanning doesn't see lib defs.
fn strip_first_block(s: &str, tag: &str) -> String {
if let Some(b) = blocks(s, tag).into_iter().next() {
return s.replacen(b, "", 1);
}
s.to_string()
}