app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
← Commit history
Add 3D-chip outline contract (.mm.svg + .json) for adom-lbr; README + 5 screenshots; v1.0.8
13 files changed
+581−19
Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-symbol"-version = "0.1.6"+version = "0.1.7" dependencies = [ "anyhow", "clap",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-symbol"-version = "0.1.6"+version = "0.1.7" edition = "2021" description = "Create and preview KiCad/Fusion 360 schematic symbols with interactive viewer"
README.md+76−1@@ -14,7 +14,13 @@ viewer. No Node, no gallia, no local KiCad install. baseline symbol (Category B). - **Render** the symbol to a cropped, dark-themed SVG via service-kicad (`POST /kicad/sch/export/svg`) — the same render path the chip-fetcher- dashboard uses, so the thumbnail and the app match exactly.+ dashboard uses, so the thumbnail and the app match exactly. The default render+ composites the part's 3D-chip **vector outline** (with the part number lasered+ on) into the body centre.+- **Emit an outline contract** — alongside `<mpn>-symbol.svg`, `render` writes+ `<mpn>-symbol-outline.mm.svg` + `<mpn>-symbol-outline.json` so **adom-lbr** can+ draw the exact same chip outline onto the EAGLE/Fusion 360 symbol with **zero+ guessing**. See [3D-chip outline contract](#3d-chip-outline-contract-for-adom-lbr). - **Serve** a live, AI-drivable app: the frontend stays connected to the Rust server (`GET /state` poller + `POST /eval` channel), so the AI can push a new symbol, hot-patch the UI, or read the console in real time.@@ -34,6 +40,9 @@ adom-symbol create --file rp2040.json --out RP2040 # Preview an existing .kicad_sym as a themed viewer adom-symbol preview --file RP2040/RP2040.kicad_sym +# Render a .kicad_sym to a themed thumbnail SVG (+ outline contract for adom-lbr)+adom-symbol render --file RP2040/RP2040.kicad_sym --out RP2040-symbol.svg+ # Run the live app (AI-drivable: /state, /load, /eval, /console) adom-symbol serve --port 8781 --file RP2040/RP2040.kicad_sym ```@@ -48,8 +57,74 @@ renders outside the container. |---|---| | `create --file <json> [--out <dir>]` | Generate `.kicad_sym` + SVG + viewer HTML from pin JSON | | `preview --file <kicad_sym> [--out <dir>]` | Render an existing symbol to a themed viewer |+| `render --file <kicad_sym> --out <svg> [--iso <png>]` | Render to a themed thumbnail SVG; auto-composites the 3D-chip outline + emits the outline contract |+| `embed --file <kicad_sym> [--out <html>]` | Export a self-contained interactive HTML viewer (pan/zoom + pin info) | | `serve [--port 8781] [--file <kicad_sym>]` | Run the live AI-drivable app server | +## 3D-chip outline contract (for adom-lbr)++The default `render` composites the part's **3D-chip vector outline** (white+`heavy` style, with the part number lasered on the top face) into the symbol+body — sized to **0.62×** the body's smaller side, fitted from the source+`320×320` outline canvas and centred on the body rectangle.++Because that exact placement was previously re-guessed downstream (adom-lbr fit+the outline to ~0.38× of a different reference frame, so it drifted ~30% small),+`render` now **exposes the placement** so it can be transcribed 1:1. Next to+`<mpn>-symbol.svg` it writes:++| File | What it is |+|---|---|+| `<mpn>-symbol-outline.mm.svg` | The outline strokes **only**, as decimated (lite) `<polyline>`s, in the **same units / origin / viewBox** as `<mpn>-symbol.svg` (mm, Y-down). A drop-in overlay; each stroke keeps the source style's real colour/width/dash (and the neon glow `<defs>`, blur scaled to mm). |+| `<mpn>-symbol-outline.json` | A self-describing sidecar: the canonical `default_variant`, the source viewBox, segment count, and the exact transform in **both** frames — EAGLE/KiCad **Y-up** (what adom-lbr maps to) and the symbol-svg **Y-down** overlay frame. |++`adom-lbr generate --symbol-art <mpn>-symbol-outline.mm.svg` detects the contract+(via the sibling `.json`), derives the svg→EAGLE offset/flip from the two frame+centres, and emits the outline as layer-94 `<wire>`s — no re-fitting, no+re-decimation, so the art lands exactly where adom-symbol drew it.++> The **canonical embedded variant is `heavy`** (white `#e6edf3`, single visible+> layer) — clean as schematic wires. The multi-layer styles (`blueprint`/`xray`+> add hidden dashed edges, `neon` is a 2-layer glow) are deliberately avoided for+> embedding.++### Outline placement (the math `render` exposes)++For a source outline point `(px, py)` in the `320×320` canvas:++```+scale = min(body_w, body_h) · 0.62 / 320 # mm per source unit+eagle_x = center_x + (px − 160) · scale # KiCad/EAGLE Y-up+eagle_y = center_y − (py − 160) · scale # (note the Y flip)+```++`center_x/center_y` (and the Y-down `symbol.svg`-frame equivalents) are written+verbatim into the sidecar, so a consumer never has to reconstruct them.++## Screenshots++### Default symbol render — 3D-chip outline + lasered part number+++### The outline contract overlays the symbol 1:1+The emitted `.mm.svg` (shown here recoloured magenta) painted over the live+`-symbol.svg` — it traces the chip body, pin-1 chamfer, every lead and the+lasered name exactly. Zero drift.+++### Real EAGLE `.lbr` output — contract vs the old guess+adom-lbr drawing the outline as layer-94 wires: **left** transcribed 1:1 from the+contract (exact 0.62 fit); **right** the legacy 0.38 centre+scale guess (~30%+too small). Both round-tripped back through `adom-sfconvert`.+++### The lite `.mm.svg` outline+Decimated to a few hundred segments for small libraries, in symbol mm-space.+++### The self-describing sidecar++ ## Architecture Pure Rust. Symbol generation (`src/sym_gen.rs`, `src/discrete_gen.rs`) is a
VERSION+1−1@@ -1 +1 @@-0.1.6+1.0.8
package.json+2−3@@ -1,6 +1,6 @@ { "slug": "adom-symbol",- "version": "1.0.7",+ "version": "1.0.8", "type": "app", "description": "KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360", "dependencies": {@@ -27,6 +27,5 @@ "eda", "footprint", "cli"- ],- "license": "MIT"+ ] }
screenshots/outline-alignment.pngadded⋯ 1 unchanged line ⋯
screenshots/outline-contract-json.pngadded⋯ 1 unchanged line ⋯
screenshots/outline-lbr-contract-vs-legacy.pngadded⋯ 1 unchanged line ⋯
screenshots/outline-mm-lite.pngadded⋯ 1 unchanged line ⋯
screenshots/outline-symbol.pngadded⋯ 1 unchanged line ⋯
src/deliver.rs+86−6@@ -9,7 +9,16 @@ use std::process::Command; /// Run an adom-desktop verb with a JSON payload; return its parsed JSON (or a /// wrapped raw result). Errors if adom-desktop itself can't be spawned. fn ad(verb: &str, payload: Value) -> Result<Value, String> {- let out = Command::new("adom-desktop")+ let mut cmd = Command::new("adom-desktop");+ // When the relay has >1 client (e.g. laptop + a cloud VM), a bare verb is+ // ambiguous. Honor ADOM_DESKTOP_TARGET to pin the host (same convention the+ // rest of the Adom toolchain uses); single-client setups need nothing.+ if let Ok(t) = std::env::var("ADOM_DESKTOP_TARGET") {+ if !t.is_empty() {+ cmd.args(["--target", &t]);+ }+ }+ let out = cmd .args([verb, &payload.to_string()]) .output() .map_err(|e| format!("adom-desktop unreachable: {e}"))?;@@ -40,12 +49,21 @@ pub fn deliver(symbol_name: &str, kicad_sym: &str, target: &str) -> Value { return json!({ "ok": false, "error": "could not write temp symbol" }); } - // KiCad takes the native symbol directly (symbol-only). The EAGLE-family- // tools (Fusion / Altium / OrCAD) import a true `.lbr` built by adom-lbr.+ // KiCad takes the native symbol directly (symbol-only). We call the bridge's+ // `kicad_install_symbol` verb, which merges the symbol into the Adom library+ // AND opens it on the Symbol Editor canvas — then we VERIFY the bridge's+ // success rather than assuming it (the old send_files + kicad_install_library+ // path reported ok with nothing on the canvas). EAGLE-family tools (Fusion /+ // Altium / OrCAD) import a true `.lbr` built by adom-lbr. if target == "kicad" {- let send = ad("send_files", json!({ "filePaths": [sym_path.to_string_lossy()], "targetApp": "kicad", "destinationFolder": "symbols" }));- let inst = ad("kicad_install_library", json!({ "libraryPath": sym_path.to_string_lossy(), "libraryName": symbol_name, "libraryType": "symbol" }));- return summarize("KiCad", "symbol", &[send, inst]);+ let resp = ad("kicad_install_symbol", json!({+ "fileName": format!("{safe}.kicad_sym"),+ "fileContent": b64(kicad_sym.as_bytes()),+ "symbolName": symbol_name,+ "openEditor": true,+ "timeoutSeconds": 60,+ }));+ return summarize_kicad(symbol_name, &sym_path.to_string_lossy(), resp); } let (tool, app, folder) = match target {@@ -107,3 +125,65 @@ fn summarize(tool: &str, what: &str, results: &[Result<Value, String>]) -> Value } json!({ "ok": true, "target": tool, "message": format!("Delivered {what} to {tool}.") }) }++/// Standard base64 (no external crate) — for sending file content through the bridge.+fn b64(data: &[u8]) -> String {+ const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+ let mut out = String::with_capacity((data.len() + 2) / 3 * 4);+ for chunk in data.chunks(3) {+ let b0 = chunk[0];+ let b1 = *chunk.get(1).unwrap_or(&0);+ let b2 = *chunk.get(2).unwrap_or(&0);+ out.push(T[(b0 >> 2) as usize] as char);+ out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);+ out.push(if chunk.len() > 1 { T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char } else { '=' });+ out.push(if chunk.len() > 2 { T[(b2 & 0x3f) as usize] as char } else { '=' });+ }+ out+}++/// A descriptive paste-into-Claude prompt — always offered as a fallback so the+/// user can finish the delivery in their own AI thread if the bridge is offline+/// or anything is off. Self-contained: names the symbol, the file, and the goal.+fn claude_prompt(symbol_name: &str, sym_path: &str) -> String {+ format!(+ "I have a KiCad symbol named \"{symbol_name}\" saved at `{sym_path}`. Please install it into my KiCad \+ \"Adom\" symbol library and open it in the KiCad Symbol Editor so I can see it on the canvas — use the \+ adom-desktop KiCad bridge (`kicad_install_symbol` with the file's base64 content and openEditor:true), \+ then confirm the symbol is actually open on the canvas."+ )+}++/// Summarize a `kicad_install_symbol` bridge call. Verifies REAL success (the+/// bridge's `success` flag, or the confirmation text in its wrapped output) —+/// not just "doesn't look offline". Always includes a paste-into-Claude prompt.+fn summarize_kicad(symbol_name: &str, sym_path: &str, resp: Result<Value, String>) -> Value {+ let prompt = claude_prompt(symbol_name, sym_path);+ match resp {+ Err(e) => json!({ "ok": false, "target": "KiCad", "offline": e.contains("unreachable"),+ "error": e, "claudePrompt": prompt, "symbolPath": sym_path,+ "message": "Couldn't reach Adom Desktop. Open it on your laptop and retry — or paste the prompt into Claude." }),+ Ok(v) => {+ if looks_offline(&v) {+ return json!({ "ok": false, "target": "KiCad", "offline": true,+ "claudePrompt": prompt, "symbolPath": sym_path,+ "message": "Adom Desktop isn't connected. Open it on your laptop and retry — or paste the prompt into Claude." });+ }+ // AD may return the bridge result at the top level OR wrapped in `output`+ // as a JSON string — check both. Success = the bridge's success flag, or+ // its confirmation text ("loaded in the Symbol Editor" / "added to … library").+ let s = v.to_string().to_lowercase();+ let ok = v.get("success").and_then(|b| b.as_bool()) == Some(true)+ || s.contains("loaded in the symbol editor")+ || (s.contains("added to") && s.contains("library"));+ if ok {+ json!({ "ok": true, "target": "KiCad", "claudePrompt": prompt,+ "message": format!("Installed \"{symbol_name}\" in the Adom library and opened it in the KiCad Symbol Editor.") })+ } else {+ json!({ "ok": false, "target": "KiCad", "claudePrompt": prompt,+ "symbolPath": sym_path, "detail": v,+ "message": "KiCad delivery didn't confirm. Paste the prompt into Claude to finish it, or open the file in KiCad manually." })+ }+ }+ }+}
src/main.rs+60−6@@ -200,15 +200,49 @@ fn run() -> Result<()> { let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?; let name = first_symbol_name(&content).unwrap_or_else(|| "symbol".to_string()); let mut svg = render::render_symbol_svg(&content, &name)?;- if let Some(iso_path) = iso.as_ref() {- match render::composite_iso_into_symbol(&svg, &content, &name, iso_path) {- Ok(s) => svg = s,- Err(e) => eprintln!("WARN: iso composite skipped: {e}"),+ // The DEFAULT symbol layout shows the 3D chip as a WHITE vector OUTLINE+ // with the chip name lasered on, composited into the body — matching the+ // live adom-symbol app's default (three_d=bold + laserName). When no+ // explicit overlay is passed, auto-discover that asset next to the+ // symbol file. We pick a NATIVELY-white outline style (#e6edf3) so it+ // reads on the dark symbol canvas in any SVG consumer — the app gets+ // white by CSS-inverting the black "bold" SVG, but a saved standalone+ // SVG can't rely on that filter, so we use the white style directly.+ let overlay = iso.clone().or_else(|| default_white_outline(&file));+ let mut composited = false;+ let mut outline_note = String::new();+ if let Some(p) = overlay.as_ref() {+ match render::composite_iso_into_symbol(&svg, &content, &name, p) {+ Ok(s) => { svg = s; composited = true; }+ Err(e) => eprintln!("WARN: 3D-chip composite skipped: {e}"),+ }+ // Emit the OUTLINE CONTRACT next to <mpn>-symbol.svg so adom-lbr+ // (and other consumers) can transcribe the exact placement we drew+ // — a lite drop-in overlay .mm.svg + a self-describing .json. Only+ // for vector outlines (a raster --iso has no strokes to extract).+ if p.extension().and_then(|e| e.to_str()) == Some("svg") {+ let stem = out.file_stem().and_then(|s| s.to_str()).unwrap_or(&name).to_string();+ let dir = out.parent().map(|d| d.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));+ let mm_name = format!("{stem}-outline.mm.svg");+ let json_name = format!("{stem}-outline.json");+ match fs::read_to_string(p).map_err(anyhow::Error::from).and_then(|ov| {+ render::build_outline_contract(&svg, &content, &ov, p, &mm_name)+ }) {+ Ok((mm_svg, json, segs)) => {+ fs::write(dir.join(&mm_name), &mm_svg)+ .with_context(|| format!("writing {mm_name}"))?;+ fs::write(dir.join(&json_name), &json)+ .with_context(|| format!("writing {json_name}"))?;+ outline_note = format!(" (+outline contract: {mm_name} [{segs} segs] + {json_name})");+ }+ Err(e) => eprintln!("WARN: outline contract skipped: {e}"),+ } } } fs::write(&out, &svg).with_context(|| format!("writing {}", out.display()))?;- println!("OK: rendered {} → {}{}", file.display(), out.display(),- if iso.is_some() { " (+iso 3D chip)" } else { "" });+ println!("OK: rendered {} → {}{}{}", file.display(), out.display(),+ if composited { " (+white 3D-chip outline + lasered name)" } else { "" },+ outline_note); Ok(()) } Cmd::RenderDs2sf { file, out, layout, sym } => cmd_render_ds2sf(file, out, layout, sym),@@ -441,6 +475,26 @@ pub(crate) fn first_symbol_name(content: &str) -> Option<String> { Some(content[i..j].to_string()) } +/// The default 3D-chip overlay for a symbol render: the lasered-name vector+/// outline chip-thumbnailer drops next to `<mpn>.kicad_sym`+/// (`<mpn>-3d-outline-<style>-named.svg`). Canonical = **heavy** (white `#e6edf3`,+/// thick) — confirmed by John 2026-06-28. We then fall back through the other+/// SINGLE-VISIBLE-LAYER styles (white thickness ladder, then the teal accent),+/// and deliberately AVOID the multi-layer styles (`blueprint`/`xray` add hidden+/// dashed edges; `neon` is a 2-layer glow) — they read as clutter when embedded+/// as schematic `<wire>`s. Returns None if no outline asset exists yet (plain).+fn default_white_outline(file: &std::path::Path) -> Option<PathBuf> {+ let mpn = file.file_stem()?.to_str()?;+ let dir = file.parent().unwrap_or_else(|| std::path::Path::new("."));+ 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+}+ fn build_viewer_html(name: &str, svg: &str, mfr: &str, pkg: &str, desc: &str) -> String { let meta: String = [mfr, pkg].iter().filter(|s| !s.is_empty()).cloned().collect::<Vec<_>>().join(" · "); let desc_html = if desc.is_empty() { String::new() } else { format!("<div class=\"desc\">{}</div>", esc(desc)) };
src/render.rs+354@@ -188,6 +188,360 @@ pub fn composite_iso_into_symbol(svg: &str, block: &str, _name: &str, iso_path: } } +/// 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,+ /// 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)+ }+}++/// 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.+pub fn outline_placement(block: &str, outline_svg: &str) -> 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 * OUTLINE_BODY_FRAC, bh * OUTLINE_BODY_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,+ 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,+) -> Result<(String, String, usize)> {+ let place = outline_placement(block, overlay_svg)+ .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": OUTLINE_BODY_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).