← Commit history

v0.3.4: click-outside-modal-to-close (like Cancel); render-warning bar now checks for actual page assets first (no bar for boards that still render from KiCad source); vendor a trimmed self-contained altium-codec (workspace refactor moved/gutted the shared crate) so the app builds independently

barrett-land ·1c3f54dee3 ·3mo ago ·parent e6264a6
9 files changed +1448−12
Cargo.toml+1−1
@@ -15,7 +15,7 @@ tiny_http = "0.12" anyhow = "1" # The Manager is built on adom-lbr: it reuses the codec (KiCad import/export, # the canonical adom-lbr part model) to render + convert on the fly.-altium-codec = { path = "../adom-lbr/crates/altium-codec" }+altium-codec = { path = "vendor/altium-codec" }  # Standalone workspace root (do not get absorbed into the adom-lbr workspace). [workspace]
src/main.rs+13−7
@@ -1558,20 +1558,26 @@ fn board_media(slug: &str) -> Value {         let sheets: Vec<String> = m.get("sheets").and_then(|s| s.as_array())             .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())             .unwrap_or_default();-        return json!({"rendering": false, "is_kicad": true, "stored": true, "has_render": true, "sheets": sheets,+        return json!({"rendering": false, "is_kicad": true, "stored": true, "has_render": true, "has_assets": true, "sheets": sheets,             "has_3d": m.get("glb").and_then(|b| b.as_bool()).unwrap_or(false),             "has_pcb": m.get("pcb").and_then(|b| b.as_bool()).unwrap_or(false)});     }-    // no stored render/ folder from here on — the board falls back to slow live-    // rendering (has_render:false), which the UI flags as an error state.+    // no stored render/ folder from here on — the board live-renders instead.     if let Some((sheets, pcb, glb)) = cached_board(slug) {         let names: Vec<String> = sheets.into_iter().map(|(n, _)| n).collect();-        return json!({"rendering": false, "is_kicad": true, "has_render": false, "sheets": names, "has_3d": glb.is_some(), "has_pcb": pcb.is_some()});+        return json!({"rendering": false, "is_kicad": true, "has_render": false, "has_assets": true, "sheets": names, "has_3d": glb.is_some(), "has_pcb": pcb.is_some()});     }     // only KiCad boards (with a .kicad_pcb / .kicad_sch) can be rendered-    let is_kicad = list_page_files(slug).iter().any(|n| n.ends_with(".kicad_pcb") || n.ends_with(".kicad_sch"));+    let files = list_page_files(slug);+    let has = |ext: &str| files.iter().any(|n| n.ends_with(ext));+    let is_kicad = has(".kicad_pcb") || has(".kicad_sch");+    // the assets the viewer needs to display the board even WITHOUT a stored render/+    // folder: the KiCad source to render the PCB + hierarchical schematic (3D comes+    // from a page .glb / model_3d_path, else is live-rendered from the .kicad_pcb).+    // Only when these are absent is the board truly un-renderable → show the red bar.+    let has_assets = has(".kicad_pcb") && has(".kicad_sch");     if !is_kicad {-        return json!({"rendering": false, "is_kicad": false, "has_render": false, "sheets": [], "has_3d": false, "has_pcb": false});+        return json!({"rendering": false, "is_kicad": false, "has_render": false, "has_assets": false, "sheets": [], "has_3d": false, "has_pcb": false});     }     let start = {         let mut g = BOARD_RENDERING.lock().unwrap();@@ -1588,7 +1594,7 @@ fn board_media(slug: &str) -> Value {             }         });     }-    json!({"rendering": true, "is_kicad": true, "has_render": false, "sheets": [], "has_3d": false, "has_pcb": false})+    json!({"rendering": true, "is_kicad": true, "has_render": false, "has_assets": has_assets, "sheets": [], "has_3d": false, "has_pcb": false}) }  /// Does a file exist on the user's machine? None = couldn't determine
src/manage.html+7−4
@@ -458,20 +458,21 @@ function fillBoardInfo(slug,log){     const a=d.activity||[]; if(a.length&&lg)lg.innerHTML=a.map(commitHtml).join('');   }).catch(()=>{}); }-// Red error bar at the top when a board/molecule has no stored render/ folder — it-// can't load properly (falls back to slow live rendering) until it's re-published.+// Red error bar at the top only when the page is actually missing the assets it+// needs to display (checked first via has_assets) — a board that still has its+// KiCad source / 3D model renders fine (live) and gets NO bar even without render/. function showRenderWarning(slug,m){   if(boardSlug!==slug)return;   const ov=document.getElementById('board-overview'); if(!ov)return;   let bar=ov.querySelector('.render-warn');-  const missing=m&&m.is_kicad!==false&&m.has_render===false;+  const missing=m&&m.is_kicad!==false&&m.has_assets===false;   if(!missing){ if(bar)bar.remove(); return; }   const kind=boardKind(bySlug[slug]||{});   if(!bar){     bar=document.createElement('div'); bar.className='render-warn';     ov.insertBefore(bar,ov.firstChild);   }-  bar.innerHTML=`⚠ This ${kind} is missing a render folder, so its 3D, PCB and schematic may not load properly. Reupload the ${kind} with the <b>publish-board</b> skill to regenerate the render assets.`;+  bar.innerHTML=`⚠ This ${kind} is missing the assets it needs to render (its KiCad board/schematic or 3D model aren't on the page), so its 3D, PCB and schematic won't load. Reupload the ${kind} with the <b>publish-board</b> skill.`; } async function loadBoardMedia(slug){   // service-kicad renders in the background (a board GLB can take ~a minute) —@@ -744,6 +745,8 @@ let modalGo=null; const showModal=()=>document.getElementById('modal').style.display='flex'; const hideModal=()=>{document.getElementById('modal').style.display='none';modalGo=null;const b=document.getElementById('applybar');if(b)b.remove();}; document.getElementById('modal-cancel').onclick=hideModal;+// click the dim backdrop (outside the box) to close, same as Cancel+document.getElementById('modal').addEventListener('click',e=>{ if(e.target.id==='modal')hideModal(); }); document.getElementById('modal-go').onclick=()=>{if(modalGo)modalGo();}; function selOpt(sel,attr,cb){document.querySelectorAll('#modal-opts .opt').forEach(o=>o.onclick=()=>{document.querySelectorAll('#modal-opts .opt').forEach(x=>x.classList.remove('sel'));o.classList.add('sel');o.querySelector('input').checked=true;cb(o.dataset[attr]);});} // recommended destination parents for delivered projects (editable in the box)
vendor/altium-codec/Cargo.tomladded+13
@@ -0,0 +1,13 @@+[package]+name = "altium-codec"+version = "0.1.0"+edition = "2021"+description = "Trimmed vendor (model + KiCad reader/writer only) of adom/adom-lbr's altium-codec, for adom-project-manager."++[lib]+path = "src/lib.rs"++[dependencies]+anyhow = "1"+serde = { version = "1", features = ["derive"] }+serde_json = "1"
vendor/altium-codec/src/kicad.rsadded+446
@@ -0,0 +1,446 @@+//! KiCad `.kicad_sym` / `.kicad_mod` **reader** (the import side of the KiCad+//! adapter). Parses the s-expression into the neutral [`AdomLbrPart`].+//!+//! Units: KiCad is millimetres. Symbol pins convert mm → mils (the model's+//! schematic unit, 1 mil = 0.0254 mm); footprint pads stay in mm.+//!+//! v0 scope: pins (number/name/position/rotation) and pads (number/type/shape/+//! position/size/drill). Silk/courtyard graphics import lands with the graphics+//! work — they have nowhere to go in the Altium *writer* yet.++use crate::model::{AdomLbrPart, ConnectEntry, Footprint, FpGraphic, FpLayer, Pad, Pin, Symbol};+use anyhow::{Context, Result};++const MM_PER_MIL: f64 = 0.0254;++// The s-expression parser lives in crate::sexpr (shared, KiCad-parser-inspired:+// line-tracked errors, strict balance checking, version gate). Re-exported here+// for backward compatibility with existing use sites.+pub use crate::sexpr::{parse_root, Sx};+++// ---------------------------------------------------------------------------+// Symbol import+// ---------------------------------------------------------------------------++fn import_symbol(text: &str, wanted: &str) -> Result<Symbol> {+    let root = parse_root(text)?;+    // The file's declared format version drives version-scoped semantics+    // (KiCad parser's m_requiredVersion gate).+    let doc_version = root.doc_version();+    // Top-level symbols only — a .kicad_sym is a LIBRARY and may hold many.+    // Everything below must be scoped to the selected one, or a multi-symbol+    // lib bleeds other symbols' properties/graphics into this part.+    let syms: Vec<&Sx> = root+        .items()+        .iter()+        .filter(|s| s.head() == Some("symbol"))+        .collect();+    let sym: &Sx = match syms.len() {+        0 => anyhow::bail!("no (symbol ...) in kicad_sym"),+        1 => syms[0],+        _ => syms+            .iter()+            .find(|s| s.atom_at(1).is_some_and(|n| n.eq_ignore_ascii_case(wanted)))+            .copied()+            .with_context(|| {+                let names: Vec<_> = syms.iter().filter_map(|s| s.atom_at(1)).collect();+                format!(+                    "library holds {} symbols and none is named {wanted:?} — available: {}",+                    syms.len(),+                    names.join(", ")+                )+            })?,+    };+    let name = sym.atom_at(1).unwrap_or("SYM").to_string();++    // designator prefix from the Reference property — of THIS symbol only+    let mut prefix = "U".to_string();+    let mut props = Vec::new();+    sym.collect("property", &mut props);+    for p in &props {+        if p.atom_at(1) == Some("Reference") {+            if let Some(r) = p.atom_at(2) {+                prefix = r.to_string();+            }+        }+    }++    // all pins anywhere under the symbol+    let mut pin_lists = Vec::new();+    sym.collect("pin", &mut pin_lists);+    let mut pins = Vec::new();+    for p in pin_lists {+        let at = p.child("at").map(|a| a.nums()).unwrap_or_default();+        let (xmm, ymm, rot) = (+            at.first().copied().unwrap_or(0.0),+            at.get(1).copied().unwrap_or(0.0),+            at.get(2).copied().unwrap_or(0.0),+        );+        let number = p+            .child("number")+            .and_then(|n| n.atom_at(1))+            .unwrap_or("?")+            .to_string();+        // "~" means "unnamed" only under the legacy (< 20250318) format —+        // gate on the file's declared version, exactly like KiCad's parser.+        let pname = p+            .child("name")+            .and_then(|n| n.atom_at(1))+            .map(|s| crate::sexpr::resolve_tilde(s, doc_version))+            .unwrap_or_default();+        // KiCad pin length is in mm → convert to mils (the model's unit).+        let len_mil = p+            .child("length")+            .and_then(|l| l.nums().first().copied())+            .map(|mm| (mm / MM_PER_MIL).round() as i32);+        pins.push(Pin {+            number,+            name: pname,+            x: (xmm / MM_PER_MIL).round() as i32,+            y: (ymm / MM_PER_MIL).round() as i32,+            length: len_mil,+            rotation: ((rot as i32 % 360 + 360) % 360) as u16,+        });+    }++    // Preserve the source's pin-number / pin-name visibility (KiCad+    // `(pin_numbers (hide yes))`). Absent → shown (KiCad default).+    let has_hide = |key: &str| -> bool {+        match sym.child(key) {+            None => false,+            Some(node) => node+                .child("hide")+                .and_then(|h| h.atom_at(1))+                .map(|v| v == "yes")+                .unwrap_or_else(|| node.items().iter().any(|i| matches!(i, Sx::Atom(a) if a == "hide"))),+        }+    };++    Ok(Symbol {+        name,+        pins,+        body: None,+        graphics: import_symbol_graphics(sym),+        designator_prefix: Some(prefix),+        footprint: None,+        parameters: Vec::new(),+        pin_numbers_hidden: Some(has_hide("pin_numbers")),+        pin_names_hidden: Some(has_hide("pin_names")),+    })+}++/// Import a KiCad symbol's body graphics (polyline / rectangle / circle / arc)+/// into neutral [`Graphic`]s, in mils. Generic — not part-specific.+fn import_symbol_graphics(root: &Sx) -> Vec<crate::model::Graphic> {+    use crate::model::Graphic;+    let to_mil = |mm: f64| mm / MM_PER_MIL;+    let mut out = Vec::new();+    let width_of = |g: &Sx| -> f64 {+        g.child("stroke")+            .and_then(|s| s.child("width"))+            .and_then(|w| w.nums().first().copied())+            .map(to_mil)+            .unwrap_or(6.0)+    };++    let mut polys = Vec::new();+    root.collect("polyline", &mut polys);+    for g in polys {+        if let Some(pts) = g.child("pts") {+            let mut xy = Vec::new();+            let mut pt_lists = Vec::new();+            pts.collect("xy", &mut pt_lists);+            for p in pt_lists {+                let n = p.nums();+                if n.len() >= 2 {+                    xy.push([to_mil(n[0]), to_mil(n[1])]);+                }+            }+            if xy.len() >= 2 {+                out.push(Graphic::Polyline { points: xy, width: width_of(g) });+            }+        }+    }++    // A shape is "filled" when its (fill (type ...)) is not `none` (KiCad fills+    // with `outline` or `background`). Cap plates are filled rectangles.+    let filled_of = |g: &Sx| -> bool {+        g.child("fill")+            .and_then(|f| f.child("type"))+            .and_then(|t| t.atom_at(1))+            .map(|t| t != "none")+            .unwrap_or(false)+    };++    let mut rects = Vec::new();+    root.collect("rectangle", &mut rects);+    for g in rects {+        let s = g.child("start").map(|c| c.nums()).unwrap_or_default();+        let e = g.child("end").map(|c| c.nums()).unwrap_or_default();+        if s.len() >= 2 && e.len() >= 2 {+            out.push(Graphic::Rect {+                x1: to_mil(s[0]), y1: to_mil(s[1]), x2: to_mil(e[0]), y2: to_mil(e[1]),+                width: width_of(g), filled: filled_of(g),+            });+        }+    }++    let mut circs = Vec::new();+    root.collect("circle", &mut circs);+    for g in circs {+        let c = g.child("center").map(|c| c.nums()).unwrap_or_default();+        let r = g.child("radius").and_then(|r| r.nums().first().copied());+        if c.len() >= 2 {+            if let Some(r) = r {+                out.push(Graphic::Circle { cx: to_mil(c[0]), cy: to_mil(c[1]), r: to_mil(r), width: width_of(g) });+            }+        }+    }++    // Arcs — KiCad gives 3 points (start / mid / end); convert to the neutral+    // center + radius + start/end-angle form. Direction is normalized so the+    // Altium CCW sweep from start→end passes through the mid point.+    let mut arcs = Vec::new();+    root.collect("arc", &mut arcs);+    for g in arcs {+        let s = g.child("start").map(|c| c.nums()).unwrap_or_default();+        let m = g.child("mid").map(|c| c.nums()).unwrap_or_default();+        let e = g.child("end").map(|c| c.nums()).unwrap_or_default();+        if s.len() >= 2 && m.len() >= 2 && e.len() >= 2 {+            if let Some((cx, cy, r, sa, ea)) =+                arc_from_3pts(s[0], s[1], m[0], m[1], e[0], e[1])+            {+                out.push(Graphic::Arc {+                    cx: to_mil(cx),+                    cy: to_mil(cy),+                    r: to_mil(r),+                    start: sa,+                    end: ea,+                    width: width_of(g),+                });+            }+        }+    }+    out+}++/// Given three points on an arc (start, mid, end), return+/// `(center_x, center_y, radius, start_angle, end_angle)` with angles in degrees+/// (0..360). The angle order is chosen so a counter-clockwise sweep from the+/// start angle to the end angle (Altium's arc convention) passes through `mid`.+/// Returns `None` if the points are collinear.+fn arc_from_3pts(+    ax: f64, ay: f64, bx: f64, by: f64, cx: f64, cy: f64,+) -> Option<(f64, f64, f64, f64, f64)> {+    let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));+    if d.abs() < 1e-9 {+        return None; // collinear → no unique circle+    }+    let a2 = ax * ax + ay * ay;+    let b2 = bx * bx + by * by;+    let c2 = cx * cx + cy * cy;+    let ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d;+    let uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d;+    let r = ((ax - ux).powi(2) + (ay - uy).powi(2)).sqrt();+    let ang = |x: f64, y: f64| {+        let mut a = (y - uy).atan2(x - ux).to_degrees();+        if a < 0.0 {+            a += 360.0;+        }+        a+    };+    let sa = ang(ax, ay);+    let ma = ang(bx, by);+    let ea = ang(cx, cy);+    // Relative CCW position from the start angle; if the mid isn't inside the+    // start→end CCW sweep, swap the endpoints so it is.+    let rel = |x: f64| {+        let mut v = (x - sa) % 360.0;+        if v < 0.0 {+            v += 360.0;+        }+        v+    };+    let (sa, ea) = if rel(ma) <= rel(ea) { (sa, ea) } else { (ea, sa) };+    Some((ux, uy, r, sa, ea))+}++// ---------------------------------------------------------------------------+// Footprint import+// ---------------------------------------------------------------------------++fn import_footprint(text: &str) -> Result<Footprint> {+    let root = parse_root(text)?;+    // (footprint "NAME" ...)  — name is the first atom after head+    let name = root.atom_at(1).unwrap_or("FP").to_string();++    let mut pad_lists = Vec::new();+    root.collect("pad", &mut pad_lists);+    let mut pads = Vec::new();+    for p in pad_lists {+        let number = p.atom_at(1).unwrap_or("?").to_string();+        let ptype = p.atom_at(2).unwrap_or("smd");+        let shape = p.atom_at(3).unwrap_or("rect").to_string();+        let at = p.child("at").map(|a| a.nums()).unwrap_or_default();+        let size = p.child("size").map(|a| a.nums()).unwrap_or_default();+        let rot = at.get(2).copied().unwrap_or(0.0);+        // (drill d) or (drill oval w h) — take the larger axis of a slot so+        // the hole isn't undersized (full slot support is a future field).+        let drill = p+            .child("drill")+            .map(|d| d.nums().into_iter().fold(0.0_f64, f64::max))+            .unwrap_or(0.0);+        let kshape = match shape.as_str() {+            "circle" => "circle",+            "oval" => "oval",+            "roundrect" => "roundrect",+            _ => "rect",+        };+        // thru_hole AND np_thru_hole both have a real hole; dropping the NPTH+        // drill would silently turn a mounting hole into an SMD copper pad.+        let is_th = ptype.ends_with("thru_hole");+        pads.push(Pad {+            number,+            x_mm: at.first().copied().unwrap_or(0.0),+            y_mm: at.get(1).copied().unwrap_or(0.0),+            w_mm: size.first().copied().unwrap_or(0.5),+            h_mm: size.get(1).copied().unwrap_or(0.5),+            drill_mm: if is_th { drill } else { 0.0 },+            rotation: rot,+            shape: kshape.to_string(),+            plated: if is_th { Some(ptype != "np_thru_hole") } else { None },+        });+    }++    Ok(Footprint { name, pads, graphics: import_footprint_graphics(&root) })+}++/// Map a KiCad footprint layer name to a neutral [`FpLayer`] role. Returns+/// `None` for layers we don't carry (paste/mask/etc.).+fn kicad_fp_layer(layer: &str) -> Option<FpLayer> {+    if layer.contains("SilkS") {+        Some(FpLayer::Silk)+    } else if layer.contains("CrtYd") {+        Some(FpLayer::Courtyard)+    } else if layer.contains("Fab") {+        Some(FpLayer::Fab)+    } else if layer.ends_with(".Cu") {+        Some(FpLayer::Copper)+    } else {+        None+    }+}++/// Import a KiCad footprint's silk/courtyard/fab graphics into neutral+/// [`FpGraphic`]s (mm, Y-down). Covers fp_line / fp_rect / fp_circle / fp_arc.+fn import_footprint_graphics(root: &Sx) -> Vec<FpGraphic> {+    let mut out = Vec::new();+    let layer_of = |g: &Sx| -> Option<FpLayer> {+        g.child("layer")+            .and_then(|l| l.atom_at(1))+            .and_then(kicad_fp_layer)+    };+    let width_of = |g: &Sx| -> f64 {+        g.child("stroke")+            .and_then(|s| s.child("width"))+            .and_then(|w| w.nums().first().copied())+            .or_else(|| g.child("width").and_then(|w| w.nums().first().copied()))+            .unwrap_or(0.12)+    };++    let mut lines = Vec::new();+    root.collect("fp_line", &mut lines);+    for g in lines {+        if let Some(layer) = layer_of(g) {+            let s = g.child("start").map(|c| c.nums()).unwrap_or_default();+            let e = g.child("end").map(|c| c.nums()).unwrap_or_default();+            if s.len() >= 2 && e.len() >= 2 {+                out.push(FpGraphic::Line { layer, x1: s[0], y1: s[1], x2: e[0], y2: e[1], width: width_of(g) });+            }+        }+    }++    let mut rects = Vec::new();+    root.collect("fp_rect", &mut rects);+    for g in rects {+        if let Some(layer) = layer_of(g) {+            let s = g.child("start").map(|c| c.nums()).unwrap_or_default();+            let e = g.child("end").map(|c| c.nums()).unwrap_or_default();+            if s.len() >= 2 && e.len() >= 2 {+                out.push(FpGraphic::Rect { layer, x1: s[0], y1: s[1], x2: e[0], y2: e[1], width: width_of(g) });+            }+        }+    }++    let mut circs = Vec::new();+    root.collect("fp_circle", &mut circs);+    for g in circs {+        if let Some(layer) = layer_of(g) {+            let c = g.child("center").map(|c| c.nums()).unwrap_or_default();+            let e = g.child("end").map(|c| c.nums()).unwrap_or_default();+            if c.len() >= 2 && e.len() >= 2 {+                let r = ((e[0] - c[0]).powi(2) + (e[1] - c[1]).powi(2)).sqrt();+                out.push(FpGraphic::Circle { layer, cx: c[0], cy: c[1], r, width: width_of(g) });+            }+        }+    }++    let mut arcs = Vec::new();+    root.collect("fp_arc", &mut arcs);+    for g in arcs {+        if let Some(layer) = layer_of(g) {+            let s = g.child("start").map(|c| c.nums()).unwrap_or_default();+            let m = g.child("mid").map(|c| c.nums()).unwrap_or_default();+            let e = g.child("end").map(|c| c.nums()).unwrap_or_default();+            if s.len() >= 2 && m.len() >= 2 && e.len() >= 2 {+                if let Some((cx, cy, r, sa, ea)) = arc_from_3pts(s[0], s[1], m[0], m[1], e[0], e[1]) {+                    out.push(FpGraphic::Arc { layer, cx, cy, r, start: sa, end: ea, width: width_of(g) });+                }+            }+        }+    }+    out+}++// ---------------------------------------------------------------------------+// Public: KiCad symbol + footprint → AdomLbrPart+// ---------------------------------------------------------------------------++/// Import a KiCad symbol + footprint pair into the neutral part. The pin↔pad+/// link is identity-mapped (pin number == pad number) where they coincide, and+/// the symbol is given the footprint name so the Altium writer links them.+pub fn import_kicad(sym_text: &str, mod_text: &str, mpn: &str) -> Result<AdomLbrPart> {+    let mut symbol = import_symbol(sym_text, mpn)?;+    let footprint = import_footprint(mod_text)?;+    symbol.footprint = Some(footprint.name.clone());+    // Center the symbol on the origin (KiCad sources are often drawn off-origin).+    symbol.center();++    // identity connect map over shared designators+    let pad_nums: std::collections::HashSet<&str> =+        footprint.pads.iter().map(|p| p.number.as_str()).collect();+    let connect = symbol+        .pins+        .iter()+        .filter(|p| pad_nums.contains(p.number.as_str()))+        .map(|p| ConnectEntry {+            pin: p.number.clone(),+            pad: p.number.clone(),+        })+        .collect();++    Ok(AdomLbrPart {+        schema_version: 2,+        mpn: mpn.to_string(),+        manufacturer: String::new(),+        package: String::new(),+        value: String::new(),+        category: String::new(),+        symbol,+        footprint,+        connect,+        model_3d: None,+        provenance: Some(serde_json::json!({"source":"kicad_import"})),+    })+}
vendor/altium-codec/src/kicad_export.rsadded+415
@@ -0,0 +1,415 @@+//! Canonical [`AdomLbrPart`] → KiCad `.kicad_sym` + `.kicad_mod` (the KiCad+//! WRITE side — inverse of `kicad.rs`). Symbol coords are stored in mils and+//! convert to mm here; footprint coords are already mm. Graphics arcs are+//! emitted in KiCad's 3-point (start/mid/end) form.++use crate::model::{Footprint, FpGraphic, FpLayer, Graphic, Symbol};++const MM_PER_MIL: f64 = 0.0254;++/// Selectable KiCad output format tier. Every KiCad reads all OLDER formats,+/// so only three tiers are meaningful (7 and 9 are redundant — their readers+/// accept the previous tier's files):+///   V6  — oldest stable s-expr grammar; for third-party tools stuck there+///         (Fusion 360's KiCad importer, kiutils-era parsers).+///   V8  — the "normalized bools" cleanup; the interop sweet spot, readable by+///         KiCad 8/9/10 and most modern third-party parsers. THE DEFAULT —+///         barrett's desktop runs KiCad 9.0.9, which REJECTS v10-format files+///         ("Unable to load library", verified 2026-07-06), and KiCad refuses+///         any file newer than itself.+///   V10 — current native format; opt in via --kicad-version 10.+/// Version values are what each KiCad release itself writes. Sources of truth+/// in the KiCad repo (branch `10.0`):+///   symbol lib → eeschema/sch_file_versions.h  SEXPR_SYMBOL_LIB_FILE_VERSION+///   footprint  → pcbnew/pcb_io/kicad_sexpr/pcb_io_kicad_sexpr.h  SEXPR_BOARD_FILE_VERSION+/// ≥20250318 semantics: a bare "~" is a literal tilde, never "empty" — always+/// write real empty strings (valid in every tier).+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]+pub enum KicadVersion {+    V6,+    #[default]+    V8,+    V10,+}++impl KicadVersion {+    /// Parse a user-facing selector ("6" | "8" | "10").+    pub fn parse(s: &str) -> Option<Self> {+        match s.trim() {+            "6" | "v6" => Some(Self::V6),+            "8" | "v8" => Some(Self::V8),+            "10" | "v10" => Some(Self::V10),+            _ => None,+        }+    }+    pub fn sym_version(self) -> u32 {+        match self {+            Self::V6 => 20211014,+            Self::V8 => 20231120,+            Self::V10 => 20251024,+        }+    }+    pub fn fp_version(self) -> u32 {+        match self {+            Self::V6 => 20211014,+            Self::V8 => 20240108,+            Self::V10 => 20260206,+        }+    }+    /// `generator_version` token value; V6 predates the token entirely.+    pub fn generator_version(self) -> Option<&'static str> {+        match self {+            Self::V6 => None,+            Self::V8 => Some("8.0"),+            Self::V10 => Some("10.0"),+        }+    }+    /// V6 wrote bare boolean flags (`hide`); V8+ normalized to `(hide yes)`.+    fn hide_flag(self) -> &'static str {+        match self {+            Self::V6 => " hide",+            _ => " (hide yes)",+        }+    }+    /// `exclude_from_sim` didn't exist in the V6 grammar — real KiCad 6+    /// errors on unknown tokens, so omit it there.+    fn writes_exclude_from_sim(self) -> bool {+        !matches!(self, Self::V6)+    }+    /// Library header line(s) shared by the symbol emitters.+    fn sym_header(self, generator: &str) -> String {+        match self.generator_version() {+            Some(gv) => format!(+                "(kicad_symbol_lib\n\t(version {})\n\t(generator \"{generator}\")\n\t(generator_version \"{gv}\")\n",+                self.sym_version()+            ),+            None => format!("(kicad_symbol_lib\n\t(version {})\n\t(generator \"{generator}\")\n", self.sym_version()),+        }+    }+}++/// Convenience constants for callers that just want "current native".+pub const KICAD_SYM_VERSION: u32 = 20251024;+pub const KICAD_FP_VERSION: u32 = 20260206;+pub const KICAD_GENERATOR_VERSION: &str = "10.0";++/// Format a float compactly (KiCad tolerates plain decimals; trim trailing 0s).+fn n(v: f64) -> String {+    let s = format!("{v:.6}");+    let s = s.trim_end_matches('0').trim_end_matches('.');+    if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() }+}++// ---------------------------------------------------------------------------+// Symbol → .kicad_sym+// ---------------------------------------------------------------------------++/// Export a symbol as a complete `.kicad_sym` library (one symbol), in the+/// default format tier (V8 — loads in KiCad 8/9/10; see [`KicadVersion`]).+pub fn export_kicad_symbol(sym: &Symbol) -> String {+    export_kicad_symbol_for(sym, KicadVersion::default())+}++/// Export a symbol targeting a specific KiCad format tier.+pub fn export_kicad_symbol_for(sym: &Symbol, ver: KicadVersion) -> String {+    let m = |mil: f64| n(mil * MM_PER_MIL);+    let name = &sym.name;+    let reference = sym.designator_prefix.clone().unwrap_or_else(|| "U".into());+    let footprint = sym.footprint.clone().unwrap_or_default();+    let value = sym+        .parameters+        .iter()+        .find(|p| p.name.eq_ignore_ascii_case("Value"))+        .map(|p| p.value.clone())+        .unwrap_or_else(|| name.clone());++    // Bounding box (mm) over pins + graphics, so the Reference/Value labels sit+    // just outside the symbol instead of overlapping it at the origin.+    let (top, bottom, cx) = symbol_bbox_mm(sym);++    let mut s = String::new();+    s.push_str(&ver.sym_header("adom-lbr"));+    s.push_str(&format!("\t(symbol \"{name}\"\n"));+    // Preserve the source's pin-number/name visibility; when unknown (e.g. built+    // from Altium or scratch), default passives (≤2 pins) to hidden like KiCad's+    // own R/C/L symbols.+    let passive = sym.pins.len() <= 2;+    let hide_num = sym.pin_numbers_hidden.unwrap_or(passive);+    let hide_name = sym.pin_names_hidden.unwrap_or(passive);+    let hide = ver.hide_flag();+    s.push_str(&if hide_num {+        format!("\t\t(pin_numbers{hide})\n")+    } else {+        "\t\t(pin_numbers)\n".to_string()+    });+    s.push_str(&if hide_name {+        format!("\t\t(pin_names (offset 0.254){hide})\n")+    } else {+        "\t\t(pin_names (offset 0.254))\n".to_string()+    });+    if ver.writes_exclude_from_sim() {+        s.push_str("\t\t(exclude_from_sim no)\n");+    }+    s.push_str("\t\t(in_bom yes)\n\t\t(on_board yes)\n");+    // properties (Reference above the symbol, Value below; metadata hidden at origin)+    let prop = |k: &str, v: &str, x: f64, y: f64, hidden: bool| {+        let h = if hidden { hide } else { "" };+        format!(+            "\t\t(property \"{k}\" \"{}\"\n\t\t\t(at {} {} 0)\n\t\t\t(effects (font (size 1.27 1.27)){h})\n\t\t)\n",+            esc(v), n(x), n(y)+        )+    };+    s.push_str(&prop("Reference", &reference, cx, top + 1.27, false));+    s.push_str(&prop("Value", &value, cx, bottom - 1.27, false));+    s.push_str(&prop("Footprint", &footprint, 0.0, 0.0, true));+    // "" not "~": since format 20250318 a tilde is a literal tilde.+    s.push_str(&prop("Datasheet", "", 0.0, 0.0, true));+    // any extra parameters become hidden properties (at origin)+    for p in &sym.parameters {+        if !p.name.eq_ignore_ascii_case("Value") {+            s.push_str(&prop(&p.name, &p.value, 0.0, 0.0, !p.visible));+        }+    }++    // body graphics → the _0_1 (shared) sub-symbol+    if !sym.graphics.is_empty() {+        s.push_str(&format!("\t\t(symbol \"{name}_0_1\"\n"));+        for g in &sym.graphics {+            s.push_str(&sym_graphic(g, &m));+        }+        s.push_str("\t\t)\n");+    }++    // pins → the _1_1 (unit 1) sub-symbol+    s.push_str(&format!("\t\t(symbol \"{name}_1_1\"\n"));+    for pin in &sym.pins {+        let len = pin.length.unwrap_or(200) as f64;+        // legacy sources used "~" for an unnamed pin; in KiCad 10 that's a+        // literal tilde, so write the real empty string.+        let pname = if pin.name == "~" { "" } else { pin.name.as_str() };+        s.push_str(&format!(+            "\t\t\t(pin unspecified line\n\t\t\t\t(at {} {} {})\n\t\t\t\t(length {})\n\+             \t\t\t\t(name \"{}\" (effects (font (size 1.27 1.27))))\n\+             \t\t\t\t(number \"{}\" (effects (font (size 1.27 1.27))))\n\t\t\t)\n",+            m(pin.x as f64), m(pin.y as f64), pin.rotation, m(len),+            esc(pname), esc(&pin.number)+        ));+    }+    s.push_str("\t\t)\n\t)\n)\n");+    s+}++/// One symbol body graphic → KiCad s-expr (mils→mm via `m`).+fn sym_graphic(g: &Graphic, m: &dyn Fn(f64) -> String) -> String {+    let stroke_fill = |w: f64, filled: bool| {+        let fill = if filled { "outline" } else { "none" };+        format!(+            "\t\t\t\t(stroke (width {}) (type default))\n\t\t\t\t(fill (type {fill}))\n",+            n((w * MM_PER_MIL).max(0.0))+        )+    };+    match g {+        Graphic::Rect { x1, y1, x2, y2, width, filled } => format!(+            "\t\t\t(rectangle\n\t\t\t\t(start {} {})\n\t\t\t\t(end {} {})\n{}\t\t\t)\n",+            m(*x1), m(*y1), m(*x2), m(*y2), stroke_fill(*width, *filled)+        ),+        Graphic::Circle { cx, cy, r, width } => format!(+            "\t\t\t(circle\n\t\t\t\t(center {} {})\n\t\t\t\t(radius {})\n{}\t\t\t)\n",+            m(*cx), m(*cy), m(*r), stroke_fill(*width, false)+        ),+        Graphic::Arc { cx, cy, r, start, end, width } => {+            let (s, mid, e) = arc_points(*cx, *cy, *r, *start, *end);+            format!(+                "\t\t\t(arc\n\t\t\t\t(start {} {})\n\t\t\t\t(mid {} {})\n\t\t\t\t(end {} {})\n{}\t\t\t)\n",+                m(s.0), m(s.1), m(mid.0), m(mid.1), m(e.0), m(e.1), stroke_fill(*width, false)+            )+        }+        Graphic::Polyline { points, width } => {+            let mut pts = String::new();+            for p in points {+                pts.push_str(&format!("\t\t\t\t\t(xy {} {})\n", m(p[0]), m(p[1])));+            }+            format!(+                "\t\t\t(polyline\n\t\t\t\t(pts\n{pts}\t\t\t\t)\n{}\t\t\t)\n",+                stroke_fill(*width, false)+            )+        }+    }+}++// ---------------------------------------------------------------------------+// Footprint → .kicad_mod+// ---------------------------------------------------------------------------++/// Export a footprint as a `.kicad_mod`, in the default format tier (V8 —+/// loads in KiCad 8/9/10; see [`KicadVersion`]).+pub fn export_kicad_footprint(fp: &Footprint) -> String {+    export_kicad_footprint_for(fp, KicadVersion::default())+}++/// Export a footprint targeting a specific KiCad format tier.+pub fn export_kicad_footprint_for(fp: &Footprint, ver: KicadVersion) -> String {+    let smd = fp.pads.iter().all(|p| p.drill_mm <= 0.0);+    let mut s = String::new();+    s.push_str(&format!("(footprint \"{}\"\n", esc(&fp.name)));+    match ver.generator_version() {+        Some(gv) => s.push_str(&format!(+            "\t(version {})\n\t(generator \"adom-lbr\")\n\t(generator_version \"{gv}\")\n\t(layer \"F.Cu\")\n",+            ver.fp_version()+        )),+        None => s.push_str(&format!(+            "\t(version {})\n\t(generator \"adom-lbr\")\n\t(layer \"F.Cu\")\n",+            ver.fp_version()+        )),+    }+    s.push_str(if smd { "\t(attr smd)\n" } else { "\t(attr through_hole)\n" });++    // silk / courtyard / fab graphics+    for g in &fp.graphics {+        s.push_str(&fp_graphic(g));+    }++    // pads+    for pad in &fp.pads {+        let thru = pad.drill_mm > 0.0;+        let npth = thru && pad.plated == Some(false);+        let (ptype, layers) = if npth {+            // NPTH (mounting hole): no copper ring, mask relief only.+            ("np_thru_hole", "\"*.Mask\"")+        } else if thru {+            ("thru_hole", "\"*.Cu\" \"*.Mask\"")+        } else {+            ("smd", "\"F.Cu\" \"F.Paste\" \"F.Mask\"")+        };+        let shape = match pad.shape.as_str() {+            "circle" => "circle",+            "oval" => "oval",+            "roundrect" => "roundrect",+            _ => "rect",+        };+        let at = if pad.rotation != 0.0 {+            format!("(at {} {} {})", n(pad.x_mm), n(pad.y_mm), n(pad.rotation))+        } else {+            format!("(at {} {})", n(pad.x_mm), n(pad.y_mm))+        };+        s.push_str(&format!(+            "\t(pad \"{}\" {ptype} {shape} {at} (size {} {})",+            esc(&pad.number), n(pad.w_mm), n(pad.h_mm)+        ));+        if thru {+            s.push_str(&format!(" (drill {})", n(pad.drill_mm)));+        }+        s.push_str(&format!(" (layers {layers})"));+        if shape == "roundrect" {+            s.push_str(" (roundrect_rratio 0.25)");+        }+        s.push_str(")\n");+    }+    s.push_str(")\n");+    s+}++fn kicad_fp_layer(l: &FpLayer) -> &'static str {+    match l {+        FpLayer::Silk => "F.SilkS",+        FpLayer::Courtyard => "F.CrtYd",+        FpLayer::Fab => "F.Fab",+        FpLayer::Copper => "F.Cu",+        FpLayer::Other { .. } => "F.Fab",+    }+}++fn fp_graphic(g: &FpGraphic) -> String {+    match g {+        FpGraphic::Line { layer, x1, y1, x2, y2, width } => format!(+            "\t(fp_line (start {} {}) (end {} {})\n\t\t(stroke (width {}) (type default)) (layer \"{}\"))\n",+            n(*x1), n(*y1), n(*x2), n(*y2), n(*width), kicad_fp_layer(layer)+        ),+        FpGraphic::Rect { layer, x1, y1, x2, y2, width } => format!(+            "\t(fp_rect (start {} {}) (end {} {})\n\t\t(stroke (width {}) (type default)) (fill none) (layer \"{}\"))\n",+            n(*x1), n(*y1), n(*x2), n(*y2), n(*width), kicad_fp_layer(layer)+        ),+        FpGraphic::Circle { layer, cx, cy, r, width } => format!(+            "\t(fp_circle (center {} {}) (end {} {})\n\t\t(stroke (width {}) (type default)) (fill none) (layer \"{}\"))\n",+            n(*cx), n(*cy), n(cx + r), n(*cy), n(*width), kicad_fp_layer(layer)+        ),+        FpGraphic::Arc { layer, cx, cy, r, start, end, width } => {+            let (s, mid, e) = arc_points(*cx, *cy, *r, *start, *end);+            format!(+                "\t(fp_arc (start {} {}) (mid {} {}) (end {} {})\n\t\t(stroke (width {}) (type default)) (layer \"{}\"))\n",+                n(s.0), n(s.1), n(mid.0), n(mid.1), n(e.0), n(e.1), n(*width), kicad_fp_layer(layer)+            )+        }+    }+}++// ---------------------------------------------------------------------------+// helpers+// ---------------------------------------------------------------------------++/// center/radius/start-end angles (deg, CCW) → the (start, mid, end) points+/// KiCad uses. Same units in and out.+fn arc_points(cx: f64, cy: f64, r: f64, start: f64, end: f64) -> ((f64, f64), (f64, f64), (f64, f64)) {+    // Angles can come straight out of a decoded (untrusted) binary — normalize+    // arithmetically. A `while sweep <= 0 { += 360 }` loop hangs on huge+    // negatives and never terminates on NaN.+    let (start, end) = (+        if start.is_finite() { start } else { 0.0 },+        if end.is_finite() { end } else { 0.0 },+    );+    let mut sweep = (end - start).rem_euclid(360.0);+    if sweep == 0.0 {+        sweep = 360.0; // same start/end angle = full circle, as before+    }+    let mid_ang = start + sweep / 2.0;+    let pt = |deg: f64| {+        let a = deg.to_radians();+        (cx + r * a.cos(), cy + r * a.sin())+    };+    (pt(start), pt(mid_ang), pt(end))+}++fn esc(s: &str) -> String {+    s.replace('\\', "\\\\").replace('"', "\\\"")+}++/// Symbol bounding box in mm over pins + graphics → `(top_y, bottom_y, center_x)`.+/// Used to place the Reference (above) and Value (below) labels clear of the art.+fn symbol_bbox_mm(sym: &Symbol) -> (f64, f64, f64) {+    let (mut minx, mut maxx) = (f64::INFINITY, f64::NEG_INFINITY);+    let (mut miny, mut maxy) = (f64::INFINITY, f64::NEG_INFINITY);+    let mut upd = |x: f64, y: f64| {+        minx = minx.min(x);+        maxx = maxx.max(x);+        miny = miny.min(y);+        maxy = maxy.max(y);+    };+    for p in &sym.pins {+        upd(p.x as f64, p.y as f64);+    }+    for g in &sym.graphics {+        match g {+            Graphic::Rect { x1, y1, x2, y2, .. } => {+                upd(*x1, *y1);+                upd(*x2, *y2);+            }+            Graphic::Circle { cx, cy, r, .. } | Graphic::Arc { cx, cy, r, .. } => {+                upd(cx - r, cy - r);+                upd(cx + r, cy + r);+            }+            Graphic::Polyline { points, .. } => {+                for p in points {+                    upd(p[0], p[1]);+                }+            }+        }+    }+    if !minx.is_finite() {+        return (2.54, -2.54, 0.0);+    }+    (+        maxy * MM_PER_MIL,+        miny * MM_PER_MIL,+        ((minx + maxx) / 2.0) * MM_PER_MIL,+    )+}
vendor/altium-codec/src/lib.rsadded+17
@@ -0,0 +1,17 @@+//! altium-codec — TRIMMED vendor for adom-project-manager.+//!+//! Contains only the canonical part model + the KiCad reader/writer that the+//! Manager uses (`import_kicad`, `export_kicad_symbol/footprint`, the model+//! types). The Altium `.SchLib`/`.PcbLib`/`.IntLib` decoders and their heavy+//! deps (altium-schlib/altium-pcblib, cfb, flate2) are intentionally omitted.+//!+//! Vendored from `adom/adom-lbr` `crates/altium-codec` so the app stays+//! self-contained and immune to workspace/dependency reshuffles.+pub mod kicad;+pub mod kicad_export;+pub mod model;+pub mod sexpr;++pub use kicad::import_kicad;+pub use kicad_export::{export_kicad_footprint, export_kicad_symbol};+pub use model::{AdomLbrPart, BodyRect, ConnectEntry, Footprint, FpGraphic, FpLayer, Graphic, Pad, Parameter, Pin, Symbol};
vendor/altium-codec/src/model.rsadded+284
@@ -0,0 +1,284 @@+//! The **adom-lbr** part model — the in-memory form of the adom-lbr json, the+//! canonical Adom library format. Every EDA adapter (Altium, EAGLE/Fusion,+//! OrCAD) converts to and from this one shape. It is Adom's own format; the+//! per-tool files are just generated views of it.+//!+//! Units: symbols use Altium schematic units (1 = 1/100 inch grid step, i.e.+//! "mils/10" as Altium stores Location.X/Y as i16 in 1/100-inch? — see schlib.rs+//! for the exact scale). Footprints use millimetres at the model layer; the+//! PcbLib encoder converts mm → Altium's 1/10000-mil integer units.++use serde::{Deserialize, Serialize};++/// A schematic symbol: a body rectangle plus pins. One SchLib holds many.+#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Symbol {+    /// LibReference — the component's library name.+    pub name: String,+    /// Pins, in declaration order.+    pub pins: Vec<Pin>,+    /// Body rectangle in mils (lo corner, hi corner). Defaults to a sane box.+    #[serde(default)]+    pub body: Option<BodyRect>,+    /// Generic body graphics (polylines/rects/circles/arcs). When present, these+    /// are drawn instead of the default body box. Format-neutral.+    #[serde(default)]+    pub graphics: Vec<Graphic>,+    /// Designator prefix without the '?' (e.g. "U", "R"). Default "U".+    #[serde(default)]+    pub designator_prefix: Option<String>,+    /// Optional footprint name to link (PCBLIB implementation).+    #[serde(default)]+    pub footprint: Option<String>,+    /// Component property fields (Altium RECORD=41 parameters) — e.g. Value,+    /// Manufacturer, MPN. Shown in the component's Properties panel.+    #[serde(default)]+    pub parameters: Vec<Parameter>,+    /// Whether pin numbers are hidden on the symbol (KiCad `(pin_numbers+    /// (hide yes))`). `None` → the exporter picks a passive-friendly default.+    #[serde(default)]+    pub pin_numbers_hidden: Option<bool>,+    /// Whether pin names are hidden on the symbol. `None` → default.+    #[serde(default)]+    pub pin_names_hidden: Option<bool>,+}++/// A component parameter / property field (Altium RECORD=41). Appears in the+/// component's Properties. `visible=false` → the field exists but isn't drawn+/// on the symbol (Altium `IsHidden=T`), which is the norm for metadata.+#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Parameter {+    /// Property name (e.g. "Value", "Manufacturer").+    pub name: String,+    /// Property value/text.+    pub value: String,+    /// Whether the field is drawn on the symbol. Metadata defaults to hidden.+    #[serde(default)]+    pub visible: bool,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Pin {+    /// Pin number / designator (e.g. "1", "A4").+    pub number: String,+    /// Pin name / label (e.g. "VDD", "GND").+    pub name: String,+    /// Location.X in mils.+    pub x: i32,+    /// Location.Y in mils.+    pub y: i32,+    /// Pin length in mils. Default 200.+    #[serde(default)]+    pub length: Option<i32>,+    /// Orientation in degrees: 0 / 90 / 180 / 270.+    #[serde(default)]+    pub rotation: u16,+}++/// A generic symbol graphic primitive (the symbol body / decorations). Units are+/// mils. This is format-agnostic: each EDA adapter maps it to its own primitive+/// (KiCad polyline/rectangle/circle/arc ↔ Altium RECORD=6/14/11/12). Works for+/// ANY component — nothing is cap-specific.+#[derive(Debug, Clone, Serialize, Deserialize)]+#[serde(tag = "kind", rename_all = "lowercase")]+pub enum Graphic {+    /// Connected line segments through `points` (mils).+    Polyline { points: Vec<[f64; 2]>, #[serde(default)] width: f64 },+    /// Axis-aligned rectangle (mils).+    Rect { x1: f64, y1: f64, x2: f64, y2: f64, #[serde(default)] width: f64, #[serde(default)] filled: bool },+    /// Circle (mils).+    Circle { cx: f64, cy: f64, r: f64, #[serde(default)] width: f64 },+    /// Arc, angles in degrees (mils).+    Arc { cx: f64, cy: f64, r: f64, start: f64, end: f64, #[serde(default)] width: f64 },+}++impl Symbol {+    /// Shift the symbol so its bounding box (pins + graphics) is centered on the+    /// origin, snapped to the 50-mil grid so pins stay grid-aligned.+    pub fn center(&mut self) {+        let (mut minx, mut maxx) = (f64::INFINITY, f64::NEG_INFINITY);+        let (mut miny, mut maxy) = (f64::INFINITY, f64::NEG_INFINITY);+        let mut upd = |x: f64, y: f64| {+            minx = minx.min(x);+            maxx = maxx.max(x);+            miny = miny.min(y);+            maxy = maxy.max(y);+        };+        for p in &self.pins {+            upd(p.x as f64, p.y as f64);+        }+        for g in &self.graphics {+            match g {+                Graphic::Polyline { points, .. } => {+                    for pt in points {+                        upd(pt[0], pt[1]);+                    }+                }+                Graphic::Rect { x1, y1, x2, y2, .. } => {+                    upd(*x1, *y1);+                    upd(*x2, *y2);+                }+                Graphic::Circle { cx, cy, r, .. } | Graphic::Arc { cx, cy, r, .. } => {+                    upd(cx - r, cy - r);+                    upd(cx + r, cy + r);+                }+            }+        }+        if !minx.is_finite() {+            return;+        }+        let cx = (((minx + maxx) / 2.0) / 50.0).round() * 50.0;+        let cy = (((miny + maxy) / 2.0) / 50.0).round() * 50.0;+        for p in &mut self.pins {+            p.x -= cx as i32;+            p.y -= cy as i32;+        }+        for g in &mut self.graphics {+            match g {+                Graphic::Polyline { points, .. } => {+                    for pt in points.iter_mut() {+                        pt[0] -= cx;+                        pt[1] -= cy;+                    }+                }+                Graphic::Rect { x1, y1, x2, y2, .. } => {+                    *x1 -= cx;+                    *x2 -= cx;+                    *y1 -= cy;+                    *y2 -= cy;+                }+                Graphic::Circle { cx: gcx, cy: gcy, .. }+                | Graphic::Arc { cx: gcx, cy: gcy, .. } => {+                    *gcx -= cx;+                    *gcy -= cy;+                }+            }+        }+    }+}++#[derive(Debug, Clone, Copy, Serialize, Deserialize)]+pub struct BodyRect {+    pub x1: i32,+    pub y1: i32,+    pub x2: i32,+    pub y2: i32,+}++impl Default for BodyRect {+    fn default() -> Self {+        BodyRect { x1: 0, y1: -50, x2: 200, y2: 50 }+    }+}++/// A PCB footprint: pads plus silk / courtyard / fab graphics. One PcbLib holds+/// many.+#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Footprint {+    /// Pattern / footprint name.+    pub name: String,+    pub pads: Vec<Pad>,+    /// Non-copper graphics (silkscreen, courtyard, fab/assembly outlines). Kept+    /// layer-tagged so every EDA target lands them on the right layer.+    #[serde(default)]+    pub graphics: Vec<FpGraphic>,+}++/// Footprint layer role — format-neutral. Each EDA adapter maps it to its own+/// layer (KiCad `F.SilkS`/`F.CrtYd`/`F.Fab` ↔ Altium TopOverlay(33)/Mech15(71)/+/// Mech13(69)). `Other` carries the raw Altium layer byte so unknown layers+/// survive a round trip.+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]+#[serde(tag = "role", rename_all = "lowercase")]+pub enum FpLayer {+    Silk,+    Courtyard,+    Fab,+    Copper,+    Other {+        /// Raw Altium layer id (for round-trip fidelity).+        #[serde(default)]+        altium: u8,+    },+}++/// A footprint graphic primitive on a non-copper layer. Units: mm, Y-down+/// (neutral convention). Format-neutral; each adapter maps it to its own record+/// (KiCad fp_line/fp_rect/fp_circle/fp_arc ↔ Altium Track/Arc).+#[derive(Debug, Clone, Serialize, Deserialize)]+#[serde(tag = "kind", rename_all = "lowercase")]+pub enum FpGraphic {+    /// Straight segment.+    Line { layer: FpLayer, x1: f64, y1: f64, x2: f64, y2: f64, #[serde(default)] width: f64 },+    /// Axis-aligned rectangle (emitted as 4 lines in most targets).+    Rect { layer: FpLayer, x1: f64, y1: f64, x2: f64, y2: f64, #[serde(default)] width: f64 },+    /// Circle.+    Circle { layer: FpLayer, cx: f64, cy: f64, r: f64, #[serde(default)] width: f64 },+    /// Arc, angles in degrees (CCW from start to end).+    Arc { layer: FpLayer, cx: f64, cy: f64, r: f64, start: f64, end: f64, #[serde(default)] width: f64 },+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Pad {+    /// Pad designator (e.g. "1", "2", "GND").+    pub number: String,+    /// Centre X in mm (KiCad/neutral convention, Y-down).+    pub x_mm: f64,+    /// Centre Y in mm (Y-down — the encoder flips to Altium's Y-up).+    pub y_mm: f64,+    /// Pad width / height in mm.+    pub w_mm: f64,+    pub h_mm: f64,+    /// Drill diameter in mm. 0 → SMD; > 0 → through-hole.+    #[serde(default)]+    pub drill_mm: f64,+    /// Rotation in degrees.+    #[serde(default)]+    pub rotation: f64,+    /// "rect" | "circle" | "oval" | "roundrect".+    #[serde(default = "default_shape")]+    pub shape: String,+    /// Through-hole plating. `None`/`Some(true)` → plated (normal TH pad);+    /// `Some(false)` → NPTH (KiCad `np_thru_hole`, e.g. a mounting hole).+    /// Only meaningful when `drill_mm > 0`. NOTE: the Altium binary encoder+    /// currently emits NPTH as a plated TH pad (the plated flag byte in the+    /// pad record is not yet calibrated) — the hole survives, the plating+    /// distinction doesn't. KiCad export round-trips it faithfully.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub plated: Option<bool>,+}++fn default_shape() -> String {+    "rect".to_string()+}++/// The unified "Adom Library" part — one document carrying identity + symbol ++/// footprint + the pin↔pad link. This is the adom-lbr json the wiki stores and+/// every EDA adapter converts to/from.+#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct AdomLbrPart {+    pub schema_version: u32,+    pub mpn: String,+    #[serde(default)]+    pub manufacturer: String,+    #[serde(default)]+    pub package: String,+    #[serde(default)]+    pub value: String,+    #[serde(default)]+    pub category: String,+    pub symbol: Symbol,+    pub footprint: Footprint,+    #[serde(default)]+    pub connect: Vec<ConnectEntry>,+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub model_3d: Option<String>,+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub provenance: Option<serde_json::Value>,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct ConnectEntry {+    pub pin: String,+    pub pad: String,+}
vendor/altium-codec/src/sexpr.rsadded+252
@@ -0,0 +1,252 @@+//! KiCad-style s-expression reader — the shared parser for every `.kicad_sym`+//! / `.kicad_mod` consumer in this workspace (replaces the old regex parsing).+//!+//! Modeled on KiCad's own parser pair (DSNLEXER + SCH_IO_KICAD_SEXPR_PARSER in+//! the KiCad repo), from which it borrows three design points:+//!  1. a line-tracking lexer, so every parse error cites its line+//!     (KiCad: `THROW_PARSE_ERROR(..., CurLineNumber(), CurOffset())`);+//!  2. strict structure enforcement — unbalanced parens, unterminated strings+//!     and trailing garbage are ERRORS, never a silently-partial tree+//!     (KiCad: `Expecting(T_LEFT)` / `NeedRIGHT()` discipline);+//!  3. the format-version gate — semantics key off the file's declared+//!     `(version N)` (KiCad: `m_requiredVersion`), e.g. `~` means "empty+//!     string" only in files older than 20250318.++use anyhow::{bail, Result};++/// A parsed s-expression node. Atoms carry their text with string quoting+/// already resolved (`"a b"` and `a_b` both become plain atoms; a `\u{0}`+/// marker is NOT leaked — quoted-ness is dropped after parse).+#[derive(Debug)]+pub enum Sx {+    Atom(String),+    List(Vec<Sx>),+}++impl Sx {+    pub fn head(&self) -> Option<&str> {+        match self {+            Sx::List(v) => match v.first() {+                Some(Sx::Atom(a)) => Some(a.as_str()),+                _ => None,+            },+            _ => None,+        }+    }+    pub fn items(&self) -> &[Sx] {+        match self {+            Sx::List(v) => v,+            _ => &[],+        }+    }+    /// First child list whose head == name.+    pub fn child(&self, name: &str) -> Option<&Sx> {+        self.items().iter().find(|s| s.head() == Some(name))+    }+    /// Every direct child list whose head == name (non-recursive).+    pub fn children<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Sx> + 'a {+        self.items().iter().filter(move |s| s.head() == Some(name))+    }+    /// Atom string at position i (after the head), if it's an atom.+    pub fn atom_at(&self, i: usize) -> Option<&str> {+        match self.items().get(i) {+            Some(Sx::Atom(a)) => Some(a.as_str()),+            _ => None,+        }+    }+    /// Numeric atoms in this list (skipping the head atom).+    pub fn nums(&self) -> Vec<f64> {+        self.items()+            .iter()+            .skip(1)+            .filter_map(|s| match s {+                Sx::Atom(a) => a.parse::<f64>().ok(),+                _ => None,+            })+            .collect()+    }+    /// Walk the whole tree, collecting every list whose head == name.+    pub fn collect<'a>(&'a self, name: &str, out: &mut Vec<&'a Sx>) {+        if self.head() == Some(name) {+            out.push(self);+        }+        for s in self.items() {+            if let Sx::List(_) = s {+                s.collect(name, out);+            }+        }+    }+    /// The document's declared `(version N)`, if present — the equivalent of+    /// KiCad's `m_requiredVersion` gate. Callers use this for version-scoped+    /// semantics (e.g. `~` means empty only when `version < 20250318`).+    pub fn doc_version(&self) -> Option<u32> {+        self.child("version")+            .and_then(|v| v.atom_at(1))+            .and_then(|s| s.parse().ok())+    }+}++/// A property/field string under the document's version semantics: before+/// format 20250318 a bare `~` meant the empty string (KiCad parser:+/// `if( m_requiredVersion < 20250318 && CurStr() == "~" )`).+pub fn resolve_tilde(s: &str, doc_version: Option<u32>) -> String {+    if s == "~" && doc_version.unwrap_or(0) < 20250318 {+        String::new()+    } else {+        s.to_string()+    }+}++#[derive(Debug)]+enum Tok {+    Open,+    Close,+    Atom(String),+}++/// Tokenize with line tracking. Errors: unterminated string.+fn tokenize(text: &str) -> Result<Vec<(Tok, u32)>> {+    let mut toks = Vec::new();+    let mut it = text.chars().peekable();+    let mut line: u32 = 1;+    while let Some(&c) = it.peek() {+        if c == '\n' {+            line += 1;+            it.next();+        } else if c.is_whitespace() {+            it.next();+        } else if c == '(' {+            toks.push((Tok::Open, line));+            it.next();+        } else if c == ')' {+            toks.push((Tok::Close, line));+            it.next();+        } else if c == '"' {+            let start = line;+            it.next();+            let mut buf = String::new();+            let mut closed = false;+            while let Some(&c) = it.peek() {+                match c {+                    '"' => {+                        it.next();+                        closed = true;+                        break;+                    }+                    '\\' => {+                        it.next();+                        if let Some(&e) = it.peek() {+                            // resolve the standard escapes; pass others through+                            buf.push(match e {+                                'n' => '\n',+                                't' => '\t',+                                other => other,+                            });+                            if e == '\n' {+                                line += 1;+                            }+                            it.next();+                        }+                    }+                    _ => {+                        if c == '\n' {+                            line += 1;+                        }+                        buf.push(c);+                        it.next();+                    }+                }+            }+            if !closed {+                bail!("unterminated string starting at line {start}");+            }+            toks.push((Tok::Atom(buf), start));+        } else {+            let mut buf = String::new();+            while let Some(&c) = it.peek() {+                if c.is_whitespace() || c == '(' || c == ')' {+                    break;+                }+                buf.push(c);+                it.next();+            }+            toks.push((Tok::Atom(buf), line));+        }+    }+    Ok(toks)+}++/// Parse one list starting at toks[*pos] (which must be Open). Strict: a+/// missing close paren is an error, not an implicit EOF-close.+fn parse_list(toks: &[(Tok, u32)], pos: &mut usize) -> Result<Sx> {+    let open_line = toks[*pos].1;+    *pos += 1; // consume '('+    let mut list = Vec::new();+    loop {+        match toks.get(*pos) {+            Some((Tok::Open, _)) => list.push(parse_list(toks, pos)?),+            Some((Tok::Close, _)) => {+                *pos += 1;+                return Ok(Sx::List(list));+            }+            Some((Tok::Atom(a), _)) => {+                list.push(Sx::Atom(a.clone()));+                *pos += 1;+            }+            None => bail!("unbalanced s-expression: '(' at line {open_line} is never closed"),+        }+    }+}++/// Parse a document into its single root list. Strict about structure+/// (unbalanced/unterminated input errors with a line number); tolerant about+/// leading junk before the first '(' (some exporters emit a BOM/comment).+pub fn parse_root(text: &str) -> Result<Sx> {+    let toks = tokenize(text)?;+    let Some(start) = toks.iter().position(|(t, _)| matches!(t, Tok::Open)) else {+        bail!("no s-expression found");+    };+    let mut pos = start;+    let root = parse_list(&toks, &mut pos)?;+    // Anything after the root except stray whitespace is a malformed file —+    // report it instead of silently ignoring half the document.+    if let Some((tok, line)) = toks.get(pos) {+        let what = match tok {+            Tok::Open => "'('".to_string(),+            Tok::Close => "')'".to_string(),+            Tok::Atom(a) => format!("{a:?}"),+        };+        bail!("trailing content after the root s-expression: {what} at line {line}");+    }+    Ok(root)+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn strict_errors_carry_lines() {+        let e = parse_root("(a\n(b c)\n").unwrap_err().to_string();+        assert!(e.contains("line 1"), "unclosed root cites its open line: {e}");+        let e = parse_root("(a \"unterminated\n").unwrap_err().to_string();+        assert!(e.contains("line 1"), "unterminated string cites its line: {e}");+        let e = parse_root("(a) (b)").unwrap_err().to_string();+        assert!(e.contains("trailing"), "second root is trailing content: {e}");+    }++    #[test]+    fn tilde_gate_matches_kicad() {+        assert_eq!(resolve_tilde("~", Some(20211014)), "");+        assert_eq!(resolve_tilde("~", Some(20231120)), "");+        assert_eq!(resolve_tilde("~", Some(20251024)), "~"); // literal in new files+        assert_eq!(resolve_tilde("~", None), ""); // no version → legacy file+        assert_eq!(resolve_tilde("A~B", Some(20211014)), "A~B");+    }++    #[test]+    fn doc_version_reads_header() {+        let r = parse_root("(kicad_symbol_lib (version 20231120) (generator \"x\"))").unwrap();+        assert_eq!(r.doc_version(), Some(20231120));+    }+}