app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850
//! Render a `.kicad_sym` to a themed, cropped dark SVG via **service-kicad**
//! (no local KiCad, no gallia). Wraps the symbol in a one-symbol schematic,
//! POSTs to `/kicad/sch/export/svg`, strips KiCad's page background, crops to
//! the symbol's bounding box, and applies the Adom dark theme.
use anyhow::{anyhow, Context, Result};
use regex::Regex;
const KICAD_SERVICE_DEFAULT: &str = "https://kicad-rk5ue5pcfemi.adom.cloud";
fn service_url() -> String {
std::env::var("KICAD_SERVICE_API")
.unwrap_or_else(|_| KICAD_SERVICE_DEFAULT.to_string())
.trim_end_matches('/')
.to_string()
}
/// Render the named symbol from `.kicad_sym` content to a themed SVG string.
pub fn render_symbol_svg(kicad_sym: &str, symbol_name: &str) -> Result<String> {
let block = extract_symbol_block(kicad_sym, symbol_name)
.ok_or_else(|| anyhow!("symbol \"{symbol_name}\" not found in .kicad_sym"))?;
let sch = build_schematic(symbol_name, &block);
let url = format!("{}/kicad/sch/export/svg", service_url());
let resp = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()?
.post(&url)
.header("Content-Type", "application/octet-stream")
.body(sch)
.send()
.with_context(|| format!("POST {url}"))?;
if !resp.status().is_success() {
return Err(anyhow!("service-kicad {} — {}", resp.status(), resp.text().unwrap_or_default()));
}
let svg = resp.text()?;
// Drop KiCad 10's warm-white page background so the dark canvas shows.
let svg = svg.replace("fill:#F5F4EF", "fill:none").replace("stroke:#F5F4EF", "stroke:none");
// crop_to_symbol also STRIPS the KiCad drawing-sheet (A4 border + Sheet/File/
// Title/Size title block) — every top-level element that lies entirely outside
// the symbol's box is deleted, so it can't appear on zoom-out. Removing it
// (rather than clipping) keeps the SVG flat, so the app's SVG-space pan/zoom
// and its 3D-chip layering are untouched.
let cropped = crop_to_symbol(&svg, &block);
let themed = crate::theme::apply_dark_theme_to_symbol(&cropped);
Ok(strip_drawing_sheet(&themed))
}
/// Delete the KiCad drawing-sheet (A4 border + "Sheet/File/Title/Size" title
/// block) from the SVG: every `<path>`/`<polyline>`/`<text>` whose coordinates
/// lie ENTIRELY outside the symbol's viewBox (the sheet is drawn out at the page
/// edges, axis-aligned) is dropped. Removing it — rather than clipping — keeps
/// the SVG flat, so the app's SVG-space pan/zoom transform and its 3D-chip
/// layering are untouched, and nothing can appear on zoom-out.
fn strip_drawing_sheet(svg: &str) -> String {
let vb = Regex::new(r#"viewBox="(-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+)""#).unwrap();
let (vx, vy, vw, vh) = match vb.captures(svg) {
Some(c) => (
c[1].parse::<f64>().unwrap_or(0.0),
c[2].parse::<f64>().unwrap_or(0.0),
c[3].parse::<f64>().unwrap_or(0.0),
c[4].parse::<f64>().unwrap_or(0.0),
),
None => return svg.to_string(),
};
let m = 1.0;
let (x0, x1, y0, y1) = (vx - m, vx + vw + m, vy - m, vy + vh + m);
let num = Regex::new(r"-?\d+\.?\d*").unwrap();
// A shape is drawing-sheet iff ALL its X are outside the box OR ALL its Y are
// (true for the axis-aligned page border + the bottom-right title block, never
// for the symbol, which lives inside the box).
let outside = |coords: &str, attr_pair: Option<(f64, f64)>| -> bool {
let nums: Vec<f64> = match attr_pair {
Some((x, y)) => vec![x, y],
None => num.find_iter(coords).filter_map(|m| m.as_str().parse().ok()).collect(),
};
if nums.len() < 2 {
return false;
}
let all_x_out = nums.iter().step_by(2).all(|&x| x < x0 || x > x1);
let all_y_out = nums.iter().skip(1).step_by(2).all(|&y| y < y0 || y > y1);
all_x_out || all_y_out
};
let mut s = svg.to_string();
for attr in ["d", "points"] {
let re = Regex::new(&format!(r#"<(?:path|polyline)\b[^>]*\b{attr}="([^"]*)"[^>]*/?>"#)).unwrap();
s = re
.replace_all(&s, |c: ®ex::Captures| {
if outside(&c[1], None) { String::new() } else { c[0].to_string() }
})
.into_owned();
}
// Invisible title-block strings (<text x=".." y="..">Sheet: /…</text>); their
// paired stroked-text glyph <path>s are already gone from the pass above.
let tre = Regex::new(r#"<text\b([^>]*)>[^<]*</text>"#).unwrap();
let xre = Regex::new(r#"\bx="(-?[\d.]+)""#).unwrap();
let yre = Regex::new(r#"\by="(-?[\d.]+)""#).unwrap();
tre.replace_all(&s, |c: ®ex::Captures| {
let a = &c[1];
match (
xre.captures(a).and_then(|m| m[1].parse::<f64>().ok()),
yre.captures(a).and_then(|m| m[1].parse::<f64>().ok()),
) {
(Some(x), Some(y)) if outside("", Some((x, y))) => String::new(),
_ => c[0].to_string(),
}
})
.into_owned()
}
fn extract_symbol_block(content: &str, name: &str) -> Option<String> {
let marker = format!("(symbol \"{name}\"");
let start = content.find(&marker)?;
let bytes = content.as_bytes();
let mut depth = 0i32;
let mut end = start;
for i in start..content.len() {
match bytes[i] {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
end = i;
break;
}
}
_ => {}
}
}
Some(content[start..=end].to_string())
}
fn build_schematic(name: &str, block: &str) -> String {
// KiCad ignores the library symbol's Reference/Value position unless the
// schematic INSTANCE carries explicit property placements. Mirror the lib
// symbol's positions (symbol placed at 100,100; schematic Y flips) so the
// designator renders exactly where ref_pos put it.
let (rx, ry) = prop_at(block, "Reference").unwrap_or((0.0, 12.7));
let (vx, vy) = prop_at(block, "Value").unwrap_or((0.0, -12.7));
let rpre = prop_val(block, "Reference").unwrap_or_else(|| "U".to_string());
// The on-canvas Value is the symbol's OWN Value property (a short human value
// like "33Ω" for a clean discrete) — NOT the symbol name (the MPN). Falling
// back to the name only when there is no Value keeps IC behavior unchanged.
let vval = prop_val(block, "Value").filter(|v| !v.is_empty()).unwrap_or_else(|| name.to_string());
let rjust = prop_justify(block, "Reference");
let vjust = prop_justify(block, "Value");
// If the library symbol hides the Value (chip name laser-marked on the 3D
// chip instead), the schematic instance must hide it too — otherwise the
// visible instance overrides the hidden library property and the label
// reappears under the symbol.
let vhide = if prop_hidden(block, "Value") { " hide" } else { "" };
let (srx, sry) = (100.0 + rx, 100.0 - ry);
let (svx, svy) = (100.0 + vx, 100.0 - vy);
format!(
"(kicad_sch (version 20211123) (generator eeschema)\n \
(uuid \"00000000-0000-0000-0000-000000000001\")\n (paper \"A4\")\n \
(lib_symbols\n {block}\n )\n \
(symbol (lib_id \"{name}\") (at 100 100 0) (unit 1)\n \
(in_bom yes) (on_board yes)\n \
(uuid \"00000000-0000-0000-0000-000000000002\")\n \
(property \"Reference\" \"{rpre}?\" (at {srx:.3} {sry:.3} 0) (effects (font (size 1.27 1.27)){rjust}))\n \
(property \"Value\" \"{vval}\" (at {svx:.3} {svy:.3} 0) (effects (font (size 1.27 1.27)){vjust}{vhide}))\n )\n \
(sheet_instances (path \"/\" (page \"1\")))\n)\n"
)
}
/// Extract the `(at x y …)` of a named visible property in the symbol block.
fn prop_at(block: &str, name: &str) -> Option<(f64, f64)> {
// The value span `"(?:[^"\\]|\\.)*"` crosses KiCad-escaped quotes (\") so a
// designator with a quote in its value still matches.
let re = Regex::new(&format!(r#"(?s)\(property "{name}" "(?:[^"\\]|\\.)*"\s*\(at (-?[\d.]+) (-?[\d.]+)"#)).unwrap();
let c = re.captures(block)?;
Some((c[1].parse().ok()?, c[2].parse().ok()?))
}
fn prop_val(block: &str, name: &str) -> Option<String> {
let re = Regex::new(&format!(r#"\(property "{name}" "((?:[^"\\]|\\.)*)""#)).unwrap();
re.captures(block).map(|c| c[1].to_string())
}
/// Whether a named property carries the `hide` flag — extract just that
/// property's balanced-paren block and look for `hide` inside it (so a later
/// always-hidden property like Footprint can't false-positive a visible Value).
fn prop_hidden(block: &str, name: &str) -> bool {
let marker = format!("(property \"{name}\"");
let Some(start) = block.find(&marker) else { return false };
let bytes = block.as_bytes();
let mut depth = 0i32;
for i in start..block.len() {
match bytes[i] {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
return block[start..=i].contains("hide");
}
}
_ => {}
}
}
false
}
/// Extract the property's ` (justify …)` clause (so left/right placements render
/// aligned), or empty if none.
fn prop_justify(block: &str, name: &str) -> String {
let re = Regex::new(&format!(r#"(?s)\(property "{name}".*?\(effects[^)]*?(\(justify [^)]+\))"#)).unwrap();
re.captures(block).map(|c| format!(" {}", &c[1])).unwrap_or_default()
}
/// The current `(at x y)` of the Reference/Value designator for `symbol_name`,
/// in KiCad symbol-local mm (Y-up). Powers the app's draggable field handles.
pub fn field_at(kicad_sym: &str, symbol_name: &str, field: &str) -> Option<(f64, f64)> {
let block = extract_symbol_block(kicad_sym, symbol_name)?;
prop_at(&block, field)
}
/// The string VALUE of a named property (e.g. Reference prefix "U", Value name)
/// for `symbol_name` — lets the app match the exact on-screen <text> for a field.
pub fn field_val(kicad_sym: &str, symbol_name: &str, field: &str) -> Option<String> {
let block = extract_symbol_block(kicad_sym, symbol_name)?;
prop_val(&block, field)
}
/// Rewrite a designator's position: replace the X Y of `(property "<field>" "…"
/// (at X Y A) …)` with `x y`, KEEPING the rotation angle A. Used to bake a
/// user-dragged / auto-placed Reference or Value position into the symbol so it
/// renders there and ships to KiCad/EAGLE exactly as positioned. Idempotent and
/// source-agnostic (works on a generated OR a vendor symbol). Untouched if the
/// field isn't found.
pub fn set_field_pos(kicad_sym: &str, field: &str, x: f64, y: f64) -> String {
let re = Regex::new(&format!(
r#"(\(property "{field}" "(?:[^"\\]|\\.)*"\s*\(at )(-?[\d.]+) (-?[\d.]+)( [-\d.]+\))"#
))
.unwrap();
re.replace(kicad_sym, |c: ®ex::Captures| {
format!("{}{:.3} {:.3}{}", &c[1], x, y, &c[4])
})
.into_owned()
}
/// Composite the 3D-chip overlay into the symbol body centre — the "default
/// symbol with the 3D chip in it" the chip-fetcher dashboard card shows, centred
/// in the symbol's largest body rectangle and sized to ~62% of it so pin
/// names/numbers near the edges stay readable.
///
/// For a **vector** overlay (`.svg` from chip-thumbnailer) we INLINE the real
/// strokes as `<polyline>`s (full resolution, each layer keeping its own
/// colour/width/dash + any glow `<defs>`), so the symbol stays crisp vector in
/// EVERY renderer — browser, librsvg thumbnailer, anything. (Wrapping the SVG in
/// an `<image data:svg+xml>` rasterised it in non-browser renderers.) For a
/// **raster** overlay (`--iso` PNG) we fall back to an `<image>` element.
pub fn composite_iso_into_symbol(svg: &str, block: &str, _name: &str, iso_path: &std::path::Path, frac: f64) -> Result<String> {
let is_svg = iso_path.extension().and_then(|e| e.to_str()) == Some("svg");
let markup = if is_svg {
let overlay = std::fs::read_to_string(iso_path)
.with_context(|| format!("reading outline {}", iso_path.display()))?;
inline_outline_markup(block, &overlay, frac)
.ok_or_else(|| anyhow!("no body geometry / outline strokes to inline"))?
} else {
let bytes = std::fs::read(iso_path)
.with_context(|| format!("reading iso {}", iso_path.display()))?;
let data = format!("data:image/png;base64,{}", b64(&bytes));
const PX: f64 = 100.0;
const PY: f64 = 100.0;
let (cx, cy, bw, bh) = match largest_body_rect(block) {
Some((sx, sy, ex, ey)) => (PX + (sx + ex) / 2.0, PY - (sy + ey) / 2.0, (ex - sx).abs(), (ey - sy).abs()),
None => match symbol_local_bbox(block) {
Some((minx, maxx, miny, maxy)) => (PX + (minx + maxx) / 2.0, PY - (miny + maxy) / 2.0, (maxx - minx).abs(), (maxy - miny).abs()),
None => return Err(anyhow!("no body geometry to place iso")),
},
};
let (w, h) = (bw * frac, bh * frac);
let (x, y) = (cx - w / 2.0, cy - h / 2.0);
format!(
"<image x=\"{x:.3}\" y=\"{y:.3}\" width=\"{w:.3}\" height=\"{h:.3}\" \
preserveAspectRatio=\"xMidYMid meet\" opacity=\"0.96\" href=\"{data}\" />"
)
};
// Inject just before </svg> so it paints on top of the symbol body.
if let Some(pos) = svg.rfind("</svg>") {
let mut out = String::with_capacity(svg.len() + markup.len() + 8);
out.push_str(&svg[..pos]);
out.push_str(&markup);
out.push_str(&svg[pos..]);
Ok(out)
} else {
Err(anyhow!("no </svg> close tag to inject overlay into"))
}
}
/// Build the inline vector markup (`<defs>` + a `<g>` of `<polyline>`s) that
/// places a chip-thumbnailer outline SVG into the symbol body at FULL resolution,
/// in the `<mpn>-symbol.svg` frame, faithful to each layer's stroke style. This
/// is the crisp-vector twin of the lite `.mm.svg` the outline contract emits.
fn inline_outline_markup(block: &str, overlay_svg: &str, frac: f64) -> Option<String> {
let place = outline_placement(block, overlay_svg, frac)?;
let elems = parse_poly_elems(overlay_svg);
if elems.is_empty() {
return None;
}
let mut out = String::with_capacity(overlay_svg.len());
let defs = scaled_defs(overlay_svg, place.scale);
if !defs.is_empty() {
out.push_str(&defs);
}
out.push_str("<g fill=\"none\" stroke-linejoin=\"round\" stroke-linecap=\"round\" opacity=\"0.96\">");
for e in &elems {
let w = (e.stroke_width * place.scale).max(0.02);
out.push_str(&format!("<polyline stroke=\"{}\" stroke-width=\"{:.4}\"", e.stroke, w));
if let Some(d) = &e.dash {
let scaled: Vec<String> = d
.split(|c| c == ',' || c == ' ')
.filter(|s| !s.is_empty())
.map(|s| s.parse::<f64>().map(|v| format!("{:.4}", v * place.scale)).unwrap_or_else(|_| s.to_string()))
.collect();
out.push_str(&format!(" stroke-dasharray=\"{}\"", scaled.join(",")));
}
if let Some(f) = &e.filter {
out.push_str(&format!(" filter=\"{f}\""));
}
out.push_str(" points=\"");
for (i, &(px, py)) in e.pts.iter().enumerate() {
let (x, y) = place.to_svg(px, py);
if i > 0 {
out.push(' ');
}
out.push_str(&format!("{x:.4},{y:.4}"));
}
out.push_str("\"/>");
}
out.push_str("</g>");
Some(out)
}
/// The fraction of the symbol body the composited 3D-chip outline covers.
/// MUST stay in sync with the `frac` used in `composite_iso_into_symbol` — the
/// emitted outline contract (mm.svg + sidecar) describes exactly that placement.
pub const OUTLINE_BODY_FRAC: f64 = 0.62;
/// The exact placement adom-symbol uses to drop the 3D-chip outline into the
/// symbol body — computed once, then expressed in BOTH coordinate frames so a
/// consumer (adom-lbr) can transcribe it 1:1 with zero guessing.
pub struct OutlinePlacement {
/// Source outline viewBox `[min_x, min_y, w, h]` (the square OCCT canvas,
/// normally `[0,0,320,320]`). preserveAspectRatio=meet fits THIS canvas
/// (not the ink bbox) into the body box — the historical drift source.
pub source_viewbox: [f64; 4],
/// mm per source unit. `min(body_w, body_h) * OUTLINE_BODY_FRAC / canvas`.
pub scale: f64,
/// The body-fraction the outline was sized to (Large 0.62 / Medium / Small).
pub frac: f64,
/// Body rectangle in `.kicad_sym` symbol-local mm (Y-up): [left, bottom, right, top].
pub body_rect: [f64; 4],
/// Outline centre in `.kicad_sym` / EAGLE frame: mm, **Y-up**, origin at the
/// symbol origin. This is what adom-lbr wants — EAGLE shares this frame.
pub center_kicad: (f64, f64),
/// Outline centre in the `<mpn>-symbol.svg` frame: mm, **Y-down**, +100 offset
/// origin (i.e. matches that SVG's viewBox). Used to draw the .mm.svg overlay.
pub center_svg: (f64, f64),
}
impl OutlinePlacement {
/// Map a source-outline point (Y-down px) into the `<mpn>-symbol.svg` frame
/// (mm, Y-down) — used for the drop-in overlay .mm.svg.
fn to_svg(&self, px: f64, py: f64) -> (f64, f64) {
let (sx, sy) = (self.source_viewbox[0] + self.source_viewbox[2] / 2.0,
self.source_viewbox[1] + self.source_viewbox[3] / 2.0);
(self.center_svg.0 + (px - sx) * self.scale,
self.center_svg.1 + (py - sy) * self.scale)
}
}
/// The white-native outline asset to composite into a saved `<mpn>-symbol.svg`
/// (thickness ladder, then teal) — mirrors the CLI `render` default so auto-saved
/// thumbnails match the canonical look. None if no outline has been generated.
pub fn default_white_outline_in(dir: &std::path::Path, mpn: &str) -> Option<std::path::PathBuf> {
for style in ["heavy", "dark", "hairline", "teal"] {
let p = dir.join(format!("{mpn}-3d-outline-{style}-named.svg"));
if p.is_file() {
return Some(p);
}
}
None
}
/// Render a chip's `.kicad_sym` to `<mpn>-symbol.svg` in `dir`, compositing the
/// 3D-chip outline at body-fraction `frac`, and write the outline contract
/// (`.mm.svg` + `.json`) alongside. This is the shared write path used by both the
/// `render` CLI and adom-symbol's auto-save, so the on-disk thumbnail the rest of
/// the pipeline (chip-fetcher, adom-lbr) reads always matches the app.
pub fn render_into_library(kicad_sym: &str, render_name: &str, mpn: &str, dir: &std::path::Path, frac: f64) -> Result<()> {
let mut svg = render_symbol_svg(kicad_sym, render_name)?;
if let Some(p) = default_white_outline_in(dir, mpn) {
if let Ok(ov) = std::fs::read_to_string(&p) {
if let Ok(s) = composite_iso_into_symbol(&svg, kicad_sym, render_name, &p, frac) {
svg = s;
}
let mm_name = format!("{mpn}-symbol-outline.mm.svg");
if let Ok((mm, json, _)) = build_outline_contract(&svg, kicad_sym, &ov, &p, &mm_name, frac) {
let _ = std::fs::write(dir.join(&mm_name), mm);
let _ = std::fs::write(dir.join(format!("{mpn}-symbol-outline.json")), json);
}
}
}
std::fs::write(dir.join(format!("{mpn}-symbol.svg")), &svg)
.with_context(|| format!("writing {mpn}-symbol.svg"))?;
Ok(())
}
/// Map a chip-size choice to the body-fraction the outline fills. Large is the
/// historical default (0.62); Medium/Small shrink it for parts where the 3D chip
/// crowded the pin names. Unknown → Large.
pub fn chip_size_frac(size: &str) -> f64 {
match size {
"small" => OUTLINE_BODY_FRAC * 0.58, // ~0.36
"medium" => OUTLINE_BODY_FRAC * 0.78, // ~0.48
_ => OUTLINE_BODY_FRAC, // large = current
}
}
/// Compute the outline placement for a symbol block + a source outline SVG.
/// `outline_svg` only needs its viewBox/size; pass the same file that gets
/// composited so the contract matches the pixels exactly. `frac` is the
/// body-fraction (see [`chip_size_frac`]).
pub fn outline_placement(block: &str, outline_svg: &str, frac: f64) -> Option<OutlinePlacement> {
const PX: f64 = 100.0;
const PY: f64 = 100.0;
let (cx_k, cy_k, bw, bh, rect) = match largest_body_rect(block) {
Some((sx, sy, ex, ey)) => (
(sx + ex) / 2.0, (sy + ey) / 2.0, (ex - sx).abs(), (ey - sy).abs(),
[sx.min(ex), sy.min(ey), sx.max(ex), sy.max(ey)],
),
None => {
let (minx, maxx, miny, maxy) = symbol_local_bbox(block)?;
((minx + maxx) / 2.0, (miny + maxy) / 2.0, (maxx - minx).abs(), (maxy - miny).abs(),
[minx, miny, maxx, maxy])
}
};
let vb = parse_viewbox(outline_svg)?;
let (w, h) = (bw * frac, bh * frac);
// preserveAspectRatio=meet: the WHOLE source canvas fits, smaller ratio wins.
let scale = (w / vb[2]).min(h / vb[3]);
if !(scale.is_finite() && scale > 0.0) {
return None;
}
Some(OutlinePlacement {
source_viewbox: vb,
scale,
frac,
body_rect: rect,
center_kicad: (cx_k, cy_k),
center_svg: (PX + cx_k, PY - cy_k),
})
}
/// Parse `viewBox="x y w h"` from the first `<svg>` tag; fall back to
/// `width`/`height` (numeric), then to a 320×320 OCCT canvas.
fn parse_viewbox(svg: &str) -> Option<[f64; 4]> {
if let Some(c) = Regex::new(r#"viewBox="\s*(-?[\d.]+)[ ,]+(-?[\d.]+)[ ,]+(-?[\d.]+)[ ,]+(-?[\d.]+)"#)
.unwrap()
.captures(svg)
{
let v: Vec<f64> = (1..=4).filter_map(|i| c[i].parse().ok()).collect();
if v.len() == 4 && v[2] > 0.0 && v[3] > 0.0 {
return Some([v[0], v[1], v[2], v[3]]);
}
}
let num = |name: &str| {
Regex::new(&format!(r#"\b{name}="\s*([\d.]+)"#))
.unwrap()
.captures(svg)
.and_then(|c| c[1].parse::<f64>().ok())
};
match (num("width"), num("height")) {
(Some(w), Some(h)) if w > 0.0 && h > 0.0 => Some([0.0, 0.0, w, h]),
_ => Some([0.0, 0.0, 320.0, 320.0]),
}
}
/// One drawn stroke from the source outline, with the styling we carry through
/// to the overlay so it LOOKS like what adom-symbol composited (current
/// chip-thumbnailer styles vary wildly: heavy `#e6edf3`, teal/neon `#00e6dc`
/// with a glow filter, xray dashed `#5a6b7e`, …). We never invent a colour.
struct PolyElem {
stroke: String,
stroke_width: f64, // source px (scaled to mm by the caller)
dash: Option<String>, // raw dasharray (px; scaled to mm by the caller)
filter: Option<String>, // e.g. url(#glow28)
pts: Vec<(f64, f64)>,
}
/// Pull `name="…"` from an opening tag. `stroke="` won't match `stroke-width="`
/// (the `-` breaks the literal), so plain names stay unambiguous.
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
let pat = format!("{name}=\"");
let i = tag.find(&pat)? + pat.len();
let rest = &tag[i..];
let j = rest.find('"')?;
Some(&rest[..j])
}
/// Parse `<polyline>`/`<polygon>` elements from an SVG, keeping each one's stroke
/// styling AND points (polygons closed by repeating the first point). The point
/// surface is what adom-lbr consumes; the styling is what makes the overlay match.
fn parse_poly_elems(svg: &str) -> Vec<PolyElem> {
let re = Regex::new(r#"<(polyline|polygon)\b([^>]*)>"#).unwrap();
let mut out = Vec::new();
for c in re.captures_iter(svg) {
let closed = &c[1] == "polygon";
let tag = &c[2];
let Some(points) = attr(tag, "points") else { continue };
let mut pts: Vec<(f64, f64)> = points
.split_whitespace()
.filter_map(|p| {
let mut it = p.split(',');
let x: f64 = it.next()?.trim().parse().ok()?;
let y: f64 = it.next()?.trim().parse().ok()?;
Some((x, y))
})
.collect();
if closed {
if let Some(&first) = pts.first() {
pts.push(first);
}
}
if pts.len() < 2 {
continue;
}
out.push(PolyElem {
stroke: attr(tag, "stroke").unwrap_or("#e6edf3").to_string(),
stroke_width: attr(tag, "stroke-width").and_then(|s| s.parse().ok()).unwrap_or(1.0),
dash: attr(tag, "stroke-dasharray").map(|s| s.to_string()),
filter: attr(tag, "filter").map(|s| s.to_string()),
pts,
});
}
out
}
/// Copy the source `<defs>…</defs>` (so e.g. the neon glow filter still resolves),
/// scaling any `feGaussianBlur` `stdDeviation` by the px→mm factor so the blur
/// stays proportional in the rescaled overlay. Returns "" when there are no defs.
fn scaled_defs(svg: &str, scale: f64) -> String {
let Some(caps) = Regex::new(r"(?s)<defs>.*?</defs>").unwrap().find(svg) else {
return String::new();
};
let defs = caps.as_str();
Regex::new(r#"stdDeviation="([\d.]+)""#)
.unwrap()
.replace_all(defs, |c: ®ex::Captures| {
let v: f64 = c[1].parse().unwrap_or(0.0);
format!("stdDeviation=\"{:.4}\"", v * scale)
})
.into_owned()
}
/// Decimate already-mapped (mm) polylines: drop points within `tol` of the last
/// kept one (endpoints always kept). Output is 1:1 with the input (a collapsed
/// poly becomes a short/empty entry) so callers can keep it aligned with the
/// per-element styling. Returns the decimated polylines + total segment count.
fn decimate_each(polys: &[Vec<(f64, f64)>], tol: f64) -> (Vec<Vec<(f64, f64)>>, usize) {
let mut out = Vec::with_capacity(polys.len());
let mut segs = 0usize;
for poly in polys {
let n = poly.len();
let mut kept: Vec<(f64, f64)> = Vec::new();
for (i, &p) in poly.iter().enumerate() {
if i == 0 || i == n - 1 {
kept.push(p);
} else if let Some(&last) = kept.last() {
if ((p.0 - last.0).powi(2) + (p.1 - last.1).powi(2)).sqrt() >= tol {
kept.push(p);
}
}
}
if kept.len() >= 2 {
segs += kept.len() - 1;
}
out.push(kept);
}
(out, segs)
}
/// Build the outline contract for adom-lbr (and any other consumer):
/// • a LITE drop-in overlay `.mm.svg` in the `<mpn>-symbol.svg` frame, and
/// • a self-describing sidecar `.json` with the exact placement transform.
/// `symbol_svg` is the rendered symbol SVG (for its width/height/viewBox header);
/// `block` is the `.kicad_sym` symbol block; `overlay_svg`/`overlay_path` are the
/// outline that was composited. Returns `(mm_svg, json, segments)`.
pub fn build_outline_contract(
symbol_svg: &str,
block: &str,
overlay_svg: &str,
overlay_path: &std::path::Path,
mm_svg_name: &str,
frac: f64,
) -> Result<(String, String, usize)> {
let place = outline_placement(block, overlay_svg, frac)
.ok_or_else(|| anyhow!("no body geometry / outline viewBox to place outline"))?;
let elems = parse_poly_elems(overlay_svg);
if elems.is_empty() {
return Err(anyhow!("outline has no <polyline>/<polygon> strokes to emit"));
}
// Map every point into the symbol.svg frame, keeping each stroke's own style.
// Then decimate to a LITE outline (few hundred segs) so embedded libraries
// stay small; raise the tolerance adaptively until we're under the cap.
let mapped: Vec<Vec<(f64, f64)>> = elems
.iter()
.map(|e| e.pts.iter().map(|&(x, y)| place.to_svg(x, y)).collect())
.collect();
const SEG_CAP: usize = 480;
let mut tol = 0.12_f64; // mm
let (mut kept, mut segs) = decimate_each(&mapped, tol);
while segs > SEG_CAP && tol < 4.0 {
tol *= 1.4;
let (k, s) = decimate_each(&mapped, tol);
kept = k;
segs = s;
}
// ── .mm.svg: same width/height/viewBox header as <mpn>-symbol.svg ──
let hdr = Regex::new(r#"width="([^"]+)"\s+height="([^"]+)"\s+viewBox="([^"]+)""#)
.unwrap()
.captures(symbol_svg);
let (w_attr, h_attr, vb_attr) = match hdr {
Some(c) => (c[1].to_string(), c[2].to_string(), c[3].to_string()),
None => {
// Fall back to a viewBox spanning the symbol body in svg frame.
let r = place.body_rect;
let (x0, x1) = (100.0 + r[0], 100.0 + r[2]);
let (y0, y1) = (100.0 - r[3], 100.0 - r[1]);
(
format!("{:.3}mm", x1 - x0),
format!("{:.3}mm", y1 - y0),
format!("{x0:.4} {y0:.4} {:.4} {:.4}", x1 - x0, y1 - y0),
)
}
};
let mut mm = String::with_capacity(segs * 24 + 512);
mm.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w_attr}\" height=\"{h_attr}\" viewBox=\"{vb_attr}\">\n"
));
mm.push_str(
" <!-- adom-symbol outline contract: lite 3D-chip outline in the SAME mm \
frame/viewBox as <mpn>-symbol.svg (Y-down), carrying the SOURCE outline's \
own stroke styling. See <mpn>-symbol-outline.json for the exact transform \
into EAGLE/KiCad (Y-up). -->\n",
);
// Carry over the source <defs> (e.g. the neon glow filter), blur scaled to mm.
let defs = scaled_defs(overlay_svg, place.scale);
if !defs.is_empty() {
mm.push_str(" ");
mm.push_str(&defs);
mm.push('\n');
}
mm.push_str(" <g fill=\"none\" stroke-linejoin=\"round\" stroke-linecap=\"round\">\n");
// Re-emit each polyline with its OWN stroke colour/width/dash/filter, widths
// and dashes scaled px→mm so the overlay reads identically to what we drew.
for (poly, e) in kept.iter().zip(elems.iter()) {
if poly.len() < 2 {
continue;
}
let w = (e.stroke_width * place.scale).max(0.02);
mm.push_str(&format!(" <polyline stroke=\"{}\" stroke-width=\"{:.4}\"", e.stroke, w));
if let Some(d) = &e.dash {
let scaled: Vec<String> = d
.split(|c| c == ',' || c == ' ')
.filter(|s| !s.is_empty())
.map(|s| s.parse::<f64>().map(|v| format!("{:.4}", v * place.scale)).unwrap_or_else(|_| s.to_string()))
.collect();
mm.push_str(&format!(" stroke-dasharray=\"{}\"", scaled.join(",")));
}
if let Some(f) = &e.filter {
mm.push_str(&format!(" filter=\"{f}\""));
}
mm.push_str(" points=\"");
for (i, &(x, y)) in poly.iter().enumerate() {
if i > 0 {
mm.push(' ');
}
mm.push_str(&format!("{x:.4},{y:.4}"));
}
mm.push_str("\"/>\n");
}
mm.push_str(" </g>\n</svg>\n");
// ── sidecar JSON: self-describing, both coordinate frames ──
let variant = variant_name(overlay_path);
let json = serde_json::json!({
"schema": "adom-symbol/outline-contract@1",
"source": overlay_path.file_name().and_then(|s| s.to_str()).unwrap_or(""),
"source_viewbox": place.source_viewbox,
"default_variant": variant,
"body_fraction": round4(place.frac),
"segments": segs,
"preserve_aspect_ratio": "xMidYMid meet (the SQUARE source canvas fits the body box — not the ink bbox)",
// The frame adom-lbr wants: KiCad/EAGLE symbol-local mm, Y-UP, origin at
// the symbol origin (same numbers as the .kicad_sym body rectangle).
// For a source point (px,py): eagle_x = center_x + (px - srcc_x)*scale,
// eagle_y = center_y - (py - srcc_y)*scale.
"placement_eagle_mm": {
"coordinate_space": "kicad-symbol-local-mm (Y-up, origin at symbol origin; EAGLE shares this frame)",
"center_x": round4(place.center_kicad.0),
"center_y": round4(place.center_kicad.1),
"scale": round6(place.scale),
"y_axis": "flip (source px is Y-down; negate the (py - srcc_y) term)",
"source_center": [round4(place.source_viewbox[0] + place.source_viewbox[2] / 2.0),
round4(place.source_viewbox[1] + place.source_viewbox[3] / 2.0)],
"body_rect": { "left": round4(place.body_rect[0]), "bottom": round4(place.body_rect[1]),
"right": round4(place.body_rect[2]), "top": round4(place.body_rect[3]) }
},
// The frame <mpn>-symbol-outline.mm.svg (and <mpn>-symbol.svg) live in:
// mm, Y-down, +100 offset origin. A drop-in overlay; consumers that
// already have the .mm.svg can just read its polylines directly.
"placement_svg_mm": {
"coordinate_space": "symbol-svg-mm (Y-down, +100 offset origin; matches <mpn>-symbol.svg viewBox)",
"center_x": round4(place.center_svg.0),
"center_y": round4(place.center_svg.1),
"scale": round6(place.scale),
"viewbox": vb_attr
},
"mm_svg": mm_svg_name
});
Ok((mm, serde_json::to_string_pretty(&json).unwrap_or_default(), segs))
}
fn round4(v: f64) -> f64 { (v * 1e4).round() / 1e4 }
fn round6(v: f64) -> f64 { (v * 1e6).round() / 1e6 }
/// Derive the outline variant ("bold"/"heavy"/"blueprint"/…) from the composited
/// file name `<mpn>-3d-outline-<style>[-named].svg`.
fn variant_name(path: &std::path::Path) -> String {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
match stem.rfind("-3d-outline-") {
Some(i) => stem[i + "-3d-outline-".len()..]
.trim_end_matches("-named")
.to_string(),
None => "bold".to_string(),
}
}
/// The largest `(rectangle (start x y) (end x y))` in the symbol block — the
/// chip body outline. Returns (start_x, start_y, end_x, end_y) of the max-area
/// rectangle, or None if the symbol has no rectangle (e.g. a polyline body).
fn largest_body_rect(block: &str) -> Option<(f64, f64, f64, f64)> {
let re = Regex::new(r"(?s)\(rectangle\s*\(start (-?[\d.]+) (-?[\d.]+)\)\s*\(end (-?[\d.]+) (-?[\d.]+)\)").unwrap();
let mut best: Option<(f64, f64, f64, f64)> = None;
let mut best_area = 0.0;
for c in re.captures_iter(block) {
if let (Ok(sx), Ok(sy), Ok(ex), Ok(ey)) =
(c[1].parse::<f64>(), c[2].parse::<f64>(), c[3].parse::<f64>(), c[4].parse::<f64>())
{
let area = (ex - sx).abs() * (ey - sy).abs();
if area > best_area {
best_area = area;
best = Some((sx, sy, ex, ey));
}
}
}
best
}
/// Minimal base64 (no extra crate dep here).
fn b64(data: &[u8]) -> String {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
for chunk in data.chunks(3) {
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
out.push(T[((n >> 18) & 63) as usize] as char);
out.push(T[((n >> 12) & 63) as usize] as char);
out.push(if chunk.len() > 1 { T[((n >> 6) & 63) as usize] as char } else { '=' });
out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
}
out
}
fn crop_to_symbol(svg: &str, block: &str) -> String {
let (minx, maxx, miny, maxy) = match symbol_local_bbox(block) {
Some(b) => b,
None => return svg.to_string(),
};
const M: f64 = 4.0;
const PX: f64 = 100.0;
const PY: f64 = 100.0;
let (sx0, sx1) = (PX + minx - M, PX + maxx + M);
let (sy0, sy1) = (PY - maxy - M, PY - miny + M); // KiCad placement Y-flip
let vbx = sx0.min(sx1);
let vby = sy0.min(sy1);
let vbw = (sx1 - sx0).abs();
let vbh = (sy1 - sy0).abs();
let re = Regex::new(r#"width="[^"]*mm" height="[^"]*mm" viewBox="[^"]*""#).unwrap();
let replacement = format!(
"width=\"{vbw:.3}mm\" height=\"{vbh:.3}mm\" viewBox=\"{vbx:.4} {vby:.4} {vbw:.4} {vbh:.4}\""
);
re.replace(svg, replacement.as_str()).into_owned()
}
fn symbol_local_bbox(block: &str) -> Option<(f64, f64, f64, f64)> {
let mut xs: Vec<f64> = Vec::new();
let mut ys: Vec<f64> = Vec::new();
let xy = Regex::new(r"\(xy (-?[\d.]+) (-?[\d.]+)\)").unwrap();
for c in xy.captures_iter(block) {
if let (Ok(x), Ok(y)) = (c[1].parse(), c[2].parse()) {
xs.push(x);
ys.push(y);
}
}
let se = Regex::new(r"\((?:start|end) (-?[\d.]+) (-?[\d.]+)\)").unwrap();
for c in se.captures_iter(block) {
if let (Ok(x), Ok(y)) = (c[1].parse(), c[2].parse()) {
xs.push(x);
ys.push(y);
}
}
let pin = Regex::new(r"(?s)\(pin\b.*?\(at (-?[\d.]+) (-?[\d.]+) (-?[\d.]+)\).*?\(length (-?[\d.]+)\)").unwrap();
for c in pin.captures_iter(block) {
if let (Ok(x), Ok(y), Ok(a), Ok(l)) = (c[1].parse::<f64>(), c[2].parse::<f64>(), c[3].parse::<f64>(), c[4].parse::<f64>()) {
let r = a.to_radians();
for (px, py) in [(x, y), (x + l * r.cos(), y + l * r.sin()), (x - l * r.cos(), y - l * r.sin())] {
xs.push(px);
ys.push(py);
}
}
}
// Include the VISIBLE Reference + Value designators so they aren't cropped
// out wherever the user places them (ref_pos: top / center / corners).
let prop = Regex::new(r#"(?s)\(property "(?:Reference|Value)" "[^"]*"\s*\(at (-?[\d.]+) (-?[\d.]+)"#).unwrap();
for c in prop.captures_iter(block) {
if let (Ok(x), Ok(y)) = (c[1].parse::<f64>(), c[2].parse::<f64>()) {
// pad a little for the glyph extent
for (px, py) in [(x - 3.0, y), (x + 3.0, y), (x, y - 1.5), (x, y + 1.5)] {
xs.push(px);
ys.push(py);
}
}
}
if xs.is_empty() {
return None;
}
let minx = xs.iter().cloned().fold(f64::INFINITY, f64::min);
let maxx = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let miny = ys.iter().cloned().fold(f64::INFINITY, f64::min);
let maxy = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
Some((minx, maxx, miny, maxy))
}