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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
//! Fetch a `.kicad_sch` rendered to an authentic KiCad SVG from service-kicad,
//! and compose it (cropped to the drawing) with our component hotspot overlay.
//!
//! Like the layout viewer: the visual is the EDA's own export; we only add an
//! invisible interaction layer aligned to the same page-mm coordinate system.
use crate::sch_parse::{self, Sheet};
use anyhow::{bail, Result};
use serde::Serialize;
use std::collections::HashMap;
use std::fmt::Write as _;
use std::time::Duration;
const SERVICE_DEFAULT: &str = "https://kicad-rk5ue5pcfemi.adom.cloud";
#[derive(Serialize)]
pub struct RenderMeta {
pub view_box: [f64; 4],
pub components: Vec<sch_parse::Comp>,
pub comp_count: usize,
}
fn base() -> String {
std::env::var("KICAD_SERVICE_API").unwrap_or_else(|_| SERVICE_DEFAULT.to_string()).trim_end_matches('/').to_string()
}
/// Fetch the KiCad SVG for the whole sheet.
fn fetch_svg(sch: &str) -> Result<String> {
let url = format!("{}/kicad/sch/export/svg", base());
let resp = ureq::post(&url)
.set("Content-Type", "application/octet-stream")
.timeout(Duration::from_secs(30))
.send_bytes(sch.as_bytes())
.map_err(|e| anyhow::anyhow!("service-kicad sch export: {e}"))?;
let svg = resp.into_string()?;
if !svg.contains("<path") { bail!("service-kicad returned an empty sheet"); }
Ok(svg)
}
fn extract_inner(svg: &str) -> String {
let open_end = match svg.find("<svg").and_then(|s| svg[s..].find('>').map(|e| s + e + 1)) { Some(i) => i, None => return String::new() };
let close = svg.rfind("</svg>").unwrap_or(svg.len());
svg[open_end..close].to_string()
}
/// Tag each KiCad drawing element (path / circle / stroked-text group) with the
/// component whose bbox CONTAINS the element (`data-cref`), so the frontend can
/// recolour just that symbol's real lines on hover. Wires (which span outside any
/// one symbol) and the page frame are never captured.
fn tag_symbols(inner: &str, comps: &[sch_parse::Comp]) -> String {
use regex::Regex;
let num = Regex::new(r"-?\d+\.?\d*").unwrap();
// bbox of a coordinate string (from a `d`/`points` attr; NOT the whole tag)
let ebbox = |coords: &str| -> Option<(f64, f64, f64, f64)> {
let ns: Vec<f64> = num.find_iter(coords).filter_map(|m| m.as_str().parse().ok()).collect();
if ns.len() < 2 { return None; }
let (mut mnx, mut mny, mut mxx, mut mxy) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for p in ns.chunks_exact(2) { mnx = mnx.min(p[0]); mxx = mxx.max(p[0]); mny = mny.min(p[1]); mxy = mxy.max(p[1]); }
if mnx.is_finite() { Some((mnx, mny, mxx, mxy)) } else { None }
};
// the SMALLEST component whose bbox fully CONTAINS the element's bbox (so a
// page-spanning frame path or a wire is never captured, and nested symbols
// pick the tightest match).
// smallest region (across every component's body + property-text boxes) that
// fully CONTAINS the element — wires/frame span outside all of them.
let find = |e: (f64, f64, f64, f64)| -> Option<&str> {
let (ex0, ey0, ex1, ey1) = e;
let t = 1.4; // tolerance so the symbol body outline is caught, not just internals
let mut best: Option<(&str, f64)> = None;
for c in comps {
for r in &c.regions {
let [x0, y0, x1, y1] = *r;
if ex0 >= x0 - t && ey0 >= y0 - t && ex1 <= x1 + t && ey1 <= y1 + t {
let area = (x1 - x0) * (y1 - y0);
if best.map_or(true, |(_, ba)| area < ba) { best = Some((c.reference.as_str(), area)); }
}
}
}
best.map(|(r, _)| r)
};
// Injecting an attribute must PRESERVE self-closing tags: KiCad emits
// `<path … />`, so the captured tail ends in `/`. Strip it and re-emit `/>`,
// otherwise the element stops being self-closed and the rest of the sheet
// nests inside it (which mangles the whole drawing).
let split_close = |tail: &str| -> (String, &'static str) {
let t = tail.trim_end();
match t.strip_suffix('/') {
Some(rest) => (rest.to_string(), "/>"),
None => (tail.to_string(), ">"),
}
};
// <path … d="…"/> and <circle … cx cy …/>
let path_re = Regex::new(r#"<path\b([^>]*?)\bd="([^"]*)"([^>]*)>"#).unwrap();
let out = path_re.replace_all(inner, |c: ®ex::Captures| {
let (a, d, b) = (&c[1], &c[2], &c[3]);
let (tail, close) = split_close(b);
match ebbox(d).and_then(find) {
Some(r) => format!(r#"<path{} d="{}"{} data-cref="{}"{}"#, a, d, tail, r, close),
None => format!(r#"<path{} d="{}"{}{}"#, a, d, tail, close),
}
}).into_owned();
// <rect> symbol bodies (e.g. the IC outline) — tag by their x/y/w/h bbox.
let rect_re = Regex::new(r#"<rect\b([^>]*?)x="([-\d.]+)"\s*y="([-\d.]+)"\s*width="([-\d.]+)"\s*height="([-\d.]+)"([^>]*)>"#).unwrap();
let out = rect_re.replace_all(&out, |c: ®ex::Captures| {
let (a, x, y, w, h, b) = (&c[1], c[2].parse::<f64>().unwrap_or(0.0), c[3].parse::<f64>().unwrap_or(0.0), c[4].parse::<f64>().unwrap_or(0.0), c[5].parse::<f64>().unwrap_or(0.0), &c[6]);
let (tail, close) = split_close(b);
match find((x, y, x + w, y + h)) {
Some(r) => format!(r#"<rect{}x="{}" y="{}" width="{}" height="{}"{} data-cref="{}"{}"#, a, x, y, w, h, tail, r, close),
None => format!(r#"<rect{}x="{}" y="{}" width="{}" height="{}"{}{}"#, a, x, y, w, h, tail, close),
}
}).into_owned();
// circles are rare here (junction dots live in wires); tag by centre point.
let circ_re = Regex::new(r#"<circle\b([^>]*?)cx="([-\d.]+)"\s*cy="([-\d.]+)"([^>]*)>"#).unwrap();
// NOTE: individual <path>s (incl. every stroked-text letter-stroke) are already
// tagged above, so we don't need to tag the <g class="stroked-text"> wrapper —
// that keeps the dim-everything-except-.symhl CSS simple (no group inheritance).
circ_re.replace_all(&out, |c: ®ex::Captures| {
let (a, cx, cy, b) = (&c[1], c[2].parse::<f64>().unwrap_or(0.0), c[3].parse::<f64>().unwrap_or(0.0), &c[4]);
let (tail, close) = split_close(b);
match find((cx, cy, cx, cy)) {
Some(r) => format!(r#"<circle{}cx="{}" cy="{}"{} data-cref="{}"{}"#, a, cx, cy, tail, r, close),
None => format!(r#"<circle{}cx="{}" cy="{}"{}{}"#, a, cx, cy, tail, close),
}
}).into_owned()
}
fn build_meta(sheet: &Sheet) -> RenderMeta {
let (x, y, w, h) = sheet.content_bbox;
RenderMeta { view_box: [x, y, w, h], comp_count: sheet.comps.len(), components: sheet.comps.clone() }
}
/// Top-level render: parse identity → fetch KiCad SVG → crop to the drawing →
/// overlay component hotspots. All share the page-mm coordinate system.
pub fn render_sheet(sch: &str) -> Result<(String, RenderMeta)> {
let sheet = sch_parse::parse(sch);
let meta = build_meta(&sheet);
// tag each KiCad drawing element with its component (data-cref) by coordinate
// containment, so we can recolour that symbol's actual lines on hover.
let inner = tag_symbols(&extract_inner(&fetch_svg(sch)?), &sheet.comps);
let (x, y, w, h) = sheet.content_bbox;
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">"##, x, y, w, h);
let _ = write!(s, r##"<rect class="adom-sheet-bg" x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="#fbfbf6"/>"##, x, y, w, h);
let _ = write!(s, r##"<g class="adom-sheet">{}</g>"##, inner);
s.push_str(&sch_parse::build_overlay(&sheet));
s.push_str("</svg>");
Ok((s, meta))
}
/// Meta-only (for callers that already have an SVG or want the component list).
pub fn meta_json(sch: &str) -> Result<String> {
Ok(serde_json::to_string(&build_meta(&sch_parse::parse(sch)))?)
}
// small helper so the map type is available if needed later
#[allow(dead_code)]
type _M = HashMap<String, String>;