app
Adom Project Manager
Public Made by Adomby adom
Adom Project Manager — the central hub for navigating wiki boards, molecules and libraries into Hydrogen: browse your wiki boards/projects (Personal / Organization / Public), view symbol + footprint + 3D and the per-EDA footprint layer stackup, explore the board and schematic in INTERACTIVE cross-probing viewers (click a net, hover a part for stock and price), add components to your KiCad/Altium libraries, and deliver self-contained KiCad projects. Built on adom-lbr. (Formerly Adom Project Manag
← 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
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));+ }+}