app
Adom Schematic
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
//! 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 crate::sexpr::esc;
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,
#[serde(default)]
pub sub_sheets: Vec<sch_parse::SubSheet>,
}
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,
segs: &HashMap<crate::kicad_self::SegKey, String>,
circles: &HashMap<(i32, i32, i32), String>,
fields: &[(String, String, f64, f64)],
pins: &[(String, f64, f64)],
labels: &[(f64, f64)]) -> 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 ONLY text that is a symbol's own is its Reference/Value field, placed at
// an exact `(at)` position (`fields`). Given a rendered text group's page bbox,
// return the reference whose Reference/Value anchor sits inside it (closest to
// centre when several qualify). Pin names/numbers, net & power labels have no
// field anchor there, so they stay standalone — exactly the user's rule.
let field_at = |bb: (f64, f64, f64, f64)| -> Option<&str> {
let (x0, y0, x1, y1) = bb;
let t = 1.5;
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let mut best: Option<(&str, f64)> = None;
for (rf, _txt, fx, fy) in fields {
if *fx >= x0 - t && *fx <= x1 + t && *fy >= y0 - t && *fy <= y1 + t {
let d = (fx - cx).powi(2) + (fy - cy).powi(2);
if best.map_or(true, |(_, bd)| d < bd) { best = Some((rf.as_str(), d)); }
}
}
best.map(|(r, _)| r)
};
// PIN NAME / NUMBER text: symbol geometry too. A pin's body-end anchors its
// name, its stub-midpoint anchors its number; a text group covering either (a
// bit of slack) belongs to that symbol. Net/power labels sit at the pin's
// CONNECTION point, which is NOT an anchor here, so they stay standalone.
let pin_at = |bb: (f64, f64, f64, f64)| -> Option<&str> {
let (x0, y0, x1, y1) = bb;
let t = 1.6;
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let mut best: Option<(&str, f64)> = None;
for (rf, ax, ay) in pins {
if *ax >= x0 - t && *ax <= x1 + t && *ay >= y0 - t && *ay <= y1 + t {
let d = (ax - cx).powi(2) + (ay - cy).powi(2);
if best.map_or(true, |(_, bd)| d < bd) { best = Some((rf.as_str(), d)); }
}
}
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(), ">"),
}
};
// Only SYMBOL graphics belong to a component. KiCad wraps each primitive in a
// `<g style="…stroke:#RRGGBB…">`: symbol bodies + fields are RED-family
// (#840000, #A90000), while WIRES/junctions/no-connects are GREEN/TEAL
// (#006464, #009600) and the paper is near-white. A big connector like J3 has
// a 25x35mm bbox that ENCLOSES lots of wires and labels; tagging by bbox alone
// captured them all, so hovering J3 lit half the sheet. Track the enclosing
// group's stroke colour and only tag inside red-family (or stroked-text)
// groups — a wire is never part of a component.
let gcolor = Regex::new(r#"stroke:\s*#([0-9A-Fa-f]{6})"#).unwrap();
// red-family = R channel clearly the largest → a symbol/field stroke
let is_symbol_color = |hex: &str| -> bool {
let (r, g, b) = (
u8::from_str_radix(&hex[0..2], 16).unwrap_or(0),
u8::from_str_radix(&hex[2..4], 16).unwrap_or(0),
u8::from_str_radix(&hex[4..6], 16).unwrap_or(0),
);
r as i32 > g as i32 + 20 && r as i32 > b as i32 + 20
};
let tag_re = Regex::new(r#"<g\b[^>]*>|</g>|<path\b[^>]*>|<rect\b[^>]*>|<circle\b[^>]*>"#).unwrap();
let path_d = Regex::new(r#"\bd="([^"]*)""#).unwrap();
let rect_xywh = Regex::new(r#"x="([-\d.]+)"\s*y="([-\d.]+)"\s*width="([-\d.]+)"\s*height="([-\d.]+)""#).unwrap();
let circ_cxy = Regex::new(r#"cx="([-\d.]+)"\s*cy="([-\d.]+)""#).unwrap();
let circ_r = Regex::new(r#"cx="([-\d.]+)"\s*cy="([-\d.]+)"\s*r="([-\d.]+)""#).unwrap();
let tf_re = Regex::new(r#"transform="([^"]*)""#).unwrap();
// KiCad rotates symbols/text with `transform="rotate(a cx cy)"`; the element's
// raw coords are in the PRE-rotation frame, so we must compose the group
// transform stack and map coords to PAGE space before containment-testing —
// otherwise a rotated label lands in a random bbox (this is what lit up J3).
#[derive(Clone, Copy)]
struct M { a: f64, b: f64, c: f64, d: f64, e: f64, f: f64 } // [a c e; b d f]
let ident = M { a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0, f: 0.0 };
let mul = |p: M, q: M| M {
a: p.a * q.a + p.c * q.b, b: p.b * q.a + p.d * q.b,
c: p.a * q.c + p.c * q.d, d: p.b * q.c + p.d * q.d,
e: p.a * q.e + p.c * q.f + p.e, f: p.b * q.e + p.d * q.f + p.f,
};
let apply = |m: &M, x: f64, y: f64| (m.a * x + m.c * y + m.e, m.b * x + m.d * y + m.f);
let parse_tf = |s: &str, base: M| -> M {
let mut cur = base;
for cap in Regex::new(r"(\w+)\s*\(([^)]*)\)").unwrap().captures_iter(s) {
let args: Vec<f64> = cap[2].split([',', ' ']).filter(|t| !t.is_empty()).filter_map(|t| t.parse().ok()).collect();
let t = match (&cap[1], args.as_slice()) {
("translate", [x, y, ..]) => M { a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: *x, f: *y },
("translate", [x]) => M { a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: *x, f: 0.0 },
("scale", [sx, sy, ..]) => M { a: *sx, b: 0.0, c: 0.0, d: *sy, e: 0.0, f: 0.0 },
("scale", [sx]) => M { a: *sx, b: 0.0, c: 0.0, d: *sx, e: 0.0, f: 0.0 },
("rotate", [deg, cx, cy, ..]) => {
let r = deg.to_radians(); let (co, si) = (r.cos(), r.sin());
// translate(cx,cy) * rotate * translate(-cx,-cy)
M { a: co, b: si, c: -si, d: co, e: cx - co * cx + si * cy, f: cy - si * cx - co * cy }
}
("rotate", [deg]) => { let r = deg.to_radians(); M { a: r.cos(), b: r.sin(), c: -r.sin(), d: r.cos(), e: 0.0, f: 0.0 } }
_ => M { a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0, f: 0.0 },
};
cur = mul(cur, t);
}
cur
};
// each group pushes (taggable colour flag, accumulated page transform)
let mut stack: Vec<(Option<bool>, M)> = Vec::new();
let taggable = |st: &[(Option<bool>, M)]| st.iter().rev().find_map(|x| x.0).unwrap_or(false);
let curm = |st: &[(Option<bool>, M)]| st.last().map(|x| x.1).unwrap_or(ident);
// transform a raw-coord bbox into page space (all four corners)
let to_page = |m: &M, bb: (f64, f64, f64, f64)| -> (f64, f64, f64, f64) {
let (x0, y0, x1, y1) = bb;
let cs = [apply(m, x0, y0), apply(m, x1, y0), apply(m, x1, y1), apply(m, x0, y1)];
let (mut mnx, mut mny, mut mxx, mut mxy) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for (x, y) in cs { mnx = mnx.min(x); mxx = mxx.max(x); mny = mny.min(y); mxy = mxy.max(y); }
(mnx, mny, mxx, mxy)
};
let inject = |tag: &str, cref: &str| -> String {
let t = tag.trim_end();
if let Some(rest) = t.strip_suffix("/>") { format!(r#"{} data-cref="{}"/>"#, rest.trim_end(), cref) }
else if let Some(rest) = t.strip_suffix('>') { format!(r#"{} data-cref="{}">"#, rest.trim_end(), cref) }
else { tag.to_string() }
};
// raw-coord bbox of any single element tag
let elem_bbox = |tag: &str| -> Option<(f64, f64, f64, f64)> {
if tag.starts_with("<path") { path_d.captures(tag).and_then(|c| ebbox(&c[1])) }
else if tag.starts_with("<rect") { rect_xywh.captures(tag).map(|c| { let (x, y, w, h) = (f2(&c[1]), f2(&c[2]), f2(&c[3]), f2(&c[4])); (x, y, x + w, y + h) }) }
else if tag.starts_with("<circle") { circ_cxy.captures(tag).map(|c| { let (x, y) = (f2(&c[1]), f2(&c[2])); (x, y, x, y) }) }
else { None }
};
let mut out = String::with_capacity(inner.len() + 4096);
let mut last = 0;
let bytes = inner.as_bytes();
for m in tag_re.find_iter(inner) {
if m.start() < last { continue; } // inside a block we already consumed
out.push_str(&inner[last..m.start()]);
last = m.end();
let tag = m.as_str();
if tag.starts_with("<g") {
// A stroked-text group is ONE text string (a ref/value/pin-name/net-label).
// Tag the WHOLE group by its combined position so a label is never split
// across components and stray text can't be claimed letter-by-letter by a
// big symbol like J3. Consume the whole group here.
if tag.contains(r#"class="stroked-text""#) {
let base = curm(&stack);
let mat = tf_re.captures(tag).map(|c| parse_tf(&c[1], base)).unwrap_or(base);
// find the matching </g> (stroked-text groups don't nest)
let mut depth = 1i32; let mut i = m.end();
while i < inner.len() && depth > 0 {
if inner[i..].starts_with("<g") { depth += 1; i += 2; }
else if inner[i..].starts_with("</g>") { depth -= 1; if depth == 0 { break; } i += 4; }
else { i += 1; }
}
let close_end = (i + 4).min(inner.len());
let block = &inner[m.end()..i.min(inner.len())];
// combined page-space bbox of every stroke in the group
let (mut mnx, mut mny, mut mxx, mut mxy) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for em in tag_re.find_iter(block) {
if let Some(bb) = elem_bbox(em.as_str()) {
let (a, b, c, d) = to_page(&mat, bb);
mnx = mnx.min(a); mny = mny.min(b); mxx = mxx.max(c); mxy = mxy.max(d);
}
}
// A text group belongs to a symbol when a Reference/Value field
// anchor sits inside it, OR when it lands on one of the symbol's pin
// name/number anchors. But if it sits on a power/net LABEL anchor it's
// a label (e.g. GND) — never tag it, even if a nearby pin anchor would
// otherwise claim it.
// `labels` are the ACTUAL label-text positions, so a tight radius
// catches the label itself without swallowing a nearby pin number.
let on_label = mnx.is_finite() && {
let (cx, cy) = ((mnx + mxx) / 2.0, (mny + mxy) / 2.0);
labels.iter().any(|&(lx, ly)| (cx - lx).abs() < 1.5 && (cy - ly).abs() < 1.5)
};
let cref = if mnx.is_finite() && !on_label {
field_at((mnx, mny, mxx, mxy)).or_else(|| pin_at((mnx, mny, mxx, mxy)))
} else { None };
out.push_str(tag);
match cref {
Some(r) => { // tag every path in the group with the same cref
let mut bl = block.to_string();
// inject cref into each element tag
let mut o2 = String::with_capacity(bl.len() + 256); let mut l2 = 0;
for em in tag_re.find_iter(&bl) {
o2.push_str(&bl[l2..em.start()]); l2 = em.end();
let et = em.as_str();
if et.starts_with("<path") || et.starts_with("<rect") || et.starts_with("<circle") { o2.push_str(&inject(et, r)); }
else { o2.push_str(et); }
}
o2.push_str(&bl[l2..]); bl = o2;
out.push_str(&bl);
}
None => out.push_str(block),
}
out.push_str("</g>");
last = close_end;
let _ = bytes; // (kept for clarity)
continue;
}
let flag = gcolor.captures(tag).map(|c| is_symbol_color(&c[1]));
let base = curm(&stack);
let mat = tf_re.captures(tag).map(|c| parse_tf(&c[1], base)).unwrap_or(base);
stack.push((flag, mat));
out.push_str(tag);
} else if tag == "</g>" {
stack.pop();
out.push_str(tag);
} else if !gcolor.captures(tag).map(|c| is_symbol_color(&c[1])).unwrap_or_else(|| taggable(&stack)) {
// Taggable when the element carries its OWN symbol-colour stroke inline,
// else fall back to the enclosing group's colour. KiCad emits some thick
// symbol strokes (cap plates, ferrite bodies) as standalone
// `<path style="stroke:#840000">` outside any coloured <g>; without the
// inline-colour check those were skipped and never highlighted.
out.push_str(tag);
} else {
// SYMBOL GRAPHIC: tag by GEOMETRY, not by region. Map the stroke's
// segments to page space and match them to a symbol's exact segments —
// a wire or another part's line never coincides, so it's never claimed.
let m0 = curm(&stack);
let mt = (m0.a, m0.b, m0.c, m0.d, m0.e, m0.f);
let _ = &elem_bbox; // (bbox helper retained for the text branch)
let cref = if tag.starts_with("<path") {
path_d.captures(tag).and_then(|c| match_path_segments(&c[1], &mt, segs, circles))
} else if tag.starts_with("<rect") {
// KiCad draws symbol bodies as <rect> (fill + outline). Match its 4
// edges to the symbol's segments exactly like a path — the page frame
// and title-block rects span no symbol so they're never claimed.
rect_xywh.captures(tag).and_then(|c| {
let (x, y, w, h) = (f2(&c[1]), f2(&c[2]), f2(&c[3]), f2(&c[4]));
let d = format!("M {x} {y} L {} {y} L {} {} L {x} {} L {x} {y}", x + w, x + w, y + h, y + h);
match_path_segments(&d, &mt, segs, circles)
})
} else if tag.starts_with("<circle") {
// KiCad draws test-point / switch-contact circles as <circle cx cy r>.
// Map the centre to page space and match against the symbol circles
// (centre+radius). Without this, those circles never highlighted.
circ_r.captures(tag).and_then(|c| {
let (cx, cy, r) = (f2(&c[1]), f2(&c[2]), f2(&c[3]));
let (px, py) = apply(&m0, cx, cy);
let q = |v: f64| (v * 20.0).round() as i32;
let mut found = None;
for dx in -1..=1 { for dy in -1..=1 { for dr in -1..=1 {
if let Some(rf) = circles.get(&(q(px) + dx, q(py) + dy, q(r) + dr)) { found = Some(rf.clone()); }
}}}
found
})
} else { None };
match cref {
Some(r) => out.push_str(&inject(tag, &r)),
None => out.push_str(tag),
}
}
}
out.push_str(&inner[last..]);
out
}
fn f2(s: &str) -> f64 { s.parse().unwrap_or(0.0) }
/// Extract the point sequence a path `d` visits (M/L/A endpoints), map to page
/// space, form segments, and return the component whose segments they match.
fn match_path_segments(d: &str, m: &(f64, f64, f64, f64, f64, f64),
segs: &HashMap<crate::kicad_self::SegKey, String>,
circles: &HashMap<(i32, i32, i32), String>) -> Option<String> {
// Parse the path into the points it visits. KiCad's SVG export is NOT uniform:
// - thin lines: "M177.0022 166.0072\nL285.0022 166.0072" (glued cmd, spaces)
// - thick lines: "M 211.3280,52.8320 211.3280,48.7680" (COMMAS + IMPLICIT
// lineto — the 2nd pair has no `L`) ← caps/ferrite plates
// - arcs: "M76.20 86.36 A0.635 0.635 0 0 0 74.93 86.36"
// Handle all three: commas → spaces, split the command letter off its digits,
// then walk a tiny state machine where a bare coordinate pair repeats the last
// command (so implicit linetos after M/L are captured). Missing this is exactly
// why cap plates and ferrite bodies never highlighted.
let norm: String = d.chars().map(|c| if c == ',' { ' ' } else { c }).collect();
let spaced: String = norm.chars().flat_map(|c| match c {
'M' | 'L' | 'A' | 'Z' | 'H' | 'V' | 'C' | 'm' | 'l' | 'a' | 'z' | 'h' | 'v' | 'c' => vec![' ', c, ' '],
_ => vec![c],
}).collect();
let toks: Vec<&str> = spaced.split_whitespace().collect();
let numf = |t: &str| t.parse::<f64>().ok();
let mut pts: Vec<(f64, f64)> = Vec::new();
let mut cmd = 'M';
let mut k = 0;
while k < toks.len() {
let t = toks[k];
let ch = t.chars().next().unwrap_or(' ');
if t.len() == 1 && ch.is_ascii_alphabetic() {
cmd = ch;
k += 1;
if cmd == 'Z' || cmd == 'z' { if let Some(&p0) = pts.first() { pts.push(p0); } }
continue;
}
match cmd {
'M' | 'L' | 'm' | 'l' => {
if let (Some(x), Some(y)) = (numf(t), toks.get(k + 1).and_then(|s| numf(s))) { pts.push((x, y)); }
k += 2;
}
'A' | 'a' => { // rx ry rot large sweep x y → take the endpoint
if let (Some(x), Some(y)) = (toks.get(k + 5).and_then(|s| numf(s)), toks.get(k + 6).and_then(|s| numf(s))) { pts.push((x, y)); }
k += 7;
}
_ => { k += 1; } // H/V/C rare in symbol graphics — skip
}
}
// page-space points
let (a, b, c, d2, e, f) = *m;
let page: Vec<(f64, f64)> = pts.iter().map(|&(x, y)| (a * x + c * y + e, b * x + d2 * y + f)).collect();
// vote across this path's segments
let mut votes: HashMap<&str, u32> = HashMap::new();
for w in page.windows(2) {
if let Some(r) = segs.get(&crate::kicad_self::seg_key_pub(w[0], w[1])) { *votes.entry(r.as_str()).or_insert(0) += 1; }
}
// an arc/circle stroke: match by its bbox centre + radius
if votes.is_empty() && page.len() >= 2 {
let (mut mnx, mut mny, mut mxx, mut mxy) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for (x, y) in &page { mnx = mnx.min(*x); mxx = mxx.max(*x); mny = mny.min(*y); mxy = mxy.max(*y); }
let (cx, cy, r) = ((mnx + mxx) / 2.0, (mny + mxy) / 2.0, ((mxx - mnx) + (mxy - mny)) / 4.0);
let q = |v: f64| (v * 20.0).round() as i32;
for dx in -1..=1 { for dy in -1..=1 { for dr in -1..=1 {
if let Some(r0) = circles.get(&(q(cx) + dx, q(cy) + dy, q(r) + dr)) { return Some(r0.clone()); }
}}}
}
votes.into_iter().max_by_key(|(_, n)| *n).map(|(r, _)| r.to_string())
}
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(), sub_sheets: sheet.sub_sheets.clone() }
}
/// Top-level render: parse identity → fetch KiCad SVG → crop to the drawing →
/// overlay component hotspots. All share the page-mm coordinate system.
/// Bytes-level entry point. Altium `.SchDoc` is a BINARY compound file, so it
/// cannot come in through the text path; everything else is UTF-8 text.
pub fn render_sheet_bytes(bytes: &[u8]) -> Result<(String, RenderMeta)> { render_sheet_bytes_base(bytes, None) }
pub fn render_sheet_bytes_base(bytes: &[u8], base: Option<&str>) -> Result<(String, RenderMeta)> {
if crate::altium_sch::looks_like_altium(bytes) {
match crate::altium_sch::render_sheet(bytes) {
Ok((svg, sheet)) => { let meta = build_meta(&sheet); return Ok((svg, meta)); }
Err(e) => eprintln!("[adom-schematic] altium parse failed ({e})"),
}
}
render_sheet_with_base(&String::from_utf8_lossy(bytes), base)
}
pub fn render_sheet(sch: &str) -> Result<(String, RenderMeta)> { render_sheet_with_base(sch, None) }
/// `base` is an AUTHENTIC pre-rendered KiCad SVG for this exact sheet (from the
/// wiki page's `render/sheet-NNN.svg`, produced by molecule-publish against the
/// FULL project). When present we use it verbatim as the visual base — real KiCad
/// fonts, and hierarchical roots render correctly — instead of asking service-kicad
/// (which can't render a lone hierarchical root). We NEVER self-draw text when a
/// base is available: the export must look exactly like the EDA.
pub fn render_sheet_with_base(sch: &str, base: Option<&str>) -> Result<(String, RenderMeta)> {
// Fusion 360 Electronics / EAGLE `.sch`: no headless renderer exists, so we
// draw the sheet ourselves (in EAGLE's own colours) and tag each primitive
// with its component directly — no bbox inference needed.
if crate::eagle_sch::looks_like_eagle(sch) {
match crate::eagle_sch::render_sheet(sch) {
Ok((svg, sheet)) => { let meta = build_meta(&sheet); return Ok((svg, meta)); }
Err(e) => eprintln!("[adom-schematic] eagle/fusion parse failed ({e}); trying KiCad"),
}
}
let sheet = sch_parse::parse(sch);
let meta = build_meta(&sheet);
// Prefer the authentic pre-rendered base; else the shared service-kicad export.
let raw_base = match base {
Some(b) if b.contains("<path") || b.contains("<text") => b.to_string(),
_ => fetch_svg(sch).unwrap_or_default(),
};
// GROUND TRUTH for hover highlighting: each symbol's exact line segments, from
// lib_symbols transformed to the instance placement. A stroke in the export
// belongs to a component IFF it coincides with one of these segments — a wire
// or another part's line never does. (Text is still matched by position.)
let (segs, circles) = crate::kicad_self::symbol_segments(sch);
// the only TEXT that is a symbol's own: its Reference + Value fields, at their
// exact `(at)` positions. Everything else (pin names/numbers, net/power labels)
// is standalone and stays untagged.
let fields = crate::kicad_self::field_anchors(sch);
// pin names (inside the body) + pin numbers (on the stubs) are symbol geometry
// too, so tag any text landing on a pin's body-end / stub-midpoint anchor.
let pins = crate::kicad_self::pin_anchors(sch);
// power/net label positions — text sitting on one is a LABEL (e.g. GND), never a
// symbol's own field/pin text, so it must not be tagged to a nearby component.
let labels = crate::kicad_self::label_anchors(sch);
let inner = tag_symbols(&extract_inner(&raw_base), &segs, &circles, &fields, &pins, &labels);
// LAST RESORT ONLY (no authentic base AND service-kicad returned empty — e.g. a
// hierarchical root on a page with no pre-render): self-draw so it isn't blank.
let inner = if base.is_none() && inner.matches("stroke:").count() < 60 && (!sheet.sub_sheets.is_empty() || !sheet.comps.is_empty()) {
let self_render = crate::kicad_self::render_inner(sch);
if self_render.is_empty() { inner } else { format!("{}{}", self_render, sheet_nav_svg(&sheet)) }
} else { inner };
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))
}
/// Self-drawn hierarchical navigation base: one KiCad-style sheet box per child
/// (border + sheet name inside the top edge + filename below), used when
/// service-kicad returns an empty root page. The clickable overlay sits on top.
fn sheet_nav_svg(sheet: &Sheet) -> String {
let mut s = String::new();
for sh in &sheet.sub_sheets {
let [x0, y0, x1, y1] = sh.bbox;
let (w, h) = ((x1 - x0).max(2.0), (y1 - y0).max(2.0));
// KiCad sheet-symbol colours: brown/red border + name, blue file text.
let _ = write!(s, r##"<rect x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="none" stroke="#843c39" stroke-width="0.15"/>"##, x0, y0, w, h);
let _ = write!(s, r##"<text x="{:.3}" y="{:.3}" font-size="2.0" fill="#843c39" font-family="monospace">{}</text>"##, x0, y0 - 0.8, esc(&sh.name));
let _ = write!(s, r##"<text x="{:.3}" y="{:.3}" font-size="1.5" fill="#0d6d6d" font-family="monospace">File: {}</text>"##, x0, y1 + 2.0, esc(&sh.file));
}
s
}
/// 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>;