app
adom-schematic-viewer
Public Made by Adomby adom
Interactive schematic viewer: your EDA's own render plus per-symbol highlighting and live MPN, stock and price on hover
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
//! Parse a `.kicad_sch` for the INTERACTION overlay: each placed symbol's
//! identity (ref / value / footprint / LCSC PN / description) and its bounding
//! box in schematic PAGE coordinates (mm), so we can drop a transparent hover
//! hotspot over it aligned to the KiCad-native SVG. We do NOT render the sheet —
//! service-kicad provides the authentic visual.
use crate::sexpr::{blocks, esc, f};
use regex::Regex;
use serde::Serialize;
#[derive(Serialize, Clone)]
pub struct Comp {
pub reference: String,
pub value: String,
pub footprint: String,
pub lcsc: String,
pub description: String,
pub datasheet: String,
pub x: f64,
pub y: f64,
pub bbox: [f64; 4], // page mm: [minx,miny,maxx,maxy]
pub pin_count: usize,
/// Hit regions that belong to this symbol: the body bbox PLUS a box around
/// each visible property text (reference/value), because KiCad's selection
/// haloes that text too. Used only for tagging, not shipped in the meta.
#[serde(skip)]
pub regions: Vec<[f64; 4]>,
}
pub struct Sheet {
pub comps: Vec<Comp>,
pub content_bbox: (f64, f64, f64, f64), // minx,miny,w,h (mm) with margin
}
struct LibSym { pts: Vec<(f64, f64)> } // all graphic/pin points (lib coords)
/// Transform a lib point to page coords (lib frame is Y-up; sheet is Y-down).
fn xf(lx: f64, ly: f64, px: f64, py: f64, rot: f64, mirror: u8) -> (f64, f64) {
let (mut x, mut y) = (lx, -ly);
if mirror == 1 { x = -x; }
if mirror == 2 { y = -y; }
let a = (-rot).to_radians();
let (c, s) = (a.cos(), a.sin());
(px + (x * c - y * s), py + (x * s + y * c))
}
fn quoted(s: &str) -> Option<String> {
let q1 = s.find('"')?;
let rest = &s[q1 + 1..];
let q2 = rest.find('"')?;
Some(rest[..q2].to_string())
}
/// The n-th (0-based) double-quoted string in `s` — property name = 0, value = 1.
fn nth_quoted(s: &str, n: usize) -> Option<String> {
let mut rest = s;
for i in 0.. {
let q1 = rest.find('"')?;
let after = &rest[q1 + 1..];
let q2 = after.find('"')?;
if i == n { return Some(after[..q2].to_string()); }
rest = &after[q2 + 1..];
}
None
}
fn cap1(s: &str, re: &str) -> Option<String> { Regex::new(re).ok()?.captures(s).map(|c| c[1].to_string()) }
/// Top-level `(symbol …)` blocks (not nested).
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; }
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
}
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();
// ── lib_symbols: collect each definition's graphic + pin points ──
let mut libs: std::collections::HashMap<String, LibSym> = std::collections::HashMap::new();
if let Some(libblk) = blocks(content, "lib_symbols").into_iter().next() {
for def in top_symbols(libblk) {
let name = match quoted(def) { Some(n) if !n.is_empty() => n, _ => continue };
let mut pts: Vec<(f64, f64)> = vec![];
for r in blocks(def, "rectangle") {
if let Some(c) = se.captures(r) {
pts.push((f(c.get(1)), f(c.get(2))));
pts.push((f(c.get(3)), f(c.get(4))));
}
}
for p in blocks(def, "polyline") { for c in xy.captures_iter(p) { pts.push((f(c.get(1)), f(c.get(2)))); } }
for c in blocks(def, "circle") {
if let (Some(ce), Some(ra)) = (center.captures(c), radius.captures(c)) {
let (cx, cy, r) = (f(ce.get(1)), f(ce.get(2)), f(ra.get(1)));
pts.push((cx - r, cy - r)); pts.push((cx + r, cy + r));
}
}
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);
pts.push((lx, ly));
pts.push((lx + ang.to_radians().cos() * len, ly + ang.to_radians().sin() * len));
}
}
libs.insert(name, LibSym { pts });
}
}
let content_wo_lib = strip_first(content, "lib_symbols");
let mut comps: Vec<Comp> = vec![];
let (mut gminx, mut gminy, mut gmaxx, mut gmaxy) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for inst in top_symbols(&content_wo_lib) {
let lib_id = match cap1(inst, r##"\(lib_id\s+"([^"]+)"\)"##) { Some(s) => s, None => continue };
if lib_id.starts_with("power:") { continue; } // skip power/ground symbols
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, mut value, mut footprint) = (String::new(), String::new(), String::new());
let (mut lcsc, mut description, mut datasheet) = (String::new(), String::new(), 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(),
"Description" => description = c[2].to_string(),
"Datasheet" => datasheet = c[2].to_string(),
k if k.eq_ignore_ascii_case("LCSC PN") || k.eq_ignore_ascii_case("LCSC") || k.eq_ignore_ascii_case("JLCPCB") => lcsc = c[2].to_string(),
_ => {}
}
}
// bbox from the lib def's points transformed to page coords
let (mut minx, mut miny, mut maxx, mut maxy) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
let mut pin_n = 0usize;
if let Some(lib) = libs.get(&lib_id) {
for &(lx, ly) in &lib.pts {
let (x, y) = xf(lx, ly, px, py, rot, mirror);
minx = minx.min(x); miny = miny.min(y); maxx = maxx.max(x); maxy = maxy.max(y);
}
pin_n = blocks(libs_def(content, &lib_id).as_deref().unwrap_or(""), "pin").len();
}
if !minx.is_finite() { minx = px - 2.54; miny = py - 2.54; maxx = px + 2.54; maxy = py + 2.54; }
gminx = gminx.min(minx); gminy = gminy.min(miny); gmaxx = gmaxx.max(maxx); gmaxy = gmaxy.max(maxy);
// regions = symbol body + a box around each VISIBLE property text
let mut regions = vec![[minx, miny, maxx, maxy]];
for pb in blocks(inst, "property") {
if pb.contains("(hide yes)") { continue; }
let txt = nth_quoted(pb, 1).unwrap_or_default();
if txt.is_empty() || txt == "~" { continue; }
let a = match at3.captures(pb) { Some(a) => a, None => continue };
let (tx, ty, trot) = (f(a.get(1)), f(a.get(2)), f(a.get(3)));
let size = Regex::new(r"\(size\s+([\d.]+)").ok().and_then(|r| r.captures(pb).map(|c| f(c.get(1)))).unwrap_or(1.27);
let w = txt.chars().count() as f64 * size * 0.9 + 1.2;
let hh = size * 0.95;
// justify decides which way the text runs from its anchor
let (a0, a1) = if pb.contains("(justify right") { (-w, 0.0) }
else if pb.contains("(justify left") { (0.0, w) }
else { (-w / 2.0, w / 2.0) };
let r = if (trot - 90.0).abs() < 1.0 || (trot - 270.0).abs() < 1.0 {
[tx - hh, ty + a0, tx + hh, ty + a1] // vertical text
} else {
[tx + a0, ty - hh, tx + a1, ty + hh]
};
gminx = gminx.min(r[0]); gminy = gminy.min(r[1]); gmaxx = gmaxx.max(r[2]); gmaxy = gmaxy.max(r[3]);
regions.push(r);
}
comps.push(Comp { reference, value, footprint, lcsc, description, datasheet, x: px, y: py, bbox: [minx, miny, maxx, maxy], pin_count: pin_n, regions });
}
// include wires in the content bounds so the crop isn't too tight
for w in blocks(&content_wo_lib, "wire") {
for c in xy.captures_iter(w) { let (x, y) = (f(c.get(1)), f(c.get(2))); gminx = gminx.min(x); gminy = gminy.min(y); gmaxx = gmaxx.max(x); gmaxy = gmaxy.max(y); }
}
if !gminx.is_finite() { gminx = 0.0; gminy = 0.0; gmaxx = 100.0; gmaxy = 100.0; }
let m = 5.0;
let content_bbox = (gminx - m, gminy - m, (gmaxx - gminx) + 2.0 * m, (gmaxy - gminy) + 2.0 * m);
Sheet { comps, content_bbox }
}
/// Number of pins for a lib — pulled from the raw lib def (rough, for the card).
fn libs_def(content: &str, lib_id: &str) -> Option<String> {
let libblk = blocks(content, "lib_symbols").into_iter().next()?;
for def in top_symbols(libblk) {
if quoted(def).as_deref() == Some(lib_id) { return Some(def.to_string()); }
}
None
}
fn strip_first(s: &str, tag: &str) -> String {
if let Some(b) = blocks(s, tag).into_iter().next() { return s.replacen(b, "", 1); }
s.to_string()
}
/// Build the transparent hover-hotspot overlay (one rect per component).
pub fn build_overlay(sheet: &Sheet) -> String {
use std::fmt::Write as _;
let mut s = String::from(r##"<g class="adom-overlay">"##);
for c in &sheet.comps {
let [x0, y0, x1, y1] = c.bbox;
let _ = write!(s, r##"<rect class="adom-comp" x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" data-ref="{}" fill="transparent"/>"##,
x0, y0, (x1 - x0).max(0.5), (y1 - y0).max(0.5), esc(&c.reference));
}
s.push_str("</g>");
s
}