← Commit history
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+![adom-symbol render of a 64-pin MCU with the heavy 3D outline composited into the body](screenshots/outline-symbol.png)++### 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.+![Magenta outline contract overlaying the white composited outline, perfectly aligned](screenshots/outline-alignment.png)++### 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`.+![Side-by-side EAGLE symbols showing the contract fit vs the smaller legacy guess](screenshots/outline-lbr-contract-vs-legacy.png)++### The lite `.mm.svg` outline+Decimated to a few hundred segments for small libraries, in symbol mm-space.+![The standalone lite outline polylines on a dark canvas](screenshots/outline-mm-lite.png)++### The self-describing sidecar+![The outline-contract JSON with placement in both coordinate frames](screenshots/outline-contract-json.png)+ ## 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"+  ] }\ No newline at end of file
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: &regex::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).