app
Adom Library
Public Made by Adomby adom
adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.
← Commit history
Manager: board-centric nav, categorization, Add-to-Library + Add-Project (self-contained), my/public projects, filename discovery, IC pin labels; codec robustness (sexpr, plated pads, tests)
21 files changed
+1959−1334
Cargo.toml+1−1VERSION+1−1crates/altium-codec/src/altium_decode.rs+8crates/altium-codec/src/intlib.rs+7−1crates/altium-codec/src/kicad.rs+48−142crates/altium-codec/src/kicad_export.rs+144−18crates/altium-codec/src/lib.rs+96−2crates/altium-codec/src/main.rs+8−2crates/altium-codec/src/model.rs+8crates/altium-codec/src/pcblib.rs+9−6crates/altium-codec/src/schlib.rs+124−43crates/altium-codec/src/sexpr.rs+252crates/altium-pcblib/src/lib.rs+46−28crates/altium-pcblib/tests/fixtures/ADS1220IRVAT.PcbLibcrates/altium-schlib/src/lib.rs+10−2package.json+3−7src/main.rs+125−577src/manage.html+350−129src/manage.rs+565−371src/selfcontain.py+140src/server.rs+14−4Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.5.0"+version = "0.3.0" edition = "2021" description = "adom-lbr generator / EDA translator: the canonical adom-lbr JSON is the hub, with native converters to Altium (.SchLib/.PcbLib/.IntLib), EAGLE/Fusion (.lbr), and KiCad. Altium output is written natively — no Altium needed at conversion time."
VERSION+1−1@@ -1 +1 @@-2.5.0+0.3.0
crates/altium-codec/src/altium_decode.rs+8@@ -429,6 +429,11 @@ fn decode_pcblib_footprint(bytes: &[u8]) -> Result<Footprint> { Ok(Footprint { name: fp_name, pads, graphics }) } +/// Altium layer byte → neutral role. Counterpart of `pcblib::layer_enc`.+/// NOTE: the house convention encodes BOTH Silk and Fab onto Mech13 (69), so+/// 69 canonically decodes to Fab — silk art merged there on encode stays Fab+/// after a round trip (deliberate; see the doc on `layer_enc`). Vendor libs+/// (UL/SamacSys) put silk on TopOverlay (33), which decodes to Silk correctly. fn altium_layer(byte: u8) -> FpLayer { match byte { 33 => FpLayer::Silk,@@ -467,6 +472,9 @@ fn decode_pad(blocks: &[&[u8]]) -> Option<Pad> { drill_mm: hole, rotation, shape: shape.to_string(),+ // The plated flag byte in the Altium pad record isn't calibrated yet —+ // unknown, so leave None (treated as plated downstream).+ plated: None, }) }
crates/altium-codec/src/intlib.rs+7−1@@ -134,8 +134,14 @@ pub fn extract_intlib_libs(bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> { let inflate = |comp: &mut cfb::CompoundFile<Cursor<Vec<u8>>>, path: &str| -> Result<Vec<u8>> { let mut raw = Vec::new(); comp.open_stream(path)?.read_to_end(&mut raw)?;+ // Payload is a 0x02 marker byte + zlib stream — verify, don't assume.+ match raw.first() {+ None => anyhow::bail!("{path}: empty embedded stream"),+ Some(0x02) => {}+ Some(b) => anyhow::bail!("{path}: unexpected marker byte {b:#04x} (expected 0x02)"),+ } let mut out = Vec::new();- ZlibDecoder::new(&raw[1..]).read_to_end(&mut out)?; // skip 0x02 marker+ ZlibDecoder::new(&raw[1..]).read_to_end(&mut out).with_context(|| format!("{path}: inflate"))?; Ok(out) }; let sch = inflate(&mut comp, "/SchLib/0.schlib")?;
crates/altium-codec/src/kicad.rs+48−142@@ -13,153 +13,51 @@ use anyhow::{Context, Result}; const MM_PER_MIL: f64 = 0.0254; -// ----------------------------------------------------------------------------// Minimal s-expression parser-// ---------------------------------------------------------------------------+// 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}; -#[derive(Debug)]-enum Sx {- Atom(String),- List(Vec<Sx>),-}--impl Sx {- fn head(&self) -> Option<&str> {- match self {- Sx::List(v) => match v.first() {- Some(Sx::Atom(a)) => Some(a.as_str()),- _ => None,- },- _ => None,- }- }- fn items(&self) -> &[Sx] {- match self {- Sx::List(v) => v,- _ => &[],- }- }- /// First child list whose head == name.- fn child(&self, name: &str) -> Option<&Sx> {- self.items().iter().find(|s| s.head() == Some(name))- }- /// Atom string at position i (after the head), if it's an atom.- 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).- 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.- 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);- }- }- }-}--fn tokenize(s: &str) -> Vec<String> {- let mut toks = Vec::new();- let mut it = s.chars().peekable();- while let Some(&c) = it.peek() {- if c.is_whitespace() {- it.next();- } else if c == '(' || c == ')' {- toks.push(c.to_string());- it.next();- } else if c == '"' {- it.next();- let mut buf = String::from("\u{0}"); // sentinel marks "was a string"- while let Some(&c) = it.peek() {- if c == '"' {- it.next();- break;- } else if c == '\\' {- it.next();- if let Some(&e) = it.peek() {- buf.push(e);- it.next();- }- } else {- buf.push(c);- it.next();- }- }- toks.push(buf);- } 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(buf);- }- }- toks-}--fn parse(toks: &[String], pos: &mut usize) -> Sx {- // expects toks[*pos] == "("- *pos += 1; // consume "("- let mut list = Vec::new();- while *pos < toks.len() {- let t = &toks[*pos];- if t == "(" {- list.push(parse(toks, pos));- } else if t == ")" {- *pos += 1;- break;- } else {- // strip the string sentinel if present- let a = t.strip_prefix('\u{0}').unwrap_or(t).to_string();- list.push(Sx::Atom(a));- *pos += 1;- }- }- Sx::List(list)-}--fn parse_root(text: &str) -> Result<Sx> {- let toks = tokenize(text);- let start = toks.iter().position(|t| t == "(").context("no s-expr")?;- let mut pos = start;- Ok(parse(&toks, &mut pos))-} // --------------------------------------------------------------------------- // Symbol import // --------------------------------------------------------------------------- -fn import_symbol(text: &str) -> Result<Symbol> {+fn import_symbol(text: &str, wanted: &str) -> Result<Symbol> { let root = parse_root(text)?;- let sym = root- .child("symbol")- .context("no (symbol ...) in kicad_sym")?;+ // 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+ // designator prefix from the Reference property — of THIS symbol only let mut prefix = "U".to_string(); let mut props = Vec::new();- root.collect("property", &mut props);+ sym.collect("property", &mut props); for p in &props { if p.atom_at(1) == Some("Reference") { if let Some(r) = p.atom_at(2) {@@ -184,11 +82,13 @@ fn import_symbol(text: &str) -> Result<Symbol> { .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))- .unwrap_or("~")- .to_string();+ .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")@@ -221,7 +121,7 @@ fn import_symbol(text: &str) -> Result<Symbol> { name, pins, body: None,- graphics: import_symbol_graphics(&root),+ graphics: import_symbol_graphics(sym), designator_prefix: Some(prefix), footprint: None, parameters: Vec::new(),@@ -385,9 +285,11 @@ fn import_footprint(text: &str) -> Result<Footprint> { 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")- .and_then(|d| d.nums().first().copied())+ .map(|d| d.nums().into_iter().fold(0.0_f64, f64::max)) .unwrap_or(0.0); let kshape = match shape.as_str() { "circle" => "circle",@@ -395,15 +297,19 @@ fn import_footprint(text: &str) -> Result<Footprint> { "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 ptype == "thru_hole" { drill } else { 0.0 },+ 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 }, }); } @@ -505,7 +411,7 @@ fn import_footprint_graphics(root: &Sx) -> Vec<FpGraphic> { /// 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)?;+ 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).
crates/altium-codec/src/kicad_export.rs+144−18@@ -7,6 +7,92 @@ 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}");@@ -18,8 +104,14 @@ fn n(v: f64) -> String { // Symbol → .kicad_sym // --------------------------------------------------------------------------- -/// Export a symbol as a complete `.kicad_sym` library (one symbol).+/// 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());@@ -36,7 +128,7 @@ pub fn export_kicad_symbol(sym: &Symbol) -> String { let (top, bottom, cx) = symbol_bbox_mm(sym); let mut s = String::new();- s.push_str("(kicad_symbol_lib\n\t(version 20231120)\n\t(generator \"adom-lbr\")\n");+ 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@@ -44,20 +136,24 @@ pub fn export_kicad_symbol(sym: &Symbol) -> String { 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);- s.push_str(if hide_num {- "\t\t(pin_numbers (hide yes))\n"+ let hide = ver.hide_flag();+ s.push_str(&if hide_num {+ format!("\t\t(pin_numbers{hide})\n") } else {- "\t\t(pin_numbers)\n"+ "\t\t(pin_numbers)\n".to_string() });- s.push_str(if hide_name {- "\t\t(pin_names (offset 0.254) (hide yes))\n"+ 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"+ "\t\t(pin_names (offset 0.254))\n".to_string() });- s.push_str("\t\t(exclude_from_sim no)\n\t\t(in_bom yes)\n\t\t(on_board yes)\n");+ 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 yes)" } else { "" };+ 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)@@ -66,7 +162,8 @@ pub fn export_kicad_symbol(sym: &Symbol) -> String { 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));- s.push_str(&prop("Datasheet", "~", 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") {@@ -87,12 +184,15 @@ pub fn export_kicad_symbol(sym: &Symbol) -> String { 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(&pin.name), esc(&pin.number)+ esc(pname), esc(&pin.number) )); } s.push_str("\t\t)\n\t)\n)\n");@@ -141,12 +241,27 @@ fn sym_graphic(g: &Graphic, m: &dyn Fn(f64) -> String) -> String { // Footprint → .kicad_mod // --------------------------------------------------------------------------- -/// Export a footprint as a `.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)));- s.push_str("\t(version 20240108)\n\t(generator \"adom-lbr\")\n\t(layer \"F.Cu\")\n");+ 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@@ -157,7 +272,11 @@ pub fn export_kicad_footprint(fp: &Footprint) -> String { // pads for pad in &fp.pads { let thru = pad.drill_mm > 0.0;- let (ptype, layers) = if thru {+ 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\"")@@ -231,9 +350,16 @@ fn fp_graphic(g: &FpGraphic) -> String { /// 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)) {- let mut sweep = end - start;- while sweep <= 0.0 {- sweep += 360.0;+ // 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| {
crates/altium-codec/src/lib.rs+96−2@@ -14,6 +14,7 @@ pub mod kicad_export; pub mod model; pub mod pcblib; pub mod schlib;+pub mod sexpr; pub use altium_decode::{decode_altium, decode_intlib_part}; pub use intlib::{decode_intlib, encode_intlib};@@ -35,6 +36,8 @@ mod tests { body: None, graphics: vec![], parameters: vec![],+ pin_numbers_hidden: None,+ pin_names_hidden: None, // pins in mils (connection point); length 100, angle 0 → Altium // stores the body end = -100 + 100 = 0. pins: vec![@@ -62,13 +65,104 @@ mod tests { assert_eq!(pins[0]["y"], 10); } + /// Pins the house layer convention: Silk and Fab both encode onto Mech13+ /// (69) and decode back as Fab (deliberately lossy — see+ /// `pcblib::layer_enc`); Courtyard and Copper round-trip exactly.+ #[test]+ fn layer_roundtrip_house_convention() {+ use model::{FpGraphic, FpLayer};+ let line = |layer: FpLayer, y: f64| FpGraphic::Line { layer, x1: -1.0, y1: y, x2: 1.0, y2: y, width: 0.1 };+ let fp = Footprint {+ name: "LAYERFP".into(),+ pads: vec![Pad { number: "1".into(), x_mm: 0.0, y_mm: 0.0, w_mm: 1.0, h_mm: 1.0, drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None }],+ graphics: vec![+ line(FpLayer::Silk, 1.0),+ line(FpLayer::Fab, 2.0),+ line(FpLayer::Courtyard, 3.0),+ line(FpLayer::Copper, 4.0),+ ],+ };+ let bytes = encode_pcblib(&fp).expect("encode");+ let part = decode_altium(None, Some(&bytes), "LAYERFP").expect("decode");+ let layer_of = |y_want: f64| {+ part.footprint.graphics.iter().find_map(|g| match g {+ model::FpGraphic::Line { layer, y1, .. } if (y1 - y_want).abs() < 1e-3 => Some(layer.clone()),+ _ => None,+ })+ };+ assert!(matches!(layer_of(1.0), Some(FpLayer::Fab)), "silk merges onto Mech13 → decodes as Fab (house convention)");+ assert!(matches!(layer_of(2.0), Some(FpLayer::Fab)), "fab round-trips");+ assert!(matches!(layer_of(3.0), Some(FpLayer::Courtyard)), "courtyard round-trips");+ assert!(matches!(layer_of(4.0), Some(FpLayer::Copper)), "copper round-trips");+ }++ /// The `|KEY=VALUE` text records have no escape mechanism — encoding must+ /// REFUSE values that would desync the stream, not silently corrupt it.+ #[test]+ fn schlib_rejects_corrupting_inputs() {+ // '|' in a parameter value+ let mut s = two_pin_symbol();+ s.parameters.push(Parameter { name: "Value".into(), value: "10k|1%".into(), visible: true });+ assert!(encode_schlib(&[s]).is_err(), "pipe in parameter value must be rejected");++ // '|' in the symbol name+ let mut s = two_pin_symbol();+ s.name = "BAD|NAME".into();+ assert!(encode_schlib(&[s]).is_err(), "pipe in symbol name must be rejected");++ // pin longer than the u16 length field can hold (max 2559 mil)+ let mut s = two_pin_symbol();+ s.pins[0].length = Some(3000);+ assert!(encode_schlib(&[s]).is_err(), "oversize pin length must be rejected");+ }++ /// Storage name, FileHeader LibRef, and RECORD=1 LibReference must agree —+ /// and a name like "LM358.A" must keep its dot (CFBF allows it).+ #[test]+ fn schlib_dotted_name_stays_resolvable() {+ let mut s = two_pin_symbol();+ s.name = "LM358.A".into();+ let bytes = encode_schlib(&[s]).expect("encode");+ let decoded = decode_schlib_first(&bytes).expect("decode");+ assert_eq!(decoded["module_name"], "LM358.A");+ // merging the same symbol again is a no-op (dedup on the storage name)+ let mut again = two_pin_symbol();+ again.name = "LM358.A".into();+ let merged = add_symbols_to_schlib(&bytes, &[again]).expect("merge");+ assert_eq!(merged, bytes, "re-adding an existing symbol must not change the library");+ }++ /// NPTH mounting holes must survive KiCad import → export: keep the drill+ /// (not become an SMD copper pad) and stay `np_thru_hole`.+ #[test]+ fn npth_pad_roundtrips_through_kicad() {+ let sym = "(kicad_symbol_lib (symbol \"M3\" (pin passive line (at 0 0 0) (length 2.54) (name \"1\") (number \"1\"))))";+ let fpt = concat!(+ "(footprint \"MountM3\" (layer \"F.Cu\")\n",+ " (pad \"\" np_thru_hole circle (at 0 0) (size 3.2 3.2) (drill 3.2) (layers \"*.Cu\" \"*.Mask\"))\n",+ " (pad \"1\" thru_hole circle (at 5 0) (size 1.7 1.7) (drill oval 1.0 1.6) (layers \"*.Cu\" \"*.Mask\"))\n",+ ")"+ );+ let part = import_kicad(sym, fpt, "M3").expect("import");+ let npth = &part.footprint.pads[0];+ assert!(npth.drill_mm > 3.1, "NPTH drill preserved, got {}", npth.drill_mm);+ assert_eq!(npth.plated, Some(false));+ let th = &part.footprint.pads[1];+ assert_eq!(th.plated, Some(true));+ assert!((th.drill_mm - 1.6).abs() < 1e-9, "slot drill takes the larger axis, got {}", th.drill_mm);++ let out = export_kicad_footprint(&part.footprint);+ assert!(out.contains("np_thru_hole"), "export keeps np_thru_hole:\n{out}");+ assert!(out.contains("(drill 3.2)"), "export keeps the NPTH drill:\n{out}");+ }+ #[test] fn pcblib_roundtrip_pads() { let fp = Footprint { name: "REFFP".into(), pads: vec![- Pad { number: "1".into(), x_mm: -0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into() },- Pad { number: "2".into(), x_mm: 0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into() },+ Pad { number: "1".into(), x_mm: -0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None },+ Pad { number: "2".into(), x_mm: 0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None }, ], graphics: vec![], };
crates/altium-codec/src/main.rs+8−2@@ -75,8 +75,11 @@ fn main() -> Result<()> { } // kicad sym+mod → universal part.json (the import / hub step) "import-kicad" => {+ let md_path = args+ .get(3)+ .ok_or_else(|| anyhow::anyhow!("usage: altium-codec import-kicad <in.kicad_sym> <in.kicad_mod> [mpn]"))?; let sym = fs::read_to_string(&args[2]).context("read kicad_sym")?;- let md = fs::read_to_string(&args[3]).context("read kicad_mod")?;+ let md = fs::read_to_string(md_path).context("read kicad_mod")?; let mpn = args.get(4).cloned().unwrap_or_else(|| "PART".into()); let part = import_kicad(&sym, &md, &mpn)?; let json = serde_json::to_string_pretty(&part)?;@@ -119,8 +122,11 @@ fn main() -> Result<()> { // Add a part's symbol into an EXISTING .SchLib (non-destructive merge): // altium-codec add-to-schlib <existing.SchLib> <part.json> <out.SchLib> "add-to-schlib" => {+ let json_path = args+ .get(3)+ .ok_or_else(|| anyhow::anyhow!("usage: altium-codec add-to-schlib <existing.SchLib> <part.json> [out.SchLib]"))?; let existing = fs::read(&args[2]).context("read existing SchLib")?;- let json = fs::read_to_string(&args[3]).context("read part.json")?;+ let json = fs::read_to_string(json_path).context("read part.json")?; // Accept an AdomLbrPart, a bare Symbol, or an array of Symbols. let syms: Vec<Symbol> = if let Ok(p) = serde_json::from_str::<altium_codec::AdomLbrPart>(&json)
crates/altium-codec/src/model.rs+8@@ -238,6 +238,14 @@ pub struct Pad { /// "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 {
crates/altium-codec/src/pcblib.rs+9−6@@ -37,14 +37,17 @@ const TEMPLATE_COMPONENTS: [&str; 2] = ["REFFP", "PCBComponent_1"]; /// class [43])`. Altium honours the tail pair `[41]/[43]` (not just `[0]`), so /// both must be set or the primitive lands on whatever the template layer was. ///-/// Mapping (barrett's house convention): silk → Top Overlay; the component-/// outline (fab) → a Mechanical layer; courtyard → the Keep-Out layer.+/// Mapping (barrett's house convention, from his C0402): the part outline+/// lives on Mechanical 13 ("Assembly Top") and the courtyard on Mechanical 15+/// ("Courtyard Top") — nothing on Top Overlay.+///+/// ⚠ DELIBERATELY LOSSY: Silk and Fab both encode onto Mech13 (one layer byte+/// can't carry two roles), and the decoder ([`altium_decode::altium_layer`])+/// canonically maps 69 back to Fab. So a KiCad→Altium→KiCad round trip moves+/// F.SilkS art onto F.Fab. That is the house convention, not drift — the+/// `layer_roundtrip_house_convention` test pins it. Change both sites together. fn layer_enc(l: &FpLayer) -> (u8, u8, u8) { match l {- // Barrett's house convention (from his C0402): the part outline lives on- // Mechanical 13 ("Assembly Top") and the courtyard on Mechanical 15- // ("Courtyard Top") — nothing on Top Overlay. KiCad silk + fab both map- // to the assembly outline layer. FpLayer::Silk => (69, 13, 2), // Mechanical 13 (Assembly Top) FpLayer::Fab => (69, 13, 2), // Mechanical 13 (Assembly Top) FpLayer::Courtyard => (71, 15, 2), // Mechanical 15 (Courtyard Top)
crates/altium-codec/src/schlib.rs+124−43@@ -49,11 +49,21 @@ const UNITS_PER_MIL: f64 = 25.6; /// Encode one or more symbols into a `.SchLib` byte image. pub fn encode_schlib(symbols: &[Symbol]) -> Result<Vec<u8>> {+ validate_symbols(symbols)?; let cursor = Cursor::new(Vec::<u8>::new()); let mut comp = cfb::CompoundFile::create(cursor).context("create CFBF")?; + // The CFBF storage name, the FileHeader LibRef entry, and the RECORD=1+ // LibReference must all agree, or Altium can't resolve the component —+ // compute each symbol's storage name once and use it in all three.+ let named: Vec<(String, &Symbol)> = symbols+ .iter()+ .enumerate()+ .map(|(i, s)| (sanitize_storage_name(&s.name, i), s))+ .collect();+ // FileHeader — library index listing every component.- let fh = build_file_header(symbols);+ let fh = build_file_header(&named); write_root_stream(&mut comp, "FileHeader", &fh)?; // Storage — fixed "Icon storage" housekeeping stream.@@ -61,11 +71,10 @@ pub fn encode_schlib(symbols: &[Symbol]) -> Result<Vec<u8>> { write_root_stream(&mut comp, "Storage", &storage)?; // One storage per component, each holding a Data record stream.- for (idx, sym) in symbols.iter().enumerate() {- let store_name = sanitize_storage_name(&sym.name, idx);+ for (store_name, sym) in &named { comp.create_storage(&format!("/{store_name}")) .with_context(|| format!("create storage /{store_name}"))?;- let data = build_component_data(sym);+ let data = build_component_data(sym, store_name)?; let mut s = comp .create_stream(&format!("/{store_name}/Data")) .with_context(|| format!("create /{store_name}/Data"))?;@@ -111,10 +120,15 @@ pub fn add_symbols_to_schlib(existing: &[u8], symbols: &[Symbol]) -> Result<Vec< } let comp_count_existing = existing_comps.len(); - // Skip symbols whose name already exists.- let to_add: Vec<&Symbol> = symbols+ validate_symbols(symbols)?;+ // Skip symbols already present. Compare SANITIZED names — the stored+ // storages are sanitized, so comparing the raw name would let e.g.+ // "LM358.A" slip past its own existing "LM358_A" storage and collide.+ let to_add: Vec<(String, &Symbol)> = symbols .iter()- .filter(|s| !existing_comps.contains(&s.name))+ .enumerate()+ .map(|(i, s)| (sanitize_storage_name(&s.name, comp_count_existing + i), s))+ .filter(|(store, _)| !existing_comps.contains(store)) .collect(); if to_add.is_empty() { // Nothing new — return the original unchanged.@@ -138,9 +152,8 @@ pub fn add_symbols_to_schlib(existing: &[u8], symbols: &[Symbol]) -> Result<Vec< let data: &[u8] = if p == "/FileHeader" { &patched_fh } else { bytes }; write_stream_deep(&mut dst, p, data, &mut made)?; }- for (k, sym) in to_add.iter().enumerate() {- let store = sanitize_storage_name(&sym.name, comp_count_existing + k);- let data = build_component_data(sym);+ for (store, sym) in &to_add {+ let data = build_component_data(sym, store)?; write_stream_deep(&mut dst, &format!("/{store}/Data"), &data, &mut made)?; } dst.flush()?;@@ -150,7 +163,7 @@ pub fn add_symbols_to_schlib(existing: &[u8], symbols: &[Symbol]) -> Result<Vec< /// Patch a SchLib FileHeader text record: bump `CompCount` by `add.len()` and /// append a `|LibRef{i}=..|PartCount{i}=1` entry per added symbol (indices /// continue after the existing `old_n` components).-fn patch_file_header_add(fh: &[u8], old_n: usize, add: &[&Symbol]) -> Result<Vec<u8>> {+fn patch_file_header_add(fh: &[u8], old_n: usize, add: &[(String, &Symbol)]) -> Result<Vec<u8>> { if fh.len() < 5 { bail!("FileHeader too short"); }@@ -174,10 +187,11 @@ fn patch_file_header_add(fh: &[u8], old_n: usize, add: &[&Symbol]) -> Result<Vec new_text.push_str(&new_n.to_string()); new_text.push_str(&text[val_start + digits.len()..]); - // Append the new component index entries.- for (k, sym) in add.iter().enumerate() {+ // Append the new component index entries — LibRef = the SANITIZED storage+ // name, so Altium can resolve the component to its storage.+ for (k, (store, _)) in add.iter().enumerate() { let i = old_n + k;- new_text.push_str(&format!("|LibRef{i}={}|PartCount{i}=1", sym.name));+ new_text.push_str(&format!("|LibRef{i}={store}|PartCount{i}=1")); } Ok(u32_text_record(&new_text)) }@@ -220,6 +234,49 @@ fn write_root_stream<F: std::io::Read + std::io::Write + std::io::Seek>( Ok(()) } +/// Validate symbols before encoding. Text records are `|KEY=VALUE` streams+/// with NO escape mechanism — a `|` inside a name/value desyncs the whole+/// record — and several binary fields are narrow. Refuse loudly rather than+/// emit a corrupt library.+fn validate_symbols(symbols: &[Symbol]) -> Result<()> {+ /// Max pin length: the length field is u16 with orientation in bits 0..1,+ /// so length_units ≤ 0xFFFC → 0xFFFC / 25.6 = 2559 mil.+ const MAX_PIN_LEN_MIL: i32 = 2559;+ for sym in symbols {+ let no_pipe = |what: &str, s: &str| -> Result<()> {+ if s.contains('|') {+ bail!("symbol {:?}: {what} {s:?} contains '|', which corrupts Altium text records — rename it", sym.name);+ }+ Ok(())+ };+ no_pipe("name", &sym.name)?;+ if let Some(p) = &sym.designator_prefix {+ no_pipe("designator prefix", p)?;+ }+ if let Some(f) = &sym.footprint {+ no_pipe("footprint name", f)?;+ }+ for param in &sym.parameters {+ no_pipe("parameter name", ¶m.name)?;+ no_pipe("parameter value", ¶m.value)?;+ if param.name.contains('=') {+ bail!("symbol {:?}: parameter name {:?} contains '=', which corrupts Altium text records", sym.name, param.name);+ }+ }+ for pin in &sym.pins {+ if pin.name.len() > 255 || pin.number.len() > 255 {+ bail!("symbol {:?}: pin name/number longer than 255 bytes (pin {:?})", sym.name, pin.number);+ }+ if let Some(l) = pin.length {+ if !(0..=MAX_PIN_LEN_MIL).contains(&l) {+ bail!("symbol {:?}: pin {:?} length {l} mil out of range 0..={MAX_PIN_LEN_MIL} (Altium field limit)", sym.name, pin.number);+ }+ }+ }+ }+ Ok(())+}+ /// `<u32 len>` + text + `\0`. Used for FileHeader and Storage streams. fn u32_text_record(text: &str) -> Vec<u8> { let mut payload = text.as_bytes().to_vec();@@ -230,16 +287,21 @@ fn u32_text_record(text: &str) -> Vec<u8> { } /// `<u16 len><u16 flags>` + text + `\0`. The component-data text record framing.-fn u16_text_record(text: &str) -> Vec<u8> {+/// Errors instead of silently wrapping the u16 frame length (a >64 KiB record —+/// e.g. a polyline with thousands of points — would desync the whole stream).+fn u16_text_record(text: &str) -> Result<Vec<u8>> { let mut payload = text.as_bytes().to_vec(); payload.push(0);+ if payload.len() > u16::MAX as usize {+ bail!("text record too large for u16 framing ({} bytes) — simplify the symbol graphics", payload.len());+ } let mut out = (payload.len() as u16).to_le_bytes().to_vec(); out.extend_from_slice(&0u16.to_le_bytes()); // flags = 0 out.extend_from_slice(&payload);- out+ Ok(out) } -fn build_file_header(symbols: &[Symbol]) -> Vec<u8> {+fn build_file_header(symbols: &[(String, &Symbol)]) -> Vec<u8> { let mut s = String::from( "|HEADER=Protel for Windows - Schematic Library Editor Binary File Version 5.0\ |Weight=0|MinorVersion=9|UniqueID=ADOMSFHX|FontIdCount=1|Size1=10\@@ -249,14 +311,17 @@ fn build_file_header(symbols: &[Symbol]) -> Vec<u8> { |ReferenceZonesOn=T|Display_Unit=0", ); s.push_str(&format!("|CompCount={}", symbols.len()));- for (i, sym) in symbols.iter().enumerate() {+ for (i, (store_name, _)) in symbols.iter().enumerate() { let parts = 1; // single-part symbols for now- s.push_str(&format!("|LibRef{i}={}|PartCount{i}={}", sym.name, parts));+ // LibRef must equal the storage name (and RECORD=1 LibReference).+ s.push_str(&format!("|LibRef{i}={store_name}|PartCount{i}={parts}")); } u32_text_record(&s) } -fn build_component_data(sym: &Symbol) -> Vec<u8> {+/// `lib_ref` is the storage name — LibReference, the FileHeader LibRef entry,+/// and the CFBF storage MUST all agree or Altium can't resolve the component.+fn build_component_data(sym: &Symbol, lib_ref: &str) -> Result<Vec<u8>> { let mut out = Vec::new(); let pin_count = sym.pins.len(); let body = sym.body.unwrap_or_default();@@ -265,12 +330,11 @@ fn build_component_data(sym: &Symbol) -> Vec<u8> { // RECORD=1 — component header. out.extend(u16_text_record(&format!(- "|RECORD=1|LibReference={name}|PartCount=1|DisplayModeCount=1|IndexInSheet=-1\+ "|RECORD=1|LibReference={lib_ref}|PartCount=1|DisplayModeCount=1|IndexInSheet=-1\ |OwnerPartId=-1|CurrentPartId=1|LibraryPath=*|SourceLibraryName=*|SheetPartFileName=*\ |TargetFileName=*|UniqueID={uid}|AreaColor=11599871|Color=128|PartIDLocked=T\ |AllPinCount={pin_count}",- name = sym.name,- )));+ ))?); // Body: emit the symbol's own graphics if it has any; otherwise a default // body rectangle. Either way these are the records between header and pins.@@ -283,16 +347,16 @@ fn build_component_data(sym: &Symbol) -> Vec<u8> { x2 = body.x2 / 10, y2 = body.y2 / 10, uid2 = pseudo_uid(&sym.name, 14),- )));+ ))?); } else { for (gi, g) in sym.graphics.iter().enumerate() {- out.extend(emit_graphic(g, &sym.name, gi));+ out.extend(emit_graphic(g, &sym.name, gi)?); } } // Pin records (binary). for (i, pin) in sym.pins.iter().enumerate() {- out.extend(frame_pin(encode_pin_payload(pin, i)));+ out.extend(frame_pin(encode_pin_payload(pin, i)?)); } // RECORD=34 — Designator label.@@ -300,14 +364,14 @@ fn build_component_data(sym: &Symbol) -> Vec<u8> { "|RECORD=34|IndexInSheet=-1|OwnerPartId=-1|Location.X=-5|Location.Y=5|Color=8388608\ |FontID=1|Text={prefix}?|Name=Designator|ReadOnlyState=1|UniqueID={uid3}", uid3 = pseudo_uid(&sym.name, 34),- )));+ ))?); // RECORD=41 — Comment label. out.extend(u16_text_record(&format!( "|RECORD=41|IndexInSheet=-1|OwnerPartId=-1|Location.X=-5|Location.Y=-15|Color=8388608\ |FontID=1|Text=*|Name=Comment|UniqueID={uid4}", uid4 = pseudo_uid(&sym.name, 41),- )));+ ))?); // RECORD=41 — component property fields (Value, Manufacturer, MPN, …). Each // is another parameter record; visible ones are drawn (stacked below the@@ -328,14 +392,14 @@ fn build_component_data(sym: &Symbol) -> Vec<u8> { t = param.value, n = param.name ) };- out.extend(u16_text_record(&rec));+ out.extend(u16_text_record(&rec)?); } // RECORD=44 — implementations list. Then, if the symbol names a footprint, // the implementation that links it: RECORD=45 (the PCBLIB model reference) // plus its RECORD=46/48 children. OwnerIndex values are record positions in // this stream: R1=0, R14=1, pins=2..(pin_count+1), R34, R41, params.., R44…- out.extend(u16_text_record("|RECORD=44"));+ out.extend(u16_text_record("|RECORD=44")?); if let Some(fp) = sym.footprint.as_deref().filter(|s| !s.is_empty()) { // record positions: R1=0, body records, pins, R34, R41-comment, params, R44. let body_recs = if sym.graphics.is_empty() { 1 } else { sym.graphics.len() };@@ -347,14 +411,14 @@ fn build_component_data(sym: &Symbol) -> Vec<u8> { "|RECORD=45|OwnerIndex={idx_44}|IndexInSheet=-1|ModelName={fp}|ModelType=PCBLIB\ |DatafileCount=1|ModelDatafileEntity0={fp}|ModelDatafileKind0=PCBLIB|IsCurrent=T|UniqueID={uid45}", uid45 = pseudo_uid(&sym.name, 45),- )));+ ))?); // RECORD=46 / 48 — implementation children (empty → implicit identity // pin↔pad map, valid whenever pin numbers equal pad numbers).- out.extend(u16_text_record(&format!("|RECORD=46|OwnerIndex={idx_45}")));- out.extend(u16_text_record(&format!("|RECORD=48|OwnerIndex={idx_45}")));+ out.extend(u16_text_record(&format!("|RECORD=46|OwnerIndex={idx_45}"))?);+ out.extend(u16_text_record(&format!("|RECORD=48|OwnerIndex={idx_45}"))?); } - out+ Ok(out) } /// Split a mil value into Altium schematic form: a base in 10-mil units plus a@@ -387,7 +451,7 @@ fn coord_field(key: &str, mil: f64) -> String { /// Emit one symbol graphic as its Altium record, with sub-10-mil precision via /// `_Frac` fields. Polyline→RECORD=6, Rect→RECORD=14, Circle→RECORD=11, /// Arc→RECORD=12.-fn emit_graphic(g: &Graphic, name: &str, gi: usize) -> Vec<u8> {+fn emit_graphic(g: &Graphic, name: &str, gi: usize) -> Result<Vec<u8>> { let uid = pseudo_uid(name, 1000 + gi as u32); // Altium standard schematic-symbol line color for passives = pure blue, // RGB(0,0,255) stored as R + G*256 + B*65536 = 16711680. (Was 128 = maroon.)@@ -434,7 +498,7 @@ fn emit_graphic(g: &Graphic, name: &str, gi: usize) -> Vec<u8> { } /// Build the 36+ byte binary pin payload (before framing).-fn encode_pin_payload(pin: &Pin, _idx: usize) -> Vec<u8> {+fn encode_pin_payload(pin: &Pin, _idx: usize) -> Result<Vec<u8>> { let mut p = PIN_PREFIX.to_vec(); // conglom byte[14] = 0x04 → a plain pin (no edge-symbol triangle), rotation // bits (0..1) = 0. Matches Altium's own pins (build_symbol ground truth):@@ -450,6 +514,12 @@ fn encode_pin_payload(pin: &Pin, _idx: usize) -> Vec<u8> { .length .map(|l| (l as f64 * UNITS_PER_MIL).round() as i32) .unwrap_or(DEFAULT_PIN_LEN as i32);+ // The field is u16 with orientation packed in bits 0..1 — a longer pin+ // would silently wrap BOTH the length and the orientation. validate_symbols+ // rejects these up front; this is the backstop.+ if !(0..=0xFFFC).contains(&length) {+ bail!("pin {:?} length {} units exceeds the u16 field (max 2559 mil)", pin.number, length);+ } let field = ((length as u16) & 0xFFFC) | aorient; // length in bits 2..15, orient in 0..1 p[15..17].copy_from_slice(&field.to_le_bytes()); // Altium's pin Location is the BODY end (where the pin meets the symbol),@@ -476,12 +546,12 @@ fn encode_pin_payload(pin: &Pin, _idx: usize) -> Vec<u8> { let uy = (body_y as f64 / 10.0).round() as i32 as i16; p[20..22].copy_from_slice(&uy.to_le_bytes()); // strings: Name, Designator, "", "|&|"- push_pstr(&mut p, &pin.name);- push_pstr(&mut p, &pin.number);- push_pstr(&mut p, "");- push_pstr(&mut p, "|&|");+ push_pstr(&mut p, &pin.name)?;+ push_pstr(&mut p, &pin.number)?;+ push_pstr(&mut p, "")?;+ push_pstr(&mut p, "|&|")?; p.push(0x00);- p+ Ok(p) } fn frame_pin(payload: Vec<u8>) -> Vec<u8> {@@ -491,10 +561,16 @@ fn frame_pin(payload: Vec<u8>) -> Vec<u8> { out } -fn push_pstr(buf: &mut Vec<u8>, s: &str) {+/// Pascal string: `<u8 len>` + bytes. Errors instead of silently wrapping the+/// length prefix (a >255-byte string would desync the pin record framing).+fn push_pstr(buf: &mut Vec<u8>, s: &str) -> Result<()> { let b = s.as_bytes();+ if b.len() > 255 {+ bail!("string too long for a pascal-string field ({} bytes, max 255): {:?}…", b.len(), &s[..32.min(s.len())]);+ } buf.push(b.len() as u8); buf.extend_from_slice(b);+ Ok(()) } /// Altium UniqueIDs are 8 uppercase letters. Generate a stable pseudo-id from@@ -514,10 +590,15 @@ fn pseudo_uid(name: &str, salt: u32) -> String { out } +/// CFBF storage name for a component. Only characters CFBF actually forbids+/// are replaced ('/', '\', ':', '!', controls), so names like "LM358.A" keep+/// their real spelling — important because this name doubles as the LibRef /+/// LibReference Altium resolves by. OLE2 names max out at 31 UTF-16 units. fn sanitize_storage_name(name: &str, idx: usize) -> String { let cleaned: String = name .chars()- .map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' })+ .map(|c| if matches!(c, '/' | '\\' | ':' | '!') || (c as u32) < 0x20 { '_' } else { c })+ .take(31) .collect(); if cleaned.is_empty() { format!("Component_{idx}")
crates/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));+ }+}
crates/altium-pcblib/src/lib.rs+46−28@@ -296,7 +296,8 @@ fn decode_pad(blocks: &[&[u8]]) -> Option<serde_json::Value> { // Block 4: main pad data, 194 bytes (v3) or 202 bytes (v4+) let pd = blocks[4];- if pd.len() < 49 {+ // The fixed fields we read run through pd[49] (shape byte) — require 50.+ if pd.len() < 50 { return None; } @@ -315,11 +316,11 @@ fn decode_pad(blocks: &[&[u8]]) -> Option<serde_json::Value> { let hole_mm = hole_units as f64 * ALTIUM_UNIT_TO_MM; let shape_byte = pd[49]; - // Rotation lives at block5[52..60] in v4+. v3 (194-byte) layout doesn't- // store it (rotation is baked into xsize/ysize). Read what's there; if- // the f64 isn't a finite angle we just default to 0. The bounding box is- // still correct because Altium pre-rotates the size for v3.- let rotation = if pd.len() >= 60 {+ // Rotation lives at block5[52..60] in v4+ (202-byte layout). v3 (194-byte)+ // doesn't store it (rotation is baked into xsize/ysize) — and a v3 block+ // still passes a `>= 60` check, so gate on the full v4 size or v3 bytes+ // 52..60 (some other field) get reinterpreted as an f64 angle.+ let rotation = if pd.len() >= 202 { let raw = read_f64_le(pd, 52).unwrap_or(0.0); if raw.is_finite() && raw.abs() < 360.0 + 1e-6 { // Normalize to [-360, 360); KiCad accepts any angle but typical@@ -704,34 +705,51 @@ fn escape_xml(s: &str) -> String { mod tests { use super::*; - /// LM358D is a SOIC-8 with 8 SMD pads at ±2.4638 mm × {±1.905, ±0.635} mm,- /// each 1.9812 × 0.5588 mm. Pad 1 is at (-2.4638, -1.905) in KiCad- /// convention.+ /// ADS1220IRVAT is TI's RVA package — VQFN-16, 0.5 mm pitch: 16 perimeter+ /// pads 0.28 × 0.80 mm with pad rows at ±1.75 mm, a 2.14 mm square exposed+ /// pad ("17") at the origin, and 4 thermal vias (0.254 mm PTH drill).+ /// Geometry cross-checked against the TI datasheet land pattern.+ /// (Fixture vendored from the UltraLibrarian download so the standalone+ /// repo can always run its own tests.) #[test]- fn lm358d_decodes_eight_pads_pad1_at_top_left() {- const LM358D_PCBLIB: &[u8] = include_bytes!(- "../../chip-fetcher/library/LM358D/LM358D.PcbLib"- );- let parsed = parse_altium_pcblib_bytes(LM358D_PCBLIB)- .expect("parse LM358D PcbLib");+ fn ads1220_vqfn16_decodes_pads_ep_and_thermal_vias() {+ const PCBLIB: &[u8] = include_bytes!("../tests/fixtures/ADS1220IRVAT.PcbLib");+ let parsed = parse_altium_pcblib_bytes(PCBLIB).expect("parse ADS1220IRVAT PcbLib");+ // 16 perimeter + 1 EP + 4 thermal vias let pad_count = parsed.get("pad_count").and_then(|v| v.as_u64()).unwrap_or(0);- assert_eq!(pad_count, 8, "LM358D should have 8 pads, got {pad_count}");+ assert_eq!(pad_count, 21, "expected 21 pads, got {pad_count}"); let pads = parsed.get("pads").and_then(|v| v.as_array()).expect("pads array");- let pad1 = pads.iter().find(|p| {- p.get("number").and_then(|v| v.as_str()) == Some("1")- }).expect("pad 1");++ // Pad 1: left column, (-1.75, -0.75), 0.28 × 0.80, SMD on F.Cu.+ let pad1 = pads.iter().find(|p| p.get("number").and_then(|v| v.as_str()) == Some("1")).expect("pad 1"); let at = pad1.get("at").and_then(|v| v.as_array()).expect("at");- let x = at[0].as_f64().unwrap();- let y = at[1].as_f64().unwrap();+ let (x, y) = (at[0].as_f64().unwrap(), at[1].as_f64().unwrap()); let size = pad1.get("size").and_then(|v| v.as_array()).expect("size");- let w = size[0].as_f64().unwrap();- let h = size[1].as_f64().unwrap();- // Pad 1 centre is at roughly (-2.46, -1.91); width 1.98, height 0.56- assert!((x - -2.4638).abs() < 0.05, "pad 1 X = {x}");- assert!((y - -1.905).abs() < 0.05, "pad 1 Y = {y}");- assert!((w - 1.9812).abs() < 0.05, "pad 1 W = {w}");- assert!((h - 0.5588).abs() < 0.05, "pad 1 H = {h}");+ let (w, h) = (size[0].as_f64().unwrap(), size[1].as_f64().unwrap());+ assert!((x - -1.75).abs() < 0.01, "pad 1 X = {x}");+ assert!((y - -0.75).abs() < 0.01, "pad 1 Y = {y}");+ assert!((w - 0.28).abs() < 0.01, "pad 1 W = {w}");+ assert!((h - 0.80).abs() < 0.01, "pad 1 H = {h}");+ assert!(pad1.get("drill").map(|d| d.is_null()).unwrap_or(false), "pad 1 is SMD"); let layers = pad1.get("layers").and_then(|v| v.as_array()).expect("layers"); assert!(layers.iter().any(|l| l.as_str() == Some("F.Cu")));++ // Pads 2..5 confirm the 0.5 mm pitch up the left column.+ let pad2 = pads.iter().find(|p| p.get("number").and_then(|v| v.as_str()) == Some("2")).expect("pad 2");+ let y2 = pad2["at"][1].as_f64().unwrap();+ assert!((y2 - y - 0.5).abs() < 0.01, "pitch was {}", y2 - y);++ // Exposed pad "17": 2.14 mm square at the origin.+ let ep = pads.iter().find(|p| p.get("number").and_then(|v| v.as_str()) == Some("17")).expect("EP");+ let ew = ep["size"][0].as_f64().unwrap();+ assert!((ew - 2.14).abs() < 0.01, "EP W = {ew}");+ assert!(ep["at"][0].as_f64().unwrap().abs() < 0.01);++ // Thermal vias: plated through-hole, 0.254 mm drill, on *.Cu.+ let vias: Vec<_> = pads.iter().filter(|p| p.get("number").and_then(|v| v.as_str()) == Some("V")).collect();+ assert_eq!(vias.len(), 4, "expected 4 thermal vias");+ let d = vias[0]["drill"]["diameter"].as_f64().unwrap();+ assert!((d - 0.254).abs() < 0.001, "via drill = {d}");+ assert!(vias[0]["layers"].as_array().unwrap().iter().any(|l| l.as_str() == Some("*.Cu"))); } }
crates/altium-pcblib/tests/fixtures/ADS1220IRVAT.PcbLibadded⋯ 1 unchanged line ⋯
crates/altium-schlib/src/lib.rs+10−2@@ -353,8 +353,15 @@ pub fn to_kicad_sym(sym: &serde_json::Value, lib_name: &str) -> String { emit(&left, true, &mut body); emit(&right, false, &mut body); + // V8-tier symbol-lib format version — loads in KiCad 8/9/10. Deliberately+ // NOT the KiCad-10 value (20251024): KiCad rejects files newer than+ // itself, and the user's desktop runs 9.0.9. Keep in sync with+ // altium-codec's kicad_export::KicadVersion (this crate is standalone, so+ // the value is duplicated here on purpose).+ const KICAD_SYM_VERSION: u32 = 20231120;+ let name = kesc(lib_name); format!(- "(kicad_symbol_lib (version 20211014) (generator altium-schlib)\n (symbol \"{lib_name}\" (in_bom yes) (on_board yes)\n (property \"Reference\" \"U\" (at 0 {:.2} 0) (effects (font (size 1.27 1.27))))\n (property \"Value\" \"{lib_name}\" (at 0 {:.2} 0) (effects (font (size 1.27 1.27))))\n (symbol \"{lib_name}_1_1\"\n (rectangle (start {:.2} {:.2}) (end {:.2} {:.2}) (stroke (width 0.254) (type default)) (fill (type background)))\n{body} )\n )\n)\n",+ "(kicad_symbol_lib (version {KICAD_SYM_VERSION}) (generator \"altium-schlib\") (generator_version \"8.0\")\n (symbol \"{name}\" (in_bom yes) (on_board yes)\n (property \"Reference\" \"U\" (at 0 {:.2} 0) (effects (font (size 1.27 1.27))))\n (property \"Value\" \"{name}\" (at 0 {:.2} 0) (effects (font (size 1.27 1.27))))\n (symbol \"{name}_1_1\"\n (rectangle (start {:.2} {:.2}) (end {:.2} {:.2}) (stroke (width 0.254) (type default)) (fill (type background)))\n{body} )\n )\n)\n", half_h + 1.27, -half_h - 1.27, -half_w, half_h, half_w, -half_h, ) }@@ -368,6 +375,7 @@ fn kicad_name(s: &str) -> String { kesc(s) } }+/// Escape for a KiCad double-quoted string (backslash first, then quotes). fn kesc(s: &str) -> String {- s.replace('"', "'")+ s.replace('\\', "\\\\").replace('"', "\\\"") }
package.json+3−7@@ -1,8 +1,8 @@ { "slug": "adom-lbr",- "version": "2.5.0",+ "version": "2.2.0", "type": "app",- "description": "adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Library Manager: Local / Mine / Org / Public tabs, infinite-scroll browse + server-side search, per-part symbol/footprint/3D completeness marks, and canonical adom-symbol/adom-footprint/babylon viewers + per-EDA layer stackup.",+ "description": "adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.", "dependencies": {}, "tags": [ "eda",@@ -50,11 +50,7 @@ "convert between any eda tools", "adom-lbr manager", "view footprint layer stackup",- "json to kicad",- "lbr placeholder footprint",- "eagle round thru-hole pad oblong",- "lbr silk circle dropped",- "lbr generate real footprint from kicad_mod"+ "json to kicad" ], "discovery_pitch": "Convert electronic-component symbols + footprints between any two EDA tools — KiCad, Altium (written natively, no Altium needed), and EAGLE/Fusion — through one canonical adom-lbr JSON, silk/courtyard/fab layers preserved. Plus a Manager to browse and inspect any wiki part.", "sample_prompts": [
src/main.rs+125−577@@ -37,16 +37,6 @@ enum Commands { /// Symbol name override #[arg(long)] name: Option<String>,- /// Chip outline drawn on the schematic symbol as <wire>s, OVERRIDING the- /// kicad_sym's own drawing. PREFERRED: adom-symbol's OUTLINE CONTRACT- /// `<mpn>-symbol-outline.mm.svg` (+ its `.json` sidecar) — placed 1:1 in- /// symbol mm-space. Else a legacy flattened outline SVG. Composes with --fp.- #[arg(long)]- symbol_art: Option<PathBuf>,- /// Size of LEGACY symbol art as a fraction of the body's smaller side.- /// Ignored for the `.mm.svg` contract (placement is exact).- #[arg(long, default_value_t = 0.38)]- symbol_art_scale: f64, }, /// Lint an existing .lbr file for Fusion 360 compatibility Lint {@@ -137,6 +127,11 @@ enum Commands { /// Output directory (files named <mpn>.kicad_sym / <mpn>.kicad_mod) #[arg(long, default_value = ".")] out_dir: PathBuf,+ /// KiCad format tier: 8 (default — loads in KiCad 8/9/10),+ /// 10 (native KiCad 10; NOT readable by KiCad 9 or older),+ /// 6 (legacy tools, e.g. Fusion 360 import)+ #[arg(long, default_value = "8")]+ kicad_version: String, }, /// Add an adom-lbr JSON's symbol into an EXISTING Altium .SchLib (non-destructive merge). AddToSchlib {@@ -215,10 +210,41 @@ struct ParsedSymbol { pins: Vec<KicadPin>, body: Option<KicadBody>, group_labels: Vec<GroupLabel>,- graphics: Vec<SymGraphic>, } +// TODO(no-regex-sexpr): this .kicad_sym parsing (and the pad/footprint-name+// extraction + `check` pin scan below) is regex-based. The designated+// replacement already exists and is tested: `altium_codec::sexpr` — a+// KiCad-parser-inspired s-expression reader (strict paren/string balance with+// line-numbered errors, `doc_version()` for KiCad's m_requiredVersion-style+// gates like "~ = empty only before 20250318", Sx tree helpers). Port these+// sites onto it and drop the regex-lite dependency. Not converted yet per+// barrett (2026-07-06) — the multi-symbol guard below stays until then. fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedSymbol, String> {+ // This parser is regex-based and file-global: with more than one symbol in+ // the library it would silently merge every symbol's pins/body into one+ // garbage part. Detect that case and refuse. Sub-units (`NAME_0_1`) of a+ // single parent are fine — they belong to the same part.+ {+ let re = regex_lite::Regex::new(r#"\(symbol "([^"]+)""#).unwrap();+ let unit_re = regex_lite::Regex::new(r"^(.*)_\d+_\d+$").unwrap();+ let mut parents: Vec<String> = Vec::new();+ for c in re.captures_iter(content) {+ let n = c.get(1).map(|m| m.as_str()).unwrap_or("");+ // unit names are `<parent>_<unit>_<style>` — fold onto the parent+ let base = unit_re.captures(n).and_then(|c| c.get(1)).map(|m| m.as_str()).unwrap_or(n);+ if !parents.iter().any(|p| p == base) {+ parents.push(base.to_string());+ }+ }+ if parents.len() > 1 {+ return Err(format!(+ "this .kicad_sym is a multi-symbol library ({}); extract the one symbol you want first — symbols: {}",+ parents.len(),+ parents.join(", ")+ ));+ }+ } let name = if let Some(n) = name_override { n.to_string() } else {@@ -261,28 +287,6 @@ fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedS }) }; - // The symbol's own drawing: (polyline (pts (xy …))) and (arc (start)(mid)(end)).- let mut graphics: Vec<SymGraphic> = Vec::new();- let xy_re = regex_lite::Regex::new(r#"\(xy\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();- for b in sexpr_blocks(content, "polyline") {- let pts: Vec<(f64, f64)> = xy_re.captures_iter(b)- .map(|c| (c[1].parse().unwrap_or(0.0), c[2].parse().unwrap_or(0.0)))- .collect();- if pts.len() >= 2 { graphics.push(SymGraphic::Poly(pts)); }- }- let arc_re = regex_lite::Regex::new(- r#"(?s)\(start\s+(-?[\d.]+)\s+(-?[\d.]+)\).*?\(mid\s+(-?[\d.]+)\s+(-?[\d.]+)\).*?\(end\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#- ).unwrap();- for b in sexpr_blocks(content, "arc") {- if let Some(c) = arc_re.captures(b) {- graphics.push(SymGraphic::Arc {- start: (c[1].parse().unwrap_or(0.0), c[2].parse().unwrap_or(0.0)),- mid: (c[3].parse().unwrap_or(0.0), c[4].parse().unwrap_or(0.0)),- end: (c[5].parse().unwrap_or(0.0), c[6].parse().unwrap_or(0.0)),- });- }- }- let mut group_labels = Vec::new(); let text_re = regex_lite::Regex::new( r#"\(text "([^"]+)"\s+\(at\s+([-\d.]+)\s+([-\d.]+)"#@@ -312,12 +316,20 @@ fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedS pins, body, group_labels,- graphics, }) } // ── EAGLE .lbr parser (import) ── +// TODO(no-regex-xml): this whole EAGLE-.lbr section parses XML with regexes,+// which silently breaks on reordered attributes, single quotes, entities,+// comments, CDATA, and multi-line tags. Replace BOTH parsing and writing with+// quick-xml (already a workspace dependency, `serialize` feature enabled):+// event-based reads for <symbol>/<pin>/<smd>/<connect>, and a serde or+// Writer-based emitter instead of the format!()+esc_xml string building below.+// Same direction as the KiCad side, which now has a real s-expression parser+// (altium_codec::sexpr) instead of regexes. Deliberately not converted yet —+// planned change, kept as comment per barrett (2026-07-06). fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> { let pin_re = regex_lite::Regex::new( r#"<pin name="([^"]+)" x="([^"]+)" y="([^"]+)" length="([^"]+)" direction="([^"]+)"([^/]*)/>"#@@ -440,7 +452,6 @@ fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> { pins, body, group_labels: Vec::new(),- graphics: Vec::new(), }) } @@ -448,7 +459,13 @@ fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> { fn generate_kicad_sym(sym: &ParsedSymbol) -> String { let mut s = String::with_capacity(4096);- s.push_str("(kicad_symbol_lib\n (version 20231120)\n (generator \"adom-lbr\")\n");+ // Default tier (V8) — see KicadVersion: loads in KiCad 8/9/10.+ let kv = altium_codec::kicad_export::KicadVersion::default();+ s.push_str(&format!(+ "(kicad_symbol_lib\n (version {})\n (generator \"adom-lbr\")\n (generator_version \"{}\")\n",+ kv.sym_version(),+ kv.generator_version().unwrap_or("8.0")+ )); s.push_str(&format!(" (symbol \"{}\"\n", sym.name)); s.push_str(" (pin_names (offset 1.016))\n"); s.push_str(" (exclude_from_sim no)\n");@@ -482,10 +499,13 @@ fn generate_kicad_sym(sym: &ParsedSymbol) -> String { 270 => 270, _ => 0, };+ // "~" meant "unnamed" in old KiCad; since format 20250318 it's a+ // literal tilde — write the real empty string instead.+ let pname = if pin.name == "~" { "" } else { pin.name.as_str() }; s.push_str(&format!( " (pin {} line (at {} {} {}) (length {}) (name \"{}\") (number \"{}\"))\n", pin.pin_type, pin.x, pin.y, kicad_angle, pin.length,- pin.name.replace('"', "\\\""),+ pname.replace('"', "\\\""), pin.number.replace('"', "\\\"") )); }@@ -496,6 +516,10 @@ fn generate_kicad_sym(sym: &ParsedSymbol) -> String { } // ── EAGLE .lbr generator ──+// TODO(no-regex-xml): the generator builds XML by string concatenation with+// esc_xml. Move to quick-xml's Writer (attribute escaping, well-formedness by+// construction) together with the parser above. See the comment on+// parse_eagle_lbr; not converted yet per barrett (2026-07-06). fn esc_xml(s: &str) -> String { s.replace('&', "&")@@ -537,32 +561,23 @@ fn pin_length_keyword(len: f64) -> &'static str { } fn dedup_pin_names(pins: &[KicadPin]) -> Vec<String> {- // Effective EAGLE pin name: when the KiCad pin name is empty (common on- // mechanical / 1-pin parts: `(number "1")(name "")`), fall back to the pad- // NUMBER. Fusion REJECTS `<pin name="">` / `<connect pin="">`, so a name is- // mandatory. This is the single source of truth for both the <pin> and the- // <connect> emission, so the fallback fixes both at once.- let eff: Vec<String> = pins.iter().enumerate().map(|(i, p)| {- let n = p.name.trim();- if !n.is_empty() {- n.to_string()- } else {- let num = p.number.trim();- if !num.is_empty() { num.to_string() } else { format!("P{}", i + 1) }- }- }).collect();- let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();- for n in &eff {- *counts.entry(n.clone()).or_insert(0) += 1;+ let mut counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();+ for pin in pins {+ *counts.entry(&pin.name).or_insert(0) += 1; }- let mut seen: std::collections::HashMap<String, u32> = std::collections::HashMap::new();- eff.iter().map(|name| {- if counts[name] == 1 {- name.clone()+ let mut seen: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();+ pins.iter().map(|pin| {+ let total = counts[pin.name.as_str()];+ if total == 1 {+ pin.name.clone() } else {- let c = seen.entry(name.clone()).or_insert(0);- *c += 1;- if *c == 1 { name.clone() } else { format!("{}@{}", name, c) }+ let n = seen.entry(&pin.name).or_insert(0);+ *n += 1;+ if *n == 1 {+ pin.name.clone()+ } else {+ format!("{}@{}", pin.name, n)+ } } }).collect() }@@ -612,369 +627,10 @@ fn synth_symbol_from_mod(km: &str, name: &str) -> ParsedSymbol { pins, body: Some(KicadBody { left: -7.62, top: body_h / 2.0, right: 7.62, bottom: -body_h / 2.0 }), group_labels: Vec::new(),- graphics: Vec::new(),- }-}--/// A drawing primitive from the `.kicad_sym` symbol body (KiCad symbol space,-/// Y-up — same as the pins, so it carries through to EAGLE without a Y-flip).-#[derive(Debug, Serialize, Deserialize)]-enum SymGraphic {- Poly(Vec<(f64, f64)>),- Arc { start: (f64, f64), mid: (f64, f64), end: (f64, f64) },-}--/// One SMD/thru-hole pad parsed from a `.kicad_mod`, in KiCad coordinates-/// (millimetres, Y-down). `drill` is `Some` only for thru-hole pads.-struct ModPad {- name: String,- smd: bool,- x: f64,- y: f64,- dx: f64,- dy: f64,- angle: f64,- drill: Option<f64>,- /// EAGLE pad shape mapped from the KiCad shape token: "round" | "square" |- /// "long" | "octagon".- shape: String,-}--/// Split a `.kicad_mod` into the raw text of each `(<tag> …)` s-expression,-/// balancing parens so multi-line definitions are captured whole. `tag` is the-/// opening token without its paren, e.g. `"pad"` or `"fp_line"`.-fn sexpr_blocks<'a>(km: &'a str, tag: &str) -> Vec<&'a str> {- let open = format!("({tag}");- let mut blocks = Vec::new();- let mut i = 0;- while let Some(rel) = km[i..].find(&open) {- let start = i + rel;- // Require whitespace after the token so "(pads"/"(fp_lines" don't match.- let after = &km[start + open.len()..];- if !after.starts_with(|c: char| c.is_whitespace()) {- i = start + open.len();- continue;- }- let mut depth = 0i32;- let mut end = start;- for (k, c) in km[start..].char_indices() {- match c {- '(' => depth += 1,- ')' => {- depth -= 1;- if depth == 0 {- end = start + k + 1;- break;- }- }- _ => {}- }- }- if end > start {- blocks.push(&km[start..end]);- i = end;- } else {- break;- }- }- blocks-}--/// Parse every real (named) pad out of a `.kicad_mod`, preserving the true-/// per-side positions, sizes, and rotations. Mechanical pads (empty name) and-/// pads missing position/size are skipped.-fn parse_mod_pads(km: &str) -> Vec<ModPad> {- // Groups: 1/2 = pad name (quoted/bare), 3 = type (smd/thru_hole), 4 = SHAPE- // (circle/oval/rect/roundrect/…) — the shape word we were previously dropping.- let name_re = regex_lite::Regex::new(r#"^\(pad\s+(?:"([^"]*)"|(\S+))\s+(\S+)\s+(\S+)"#).unwrap();- let at_re = regex_lite::Regex::new(r#"\(at\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s+(-?[\d.]+))?\s*\)"#).unwrap();- let size_re = regex_lite::Regex::new(r#"\(size\s+(-?[\d.]+)\s+(-?[\d.]+)\s*\)"#).unwrap();- let drill_re = regex_lite::Regex::new(r#"\(drill\s+(-?[\d.]+)"#).unwrap();-- let mut pads = Vec::new();- for block in sexpr_blocks(km, "pad") {- let Some(nc) = name_re.captures(block) else { continue };- let name = nc.get(1).or_else(|| nc.get(2)).map(|m| m.as_str().to_string()).unwrap_or_default();- if name.is_empty() {- continue;- }- let smd = nc.get(3).map(|m| m.as_str()).unwrap_or("") == "smd";- let Some(at) = at_re.captures(block) else { continue };- let Some(size) = size_re.captures(block) else { continue };- let x: f64 = at[1].parse().unwrap_or(0.0);- let y: f64 = at[2].parse().unwrap_or(0.0);- let angle: f64 = at.get(3).and_then(|m| m.as_str().parse().ok()).unwrap_or(0.0);- let dx: f64 = size[1].parse().unwrap_or(0.0);- let dy: f64 = size[2].parse().unwrap_or(0.0);- let drill = drill_re.captures(block).and_then(|c| c[1].parse().ok());- // KiCad pad shape → EAGLE pad shape.- let shape = match nc.get(4).map(|m| m.as_str()).unwrap_or("") {- "circle" => "round",- "rect" => "square",- "oval" => "long",- "roundrect" | "custom" => "round",- _ => "round",- }.to_string();- pads.push(ModPad { name, smd, x, y, dx, dy, angle, drill, shape });- }- pads-}--/// Map a KiCad pad rotation (degrees) to an EAGLE `rot=` attribute, matching-/// Fusion 360's own library export convention: the KiCad angle maps directly-/// (KiCad 90 → `R90`), so a non-square pad on a QFP/SOIC top/bottom side is-/// oriented along the lead. Rect pads are 180°-symmetric so the Y-flip sense-/// is moot; the direct mapping is what Fusion writes (verified by the Fusion-/// bridge against a round-tripped `.flbr`).-fn smd_rotation(angle: f64) -> &'static str {- let a = ((angle.round() as i64) % 360 + 360) % 360;- match a {- 90 => " rot=\"R90\"",- 180 => " rot=\"R180\"",- 270 => " rot=\"R270\"",- _ => "",- }-}--/// Bounding box (min_x, min_y, max_x, max_y) of `fp_line` endpoints on the-/// given layer, in KiCad coordinates. Each `fp_line` is extracted as a balanced-/// block and matched against its OWN `(layer …)`, so coordinates never leak-/// across layers. Returns `None` if the layer has no lines.-fn fp_line_bbox(km: &str, layer: &str) -> Option<(f64, f64, f64, f64)> {- let pt_re = regex_lite::Regex::new(r#"\((?:start|end)\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();- let layer_re = regex_lite::Regex::new(r#"\(layer\s+"([^"]+)"\)"#).unwrap();- let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);- let mut found = false;- for block in sexpr_blocks(km, "fp_line") {- if layer_re.captures(block).map(|c| c[1].to_string()).as_deref() != Some(layer) {- continue;- }- for c in pt_re.captures_iter(block) {- let x: f64 = c[1].parse().unwrap_or(0.0);- let y: f64 = c[2].parse().unwrap_or(0.0);- minx = minx.min(x); miny = miny.min(y);- maxx = maxx.max(x); maxy = maxy.max(y);- found = true;- }- }- found.then_some((minx, miny, maxx, maxy))-}--/// Derive the package silkscreen outline bbox from the `.kicad_mod` body.-/// Prefers the silkscreen layer, falls back to fab, then to the pad extents.-fn package_outline(km: &str, pads: &[ModPad]) -> (f64, f64, f64, f64) {- fp_line_bbox(km, "F.SilkS")- .or_else(|| fp_line_bbox(km, "F.Fab"))- .unwrap_or_else(|| {- let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);- for p in pads {- minx = minx.min(p.x - p.dx); miny = miny.min(p.y - p.dy);- maxx = maxx.max(p.x + p.dx); maxy = maxy.max(p.y + p.dy);- }- (minx, miny, maxx, maxy)- })-}--/// Flatten a 3-point (start → mid → end) circular arc into a polyline of `segs`-/// segments. Falls back to the three literal points when they're collinear.-fn flatten_arc(start: (f64, f64), mid: (f64, f64), end: (f64, f64), segs: usize) -> Vec<(f64, f64)> {- use std::f64::consts::PI;- let ((ax, ay), (bx, by), (cx, cy)) = (start, mid, end);- let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));- if d.abs() < 1e-9 {- return vec![start, mid, end];- }- let ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;- let uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;- let r = ((ax - ux).powi(2) + (ay - uy).powi(2)).sqrt();- let a0 = (ay - uy).atan2(ax - ux);- let a1 = (cy - uy).atan2(cx - ux);- let am = (by - uy).atan2(bx - ux);- let norm = |mut t: f64| { while t < 0.0 { t += 2.0 * PI; } while t >= 2.0 * PI { t -= 2.0 * PI; } t };- let ccw_end = norm(a1 - a0); // CCW sweep from start→end- let ccw_mid = norm(am - a0); // CCW offset of the mid point- // If mid lies within the CCW sweep, go CCW; else go CW the other way round.- let (dir, sweep) = if ccw_mid <= ccw_end { (1.0, ccw_end) } else { (-1.0, 2.0 * PI - ccw_end) };- (0..=segs).map(|i| {- let a = a0 + dir * sweep * (i as f64 / segs as f64);- (ux + r * a.cos(), uy + r * a.sin())- }).collect()-}--/// Map a KiCad graphics layer to its EAGLE package-layer number. Only silk/fab-/// are drawn in the package outline; copper/mask/paste/courtyard are skipped.-fn kicad_layer_to_eagle(klayer: &str) -> Option<u32> {- match klayer {- "F.SilkS" => Some(21),- "B.SilkS" => Some(22),- "F.Fab" => Some(51),- "B.Fab" => Some(52),- _ => None,- }-}--/// Transcribe the footprint's REAL silkscreen/fab graphics — `fp_circle`,-/// `fp_rect`, `fp_line`, `fp_arc` — into EAGLE `<circle>`/`<wire>` on the mapped-/// layer (21 tPlace / 51 tDocu / …), Y negated (KiCad Y-down → EAGLE Y-up).-/// Returns "" when the footprint carries no silk/fab graphics, so the caller can-/// fall back to a bbox rectangle.-fn package_graphics(km: &str) -> String {- let layer_re = regex_lite::Regex::new(r#"\(layer\s+"([^"]+)"\)"#).unwrap();- let center_re = regex_lite::Regex::new(r#"\(center\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();- let start_re = regex_lite::Regex::new(r#"\(start\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();- let mid_re = regex_lite::Regex::new(r#"\(mid\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();- let end_re = regex_lite::Regex::new(r#"\(end\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();- let width_re = regex_lite::Regex::new(r#"\(width\s+(-?[\d.]+)\)"#).unwrap();- let el_of = |b: &str| layer_re.captures(b).and_then(|c| kicad_layer_to_eagle(&c[1]));- let w_of = |b: &str| width_re.captures(b).and_then(|c| c[1].parse::<f64>().ok()).unwrap_or(0.127);- let f = |re: ®ex_lite::Regex, b: &str| re.captures(b).map(|c| (c[1].parse::<f64>().unwrap_or(0.0), c[2].parse::<f64>().unwrap_or(0.0)));-- let mut out = String::new();- for b in sexpr_blocks(km, "fp_circle") {- let (Some(el), Some((cx, cy)), Some((ex, ey))) = (el_of(b), f(¢er_re, b), f(&end_re, b)) else { continue };- let radius = ((ex - cx).powi(2) + (ey - cy).powi(2)).sqrt();- out.push_str(&format!(" <circle x=\"{:.4}\" y=\"{:.4}\" radius=\"{:.4}\" width=\"{:.4}\" layer=\"{}\"/>\n", cx, -cy, radius, w_of(b), el));- }- for b in sexpr_blocks(km, "fp_rect") {- let (Some(el), Some((x1, y1r)), Some((x2, y2r))) = (el_of(b), f(&start_re, b), f(&end_re, b)) else { continue };- let (y1, y2, w) = (-y1r, -y2r, w_of(b));- for (ax, ay, bx, by) in [(x1, y1, x2, y1), (x2, y1, x2, y2), (x2, y2, x1, y2), (x1, y2, x1, y1)] {- out.push_str(&format!(" <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"{:.4}\" layer=\"{}\"/>\n", ax, ay, bx, by, w, el));- }- }- for b in sexpr_blocks(km, "fp_line") {- let (Some(el), Some((sx, sy)), Some((ex, ey))) = (el_of(b), f(&start_re, b), f(&end_re, b)) else { continue };- out.push_str(&format!(" <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"{:.4}\" layer=\"{}\"/>\n", sx, -sy, ex, -ey, w_of(b), el)); }- for b in sexpr_blocks(km, "fp_arc") {- let (Some(el), Some(s), Some(m), Some(e)) = (el_of(b), f(&start_re, b), f(&mid_re, b), f(&end_re, b)) else { continue };- let w = w_of(b);- for win in flatten_arc(s, m, e, 12).windows(2) {- out.push_str(&format!(" <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"{:.4}\" layer=\"{}\"/>\n", win[0].0, -win[0].1, win[1].0, -win[1].1, w, el));- }- }- out-}--/// Resolved `--symbol-art` input. PREFERRED: an adom-symbol OUTLINE CONTRACT-/// (`<mpn>-symbol-outline.mm.svg` + `<mpn>-symbol-outline.json` sidecar) whose-/// polylines are ALREADY placed in the symbol's mm space; the sidecar gives the-/// exact Y-up transform, so it's transcribed 1:1 with zero guessing. Anything-/// else is a `Legacy` raw outline we centre + scale by eye.-enum SymbolArt {- Contract { mm_svg: String, dx: f64, svg_cy: f64, e_cy: f64 },- Legacy(String),-}--/// Resolve a `--symbol-art` path. A `*.mm.svg` with a readable `*.json` sidecar-/// is a contract; otherwise legacy.-fn resolve_symbol_art(path: &std::path::Path) -> Result<SymbolArt, String> {- let content = fs::read_to_string(path).map_err(|e| format!("Cannot read symbol art {}: {}", path.display(), e))?;- let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");- if name.ends_with(".mm.svg") {- let json_path = path.with_file_name(name.replace(".mm.svg", ".json"));- if let Ok(js) = fs::read_to_string(&json_path) {- if let Ok(v) = serde_json::from_str::<serde_json::Value>(&js) {- let g = |a: &str, b: &str| v.get(a).and_then(|o| o.get(b)).and_then(|n| n.as_f64());- if let (Some(svg_cx), Some(svg_cy), Some(e_cx), Some(e_cy)) = (- g("placement_svg_mm", "center_x"), g("placement_svg_mm", "center_y"),- g("placement_eagle_mm", "center_x"), g("placement_eagle_mm", "center_y"),- ) {- return Ok(SymbolArt::Contract { mm_svg: content, dx: svg_cx - e_cx, svg_cy, e_cy });- }- }- }- eprintln!("WARN: {name} looks like an outline contract but its .json sidecar is missing/unreadable — falling back to centre+scale guessing.");- }- Ok(SymbolArt::Legacy(content))-}--/// Transcribe a contract `.mm.svg` (symbol mm-space, Y-down) to EAGLE `<wire>`s-/// (Y-up), 1:1: `eagle_x = svg_x - dx`, `eagle_y = e_cy - (svg_y - svg_cy)`.-fn contract_wires(mm_svg: &str, dx: f64, svg_cy: f64, e_cy: f64) -> (String, usize) {- let poly_re = regex_lite::Regex::new(r#"<(polyline|polygon)\b[^>]*\bpoints="([^"]+)""#).unwrap();- let mut out = String::new();- let mut segs = 0usize;- for c in poly_re.captures_iter(mm_svg) {- let closed = &c[1] == "polygon";- let mut pts: Vec<(f64, f64)> = c[2].split_whitespace().filter_map(|p| {- let mut it = p.split(',');- let x: f64 = it.next()?.trim().parse().ok()?;- let y: f64 = it.next()?.trim().parse().ok()?;- Some((x - dx, e_cy - (y - svg_cy)))- }).collect();- if closed { if let Some(&first) = pts.first() { pts.push(first); } }- for w in pts.windows(2) {- out.push_str(&format!(" <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.1016\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1));- segs += 1;- }- }- (out, segs) } -/// Legacy raw outline SVG → wires: centre + scale to ~`scale_frac` of the body's-/// smaller side, decimate in output space so dense sampling collapses.-fn symbol_art_wires(svg: &str, body: Option<&KicadBody>, scale_frac: f64) -> (String, usize) {- let poly_re = regex_lite::Regex::new(r#"<(polyline|polygon)\b[^>]*\bpoints="([^"]+)""#).unwrap();- let mut polys: Vec<Vec<(f64, f64)>> = Vec::new();- for c in poly_re.captures_iter(svg) {- let closed = &c[1] == "polygon";- let mut pts: Vec<(f64, f64)> = c[2].split_whitespace().filter_map(|p| {- let mut it = p.split(',');- let x: f64 = it.next()?.trim().parse().ok()?;- let y: f64 = it.next()?.trim().parse().ok()?;- Some((x, y))- }).collect();- if closed { if let Some(&first) = pts.first() { pts.push(first); } }- polys.push(pts);- }- let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);- for poly in &polys { for &(x, y) in poly {- minx = minx.min(x); miny = miny.min(y); maxx = maxx.max(x); maxy = maxy.max(y);- }}- let (sw, sh) = (maxx - minx, maxy - miny);- if !(sw > 0.0 && sh > 0.0) { return (String::new(), 0); }- let (target, cx, cy) = match body {- Some(b) => {- let bw = (b.right - b.left).abs();- let bh = (b.top - b.bottom).abs();- (bw.min(bh) * scale_frac, (b.left + b.right) / 2.0, (b.top + b.bottom) / 2.0)- }- None => (26.0 * scale_frac, 0.0, 0.0),- };- let scale = target / sw.max(sh);- let tol_mm = 0.12;- let (smx, smy) = ((minx + maxx) / 2.0, (miny + maxy) / 2.0);- let map = |x: f64, y: f64| (cx + (x - smx) * scale, cy - (y - smy) * scale);- let mut out = String::new();- let mut segs = 0usize;- for poly in &polys {- let mut kept: Vec<(f64, f64)> = Vec::new();- let n = poly.len();- for (i, &(px, py)) in poly.iter().enumerate() {- let pp = map(px, py);- if i == 0 || i == n - 1 { kept.push(pp); }- else if let Some(&last) = kept.last() {- if ((pp.0 - last.0).powi(2) + (pp.1 - last.1).powi(2)).sqrt() >= tol_mm { kept.push(pp); }- }- }- for w in kept.windows(2) {- out.push_str(&format!(" <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.1016\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1));- segs += 1;- }- }- (out, segs)-}--/// Wire block for a resolved symbol-art input.-fn symbol_art_block(art: &SymbolArt, body: Option<&KicadBody>, scale_frac: f64) -> (String, usize) {- match art {- SymbolArt::Contract { mm_svg, dx, svg_cy, e_cy } => contract_wires(mm_svg, *dx, *svg_cy, *e_cy),- SymbolArt::Legacy(svg) => symbol_art_wires(svg, body, scale_frac),- }-}--fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64) -> String {+fn generate_lbr(sym: &ParsedSymbol, _fp_content: Option<&str>) -> String { let mut xml = String::with_capacity(8192); xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");@@ -992,48 +648,6 @@ fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option // Packages section — generate a placeholder SMD package from pin positions xml.push_str("<packages>\n"); xml.push_str(&format!("<package name=\"{}\">\n", esc_xml(&sym.name)));-- // Real footprint from the .kicad_mod when given; else a placeholder.- let mod_pads = fp_content.map(parse_mod_pads).unwrap_or_default();- if !mod_pads.is_empty() {- let km = fp_content.unwrap();- xml.push_str(&format!(" <description>{}</description>\n", esc_xml(&sym.name)));- for p in &mod_pads {- if p.smd {- xml.push_str(&format!(- " <smd name=\"{}\" x=\"{:.4}\" y=\"{:.4}\" dx=\"{:.4}\" dy=\"{:.4}\" layer=\"1\"{}/>\n",- esc_xml(&p.name), p.x, -p.y, p.dx, p.dy, smd_rotation(p.angle)));- } else {- // Round thru-hole pads: real drill + circular diameter (not oblong).- let drill = p.drill.unwrap_or_else(|| p.dx.min(p.dy) * 0.6);- let diameter = if p.shape == "round" { p.dx } else { p.dx.max(p.dy) };- xml.push_str(&format!(- " <pad name=\"{}\" x=\"{:.4}\" y=\"{:.4}\" drill=\"{:.4}\" diameter=\"{:.4}\" shape=\"{}\"{}/>\n",- esc_xml(&p.name), p.x, -p.y, drill, diameter, p.shape, smd_rotation(p.angle)));- }- }- // Real silk/fab graphics (circles/lines/arcs); bbox rectangle only as fallback.- let (minx, miny, maxx, maxy) = package_outline(km, &mod_pads);- let (sx1, sy1, sx2, sy2) = (minx, -maxy, maxx, -miny);- let silk = package_graphics(km);- if silk.is_empty() {- for (x1, y1, x2, y2) in [(sx1, sy1, sx2, sy1), (sx2, sy1, sx2, sy2), (sx2, sy2, sx1, sy2), (sx1, sy2, sx1, sy1)] {- xml.push_str(&format!(" <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.127\" layer=\"21\"/>\n", x1, y1, x2, y2));- }- } else {- xml.push_str(&silk);- }- let mut top = sy2;- let mut bot = sy1;- for p in &mod_pads {- let py = -p.y;- let r = p.dx.max(p.dy) / 2.0;- top = top.max(py + r);- bot = bot.min(py - r);- }- xml.push_str(&format!(" <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"25\" align=\"bottom-center\">>NAME</text>\n", top + 0.6));- xml.push_str(&format!(" <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"27\" align=\"top-center\">>VALUE</text>\n", bot - 0.6));- } else { xml.push_str(&format!(" <description>{} placeholder footprint</description>\n", esc_xml(&sym.name))); // Generate SMD pads from pin numbers@@ -1078,7 +692,6 @@ fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option xml.push_str(&format!(" <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"25\" align=\"bottom-center\">>NAME</text>\n", body_h / 2.0 + 0.5)); xml.push_str(&format!(" <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"27\" align=\"top-center\">>VALUE</text>\n", -body_h / 2.0 - 0.5));- } xml.push_str("</package>\n"); xml.push_str("</packages>\n");@@ -1094,30 +707,6 @@ fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option xml.push_str(&format!(" <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.left, body.bottom, body.left, body.top)); } - // The chip drawing. A `--symbol-art` SVG is an explicit OVERRIDE; otherwise- // transcribe the `.kicad_sym`'s OWN (polyline)/(arc) graphics as raw layer-94- // wires (KiCad symbol space is Y-up, same as the pins → carried through 1:1).- if let Some(art) = symbol_art {- let (wires, _) = symbol_art_block(art, sym.body.as_ref(), symbol_art_scale);- xml.push_str(&wires);- } else {- for g in &sym.graphics {- match g {- SymGraphic::Poly(pts) => {- for w in pts.windows(2) {- xml.push_str(&format!(" <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.1524\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1));- }- }- SymGraphic::Arc { start, mid, end } => {- let pts = flatten_arc(*start, *mid, *end, 12);- for w in pts.windows(2) {- xml.push_str(&format!(" <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.1524\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1));- }- }- }- }- }- let name_y = sym.body.as_ref().map(|b| b.top + 2.54).unwrap_or(10.0); let value_y = sym.body.as_ref().map(|b| b.bottom - 2.54).unwrap_or(-10.0); xml.push_str(&format!(" <text x=\"0\" y=\"{}\" size=\"1.27\" layer=\"95\" align=\"bottom-center\">>NAME</text>\n", name_y));@@ -1227,74 +816,20 @@ pub(crate) fn lint_lbr(content: &str) -> LintResult { errors.push(LintError { code: "device-no-package".into(), message: "Device has no package attribute — Fusion needs a package to show the component".into() }); } - // Per-symbol pin analysis. Pin names must be NON-EMPTY (Fusion rejects- // <pin name="">) and UNIQUE WITHIN a symbol. Across symbols, a repeat is- // LEGAL — e.g. a merged multi-part lib where four 1-pin symbols each have a- // pin "1" — so the uniqueness check is scoped to each <symbol> block, not- // the whole file.- let sym_block_re = regex_lite::Regex::new(r#"(?s)<symbol name="([^"]*)">(.*?)</symbol>"#).unwrap();- let pin_name_re = regex_lite::Regex::new(r#"<pin name="([^"]*)""#).unwrap();- let mut sym_pins: std::collections::HashMap<String, std::collections::HashSet<String>> =- std::collections::HashMap::new();- for sc in sym_block_re.captures_iter(content) {- let sname = sc[1].to_string();- let body = &sc[2];- let mut within: std::collections::HashMap<String, u32> = std::collections::HashMap::new();- let mut set = std::collections::HashSet::new();- for pc in pin_name_re.captures_iter(body) {- let pname = pc[1].to_string();- if pname.trim().is_empty() {- errors.push(LintError {- code: "empty-pin-name".into(),- message: format!("Symbol '{}' has a <pin name=\"\"> — Fusion rejects empty pin names (fall back to the pad number)", sname),- });- continue;- }- *within.entry(pname.clone()).or_insert(0) += 1;- set.insert(pname);- }- for (name, count) in &within {- if *count > 1 {- errors.push(LintError {- code: "duplicate-pin-name".into(),- message: format!("Pin name '{}' appears {} times in symbol '{}' — EAGLE requires unique pin names within a symbol (use @suffix)", name, count, sname),- });- }- }- sym_pins.insert(sname, set);+ // Check for duplicate pin names (EAGLE requires unique)+ // TODO(no-regex-xml): these lint scans regex over raw XML too — port to+ // quick-xml along with parse_eagle_lbr (see its comment).+ let pin_re = regex_lite::Regex::new(r#"<pin name="([^"]+)""#).unwrap();+ let mut pin_names = std::collections::HashMap::new();+ for cap in pin_re.captures_iter(content) {+ *pin_names.entry(cap[1].to_string()).or_insert(0u32) += 1; }-- // Connect integrity: no empty pin=, and every connect's pin must resolve to a- // named <pin> in its gate's symbol. Resolve per-deviceset (gate names like- // G$1 repeat across devicesets, so a global map would cross-wire them).- let deviceset_re = regex_lite::Regex::new(r#"(?s)<deviceset\b.*?</deviceset>"#).unwrap();- let gate_re = regex_lite::Regex::new(r#"<gate name="([^"]*)" symbol="([^"]*)""#).unwrap();- let connect_full_re = regex_lite::Regex::new(r#"<connect gate="([^"]*)" pin="([^"]*)" pad="([^"]*)"/>"#).unwrap();- for ds in deviceset_re.captures_iter(content) {- let block = &ds[0];- let mut gate_sym: std::collections::HashMap<String, String> = std::collections::HashMap::new();- for g in gate_re.captures_iter(block) {- gate_sym.insert(g[1].to_string(), g[2].to_string());- }- for c in connect_full_re.captures_iter(block) {- let (gate, pin) = (c[1].to_string(), c[2].to_string());- if pin.trim().is_empty() {- errors.push(LintError {- code: "empty-connect-pin".into(),- message: "A <connect> has pin=\"\" — Fusion rejects empty connect pins (map to the gate's pin name)".into(),- });- continue;- }- if let Some(sym) = gate_sym.get(&gate) {- if let Some(names) = sym_pins.get(sym) {- if !names.contains(&pin) {- errors.push(LintError {- code: "connect-pin-unresolved".into(),- message: format!("Connect pin '{}' (gate '{}') matches no <pin name> in symbol '{}' — Fusion cannot bind it", pin, gate, sym),- });- }- }- }+ for (name, count) in &pin_names {+ if *count > 1 {+ errors.push(LintError {+ code: "duplicate-pin-name".into(),+ message: format!("Pin name '{}' appears {} times — EAGLE requires unique pin names (use @suffix for duplicates)", name, count),+ }); } } @@ -1388,6 +923,16 @@ fn read_or_exit(path: &std::path::Path) -> String { }) } +/// Write a file or exit with a clear error — never print `OK: wrote …` for a+/// write that didn't happen.+fn write_or_exit(path: &std::path::Path, bytes: impl AsRef<[u8]>) {+ fs::write(path, bytes).unwrap_or_else(|e| {+ eprintln!("ERROR: write {}: {e}", path.display());+ eprintln!("Hint: check the output directory exists and is writable (mkdir -p first).");+ process::exit(1);+ })+}+ /// Load an adom-lbr JSON into the canonical part. fn load_part(path: &std::path::Path) -> altium_codec::AdomLbrPart { let json = read_or_exit(path);@@ -1419,7 +964,7 @@ fn main() { let cli = Cli::parse(); match cli.command {- Commands::Generate { sym, fp, output, name, symbol_art, symbol_art_scale } => {+ Commands::Generate { sym, fp, output, name } => { let fp_content = fp.as_ref().map(|p| { fs::read_to_string(p).unwrap_or_else(|e| { eprintln!("ERROR: Cannot read footprint {}: {}", p.display(), e);@@ -1454,12 +999,7 @@ fn main() { }; let out_default = sym.as_ref().or(fp.as_ref()).cloned().unwrap_or_else(|| PathBuf::from(&parsed.name));- let art = match symbol_art.as_ref().map(|p| resolve_symbol_art(p)) {- Some(Ok(a)) => Some(a),- Some(Err(e)) => { eprintln!("ERROR: {e}"); process::exit(1); }- None => None,- };- let lbr = generate_lbr(&parsed, fp_content.as_deref(), art.as_ref(), symbol_art_scale);+ let lbr = generate_lbr(&parsed, fp_content.as_deref()); // Lint before writing let lint = lint_lbr(&lbr);@@ -1649,7 +1189,7 @@ fn main() { let part = altium_codec::import_kicad(&sym_txt, &fp_txt, &mpn) .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); }); let outp = out.unwrap_or_else(|| PathBuf::from(format!("{mpn}.adom-lbr.json")));- fs::write(&outp, serde_json::to_string_pretty(&part).unwrap()).ok();+ write_or_exit(&outp, serde_json::to_string_pretty(&part).unwrap()); println!("OK: wrote {} (adom-lbr JSON — the canonical hub)", outp.display()); println!("Hint: convert it with `adom-lbr export-altium {}` or `export-intlib`.", outp.display()); }@@ -1664,8 +1204,8 @@ fn main() { .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); }); let sp = out_dir.join(format!("{}.SchLib", p.mpn)); let pp = out_dir.join(format!("{}.PcbLib", p.mpn));- fs::write(&sp, &sym_bytes).ok();- fs::write(&pp, &fp_bytes).ok();+ write_or_exit(&sp, &sym_bytes);+ write_or_exit(&pp, &fp_bytes); println!("OK: wrote {} ({} pins) + {} ({} pads)", sp.display(), p.symbol.pins.len(), pp.display(), p.footprint.pads.len()); }@@ -1678,20 +1218,25 @@ fn main() { let bytes = altium_codec::encode_intlib(&symbol, &p.footprint, &descr) .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); }); let outp = out.unwrap_or_else(|| PathBuf::from(format!("{}.IntLib", p.mpn)));- fs::write(&outp, &bytes).ok();+ write_or_exit(&outp, &bytes); println!("OK: wrote {} ({} bytes) — symbol + footprint bundled", outp.display(), bytes.len()); } - Commands::ExportKicad { part, out_dir } => {+ Commands::ExportKicad { part, out_dir, kicad_version } => {+ let Some(ver) = altium_codec::kicad_export::KicadVersion::parse(&kicad_version) else {+ eprintln!("ERROR: unknown --kicad-version {kicad_version:?}");+ eprintln!("Hint: valid tiers are 8 (default; loads in KiCad 8/9/10), 10 (native KiCad 10 only), 6 (legacy tools).");+ process::exit(1);+ }; let p = load_part(&part); let mut symbol = p.symbol.clone(); seed_parameters(&mut symbol, &p);- let sym_txt = altium_codec::export_kicad_symbol(&symbol);- let fp_txt = altium_codec::export_kicad_footprint(&p.footprint);+ let sym_txt = altium_codec::kicad_export::export_kicad_symbol_for(&symbol, ver);+ let fp_txt = altium_codec::kicad_export::export_kicad_footprint_for(&p.footprint, ver); let sp = out_dir.join(format!("{}.kicad_sym", p.mpn)); let fp = out_dir.join(format!("{}.kicad_mod", p.mpn));- fs::write(&sp, sym_txt).ok();- fs::write(&fp, fp_txt).ok();+ write_or_exit(&sp, sym_txt);+ write_or_exit(&fp, fp_txt); println!("OK: wrote {} ({} pins) + {} ({} pads, {} graphics)", sp.display(), p.symbol.pins.len(), fp.display(), p.footprint.pads.len(), p.footprint.graphics.len());@@ -1713,7 +1258,7 @@ fn main() { let merged = altium_codec::add_symbols_to_schlib(&existing_bytes, &syms) .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); }); let outp = out.unwrap_or_else(|| PathBuf::from("merged.SchLib"));- fs::write(&outp, &merged).ok();+ write_or_exit(&outp, &merged); println!("OK: wrote {} — added {} symbol(s) into the library", outp.display(), syms.len()); } @@ -1739,7 +1284,7 @@ fn main() { .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); }); let name = if part.mpn.is_empty() { "part".to_string() } else { part.mpn.clone() }; let outp = out.unwrap_or_else(|| PathBuf::from(format!("{name}.adom-lbr.json")));- fs::write(&outp, serde_json::to_string_pretty(&part).unwrap()).ok();+ write_or_exit(&outp, serde_json::to_string_pretty(&part).unwrap()); println!("OK: wrote {} — {} pins, {} pads, {} footprint graphics", outp.display(), part.symbol.pins.len(), part.footprint.pads.len(), part.footprint.graphics.len()); }@@ -1747,10 +1292,13 @@ fn main() { Commands::Install => { let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string()); let skill_dir = PathBuf::from(&home).join(".claude/skills/adom-lbr");- fs::create_dir_all(&skill_dir).ok();- fs::write(skill_dir.join("SKILL.md"), SKILL).ok();- println!("Installed skill: adom-lbr -> {}", skill_dir.display());- println!("Installed adom-lbr v{}", VERSION.trim());+ fs::create_dir_all(&skill_dir).unwrap_or_else(|e| {+ eprintln!("ERROR: create {}: {e}", skill_dir.display());+ process::exit(1);+ });+ write_or_exit(&skill_dir.join("SKILL.md"), SKILL);+ println!("OK: installed skill adom-lbr -> {}", skill_dir.display());+ println!("OK: installed adom-lbr v{}", VERSION.trim()); } Commands::Health => {
src/manage.html+350−129@@ -4,49 +4,80 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>adom-lbr Manager</title>+<script type="module" src="https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"></script> <style>- :root{--bg:#0d1117;--panel:#171a21;--edge:#222b38;--ink:#e6edf3;--mut:#8b949e;--acc:#00b8b1;--dot:#00e6dc}+ :root{--bg:#0f1115;--panel:#171a21;--edge:#252a34;--ink:#e6e9ef;--mut:#8b93a3;--acc:#6ea8fe} *{box-sizing:border-box}- body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink);height:100vh;display:flex;flex-direction:column}- /* Canonical Adom app-shell header (matches adom-symbol / adom-footprint). */- #appbar{display:flex;align-items:center;gap:10px;padding:12px 18px;border-bottom:1px solid var(--edge);background:var(--panel);flex:0 0 auto}- #appbar .dot{width:10px;height:10px;border-radius:50%;background:var(--dot);box-shadow:0 0 12px var(--dot)}- #appbar .brand{font-weight:700}- #appbar .brand span{color:var(--mut);font-weight:400}- #appbar .legend{margin-left:auto;display:inline-flex;align-items:center;gap:10px;font-size:11px;color:var(--mut)}- #appbar .legend .pip{width:9px;height:9px}- #shell{flex:1;display:flex;min-height:0}- /* Sidebar: tabs (source) → search + complete toggle → scrolling list. */- #list{width:340px;min-width:340px;border-right:1px solid var(--edge);background:var(--panel);display:flex;flex-direction:column;min-height:0}- .tabs{display:flex;gap:2px;padding:8px 8px 0;flex-wrap:wrap}- .tab{font-size:12px;padding:6px 10px;border-radius:7px 7px 0 0;color:var(--mut);cursor:pointer;border:1px solid transparent;border-bottom:none;white-space:nowrap;max-width:130px;overflow:hidden;text-overflow:ellipsis}- .tab:hover{color:var(--ink)}- .tab.sel{color:var(--ink);background:var(--bg);border-color:var(--edge)}- .srcline{font-size:11px;color:var(--mut);padding:4px 14px;border-bottom:1px solid var(--edge)}- .ctl{display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:1px solid var(--edge)}- #q{flex:1;padding:8px 10px;background:#0d0f14;border:1px solid var(--edge);border-radius:8px;color:var(--ink)}- .only{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--mut);cursor:pointer;user-select:none;white-space:nowrap}- .only input{margin:0}+ body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink);height:100vh;display:flex}+ #list{width:300px;min-width:300px;border-right:1px solid var(--edge);background:var(--panel);display:flex;flex-direction:column}+ #list h1{font-size:15px;margin:0;padding:14px 16px;border-bottom:1px solid var(--edge);letter-spacing:.02em}+ #list h1 small{color:var(--mut);font-weight:400}+ #refresh{float:right;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:6px;width:26px;height:24px;font-size:14px;cursor:pointer;line-height:1}+ #refresh:hover{background:#243049;border-color:var(--acc)}+ #crumb{padding:7px 16px 0;font-size:12px;color:var(--mut)}+ #crumb a{color:var(--acc);text-decoration:none}+ #crumb a:hover{text-decoration:underline}+ #crumb .cactive{color:var(--ink);font-weight:600}+ .row .tag{display:inline-block;font-size:10px;background:#243049;color:var(--acc);border-radius:4px;padding:1px 5px;margin-left:6px;vertical-align:1px}+ .cathead{padding:7px 16px 5px;font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--acc);background:#12151c;border-bottom:1px solid var(--edge);font-weight:700;position:sticky;top:0;z-index:1}+ .pubsearch{margin-top:6px;border-top:1px solid var(--edge)}+ .pubsearch .pubrow{display:flex;gap:8px;margin:8px 12px}+ .pubsearch input{flex:1;min-width:0;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:9px 10px;font-size:13px}+ .pubsearch input:focus{outline:none;border-color:var(--acc)}+ .pubsearch button{background:var(--acc);color:#08111f;border:none;border-radius:8px;padding:0 13px;font-size:19px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}+ .pubsearch button:hover{filter:brightness(1.1)}+ .row .selbox{margin-right:9px;vertical-align:middle;cursor:pointer}+ #toolbar{border-bottom:1px solid var(--edge);padding:8px 12px;display:flex;flex-direction:column;gap:7px}+ #toolbar .selall{font-size:12px;color:var(--mut);cursor:pointer;user-select:none;display:flex;align-items:center;gap:6px}+ #toolbar .tbtns{display:flex;gap:7px}+ #toolbar button{flex:1;background:#1a2130;border:1px solid var(--edge);color:var(--ink);border-radius:7px;padding:7px 8px;font-size:12px;cursor:pointer}+ #toolbar button:hover{border-color:var(--acc);background:#22304a}+ #toolbar button.primary{background:var(--acc);color:#08111f;border-color:var(--acc);font-weight:600}+ #toolbar button:disabled{opacity:.45;cursor:not-allowed}+ .modal-overlay{position:fixed;inset:0;background:#000a;display:flex;align-items:center;justify-content:center;z-index:1000}+ .modal{background:var(--panel);border:1px solid var(--edge);border-radius:14px;width:min(760px,92vw);max-height:88vh;overflow:auto;padding:22px 24px;box-shadow:0 20px 60px #000a}+ .modal h2{margin:0 0 4px;font-size:18px}+ .modal .msub{color:var(--mut);font-size:13px;margin-bottom:16px}+ .modal .opts{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px}+ .modal .opt{border:1px solid var(--edge);border-radius:9px;padding:9px 13px;cursor:pointer;font-size:13px;display:flex;gap:8px;align-items:center}+ .modal .opt.sel{border-color:var(--acc);background:#1a2740}+ .modal table{width:100%;margin:0 0 6px}+ .modal .sure{margin:18px 0 10px;font-size:15px;font-weight:600}+ .modal-actions{display:flex;gap:10px;justify-content:flex-end}+ .modal-actions button{background:#1a2130;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:9px 18px;font-size:14px;cursor:pointer}+ .modal-actions button.primary{background:var(--acc);color:#08111f;border-color:var(--acc);font-weight:700}+ .modal .lib{font:12px ui-monospace,monospace;color:#7ee0c0}+ .modal .note{background:#12151c;border:1px solid var(--edge);border-radius:8px;padding:10px 12px;font-size:12px;color:var(--mut);margin:6px 0 4px}+ #modal-dest{margin:0 0 16px}+ #modal-dest label{display:block;font-size:11px;color:var(--mut);margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em}+ #modal-dest .destrow{display:flex;gap:8px}+ #modal-dest select,#modal-dest input{background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:8px 10px}+ #modal-dest select{min-width:200px}+ #modal-dest input{flex:1;font:12px ui-monospace,monospace}+ #modal-dest input:focus,#modal-dest select:focus{outline:none;border-color:var(--acc)}+ #refresh.spin{animation:sp .6s linear infinite}+ @keyframes sp{to{transform:rotate(360deg)}}+ #q{margin:10px;padding:8px 10px;background:#0d0f14;border:1px solid var(--edge);border-radius:8px;color:var(--ink)} #rows{overflow:auto;flex:1}- .row{display:flex;align-items:center;gap:8px;padding:9px 14px;border-bottom:1px solid var(--edge);cursor:pointer}+ .row{padding:10px 16px;border-bottom:1px solid var(--edge);cursor:pointer} .row:hover{background:#1e222b}- .row.sel{background:#123331;border-left:3px solid var(--acc);padding-left:11px}- .row .meta{flex:1;min-width:0}- .row .mpn{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}- .row .sub{color:var(--mut);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}- .pips{display:flex;gap:3px;flex:0 0 auto;cursor:help}- .pip{width:9px;height:9px;border-radius:50%;border:1.5px solid var(--mut);opacity:.4}- .pip.on{background:var(--acc);border-color:var(--acc);opacity:1}- .endmsg{color:var(--mut);text-align:center;font-size:12px;padding:12px}- /* Viewer */+ .row.sel{background:#243049;border-left:3px solid var(--acc);padding-left:13px}+ .row .mpn{font-weight:600}+ .row .sub{color:var(--mut);font-size:12px} #view{flex:1;overflow:auto;padding:18px} #hint{color:var(--mut);text-align:center;margin-top:20vh} .cols{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px} .card{background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden;display:flex;flex-direction:column} .card h3{margin:0;padding:9px 12px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);border-bottom:1px solid var(--edge)}- .card h3 small{text-transform:none;letter-spacing:0;color:#5b6675;font-weight:400}- .card iframe.embed{width:100%;height:460px;border:0;background:#161b22;display:block}- .card .body{aspect-ratio:1;width:100%;display:flex;align-items:center;justify-content:center;background:#161b22;color:#8b93a3}+ .card .body{aspect-ratio:1;width:100%;display:flex;align-items:center;justify-content:center;padding:10px;background:#161b22;overflow:hidden;cursor:grab;touch-action:none;position:relative}+ .card .body svg{max-width:100%;max-height:100%;will-change:transform}+ .card .hint2{position:absolute;bottom:6px;right:8px;font-size:10px;color:#b9c0cc;background:#0d0f14aa;border-radius:5px;padding:1px 6px;pointer-events:none}+ .lfilter{display:flex;flex-wrap:wrap;gap:6px;padding:8px 10px;border-bottom:1px solid var(--edge)}+ .chip{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--ink);background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:3px 7px;cursor:pointer;user-select:none}+ .chip input{margin:0;cursor:pointer}+ .chip .sw{width:9px;height:9px;border-radius:2px;display:inline-block}+ .chip:has(input:not(:checked)){opacity:.4;text-decoration:line-through}+ model-viewer{width:100%;aspect-ratio:1;height:auto;background:#161b22} h2.title{margin:0 0 14px} h2.title small{color:var(--mut);font-weight:400;font-size:14px} table{width:100%;border-collapse:collapse;margin-top:16px;background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden}@@ -57,121 +88,311 @@ .badge{font:12px ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:2px 7px} .stacktitle{margin:22px 0 6px;font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut)} .err{color:#f87171;padding:16px}+ .card .body [data-num]{cursor:pointer}+ .pinhi .pinvis{stroke:#6ea8fe !important;vector-effect:non-scaling-stroke;stroke-width:3}+ rect.pad.pinhi{stroke:#6ea8fe;vector-effect:non-scaling-stroke;stroke-width:3}+ #tip{position:fixed;z-index:9999;background:#0d1117;border:1px solid var(--acc);color:var(--ink);font:12px ui-monospace,monospace;padding:3px 8px;border-radius:6px;pointer-events:none;display:none;white-space:nowrap}+ #tip b{color:var(--acc)} </style> </head> <body>- <header id="appbar">- <span class="dot"></span>- <span class="brand">adom-lbr <span>· Library Manager</span></span>- <span class="legend"><span class="pips"><span class="pip on"></span><span class="pip on"></span><span class="pip on"></span></span>= symbol · footprint · 3D present</span>- </header>- <div id="shell">- <aside id="list">- <div class="tabs" id="tabs"></div>- <div class="srcline" id="srcline"></div>- <div class="ctl">- <input id="q" placeholder="filter / search…">- <label class="only" title="Only show parts that have symbol + footprint + 3D"><input type="checkbox" id="only">complete</label>+ <aside id="list">+ <h1>adom-lbr <small>Manager</small><button id="refresh" title="refresh list (fetch newly-pushed boards)">↻</button></h1>+ <div id="crumb"></div>+ <input id="q" placeholder="filter boards…">+ <div id="toolbar" style="display:none">+ <label class="selall"><input type="checkbox" id="selall"> <span id="selcount">select all</span></label>+ <div class="tbtns">+ <button id="btn-addlib" disabled>Add to Library</button>+ <button id="btn-addproj">Add Project ↓</button> </div>- <div id="rows"></div>- </aside>- <main id="view"><div id="hint">← pick a component to open its viewer</div></main>- </div>+ </div>+ <div id="rows"></div>+ </aside>+ <main id="view"><div id="hint">← pick a board, then a component, to open its viewer</div></main>+ <div id="modal" class="modal-overlay" style="display:none"><div class="modal">+ <h2 id="modal-title"></h2><div class="msub" id="modal-sub"></div>+ <div class="opts" id="modal-opts"></div>+ <div id="modal-dest"></div>+ <div id="modal-preview"></div>+ <div class="sure">Are you sure this is good?</div>+ <div class="modal-actions"><button id="modal-cancel">Cancel</button><button class="primary" id="modal-go">Yes, add it</button></div>+ </div></div>+ <script> const COLORS={Silkscreen:'#d8b4fe',Courtyard:'#4ade80','Fabrication / Assembly':'#f472b6',Copper:'#f59e0b',Other:'#94a3b8'};-const LIMIT=50;-let tabs=[], curTab=null, offset=0, loading=false, done=false, seen=new Set();-const rowsEl=document.getElementById('rows');--function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}-function cssq(s){return String(s).replace(/["\\]/g,'\\$&');}--async function loadTabs(){- let r; try{ r=await fetch('api/tabs').then(x=>x.json()); }catch(e){ document.getElementById('tabs').innerHTML='<div class="endmsg">failed to load</div>'; return; }- tabs=r.tabs||[];- const tb=document.getElementById('tabs'); tb.innerHTML='';- tabs.forEach(t=>{- const d=document.createElement('div'); d.className='tab'; d.textContent=t.label; d.title=t.label;- d.onclick=()=>selectTab(t.key); tb.appendChild(d); t._el=d;- });- selectTab((tabs[0]&&tabs[0].key)||'public');+// Board-centric navigation: Boards (tagged board/molecule) ▸ a board's component+// dependencies ▸ the symbol/footprint/3D viewer.+let all=[], bySlug={}, boardSlug=null, curDeps=[], me='', pubQuery='';+const isBoard=c=>{const t=(c.tags||[]).map(x=>(''+x).toLowerCase());return t.includes('board')||t.includes('molecule');};+const qval=()=>document.getElementById('q').value.toLowerCase();+const rowsEl=()=>document.getElementById('rows');+function makeRow(slug,title,sub,onclick,tag,sel){+ const d=document.createElement('div');d.className='row';d.dataset.slug=slug;+ const cb=sel?`<input type="checkbox" class="selbox"${selected.has(slug)?' checked':''}>`:'';+ d.innerHTML=`<div class="mpn">${cb}${title}${tag?`<span class="tag">${tag}</span>`:''}</div><div class="sub">${sub}</div>`;+ d.onclick=(e)=>{ if(e.target.classList.contains('selbox')){toggleSel(slug,e.target.checked);e.stopPropagation();return;} onclick(slug,d); };+ return d; }-function srcText(t){- if(t.source==='local') return 'local container · ~/project/library';- if(t.key==='public') return 'wiki.adom.inc · public · scroll to browse, type to search';- if(t.key==='mine') return 'wiki.adom.inc · authored by you';- return 'wiki.adom.inc · org: '+t.label;+// component-type → the user's existing KiCad symbol library (fallback JLC_Misc_BLT)+const TYPE_TO_SYMLIB={'Capacitors':'JLC_Capacitors_BLT','Resistors':'JLC_Resistors_BLT','MOSFETs':'JLC_FETs_BLT','Transistors':'JLC_FETs_BLT','Inductors':'JLC_Inductors_BLT','LEDs':'JLC_LEDs_BLT','Diodes':'JLC_Diodes_BLT','Connectors':'JLC_Connectors_BLT','Microcontrollers':'JLC_MCUs_BLT','Power ICs':'JLC_Power_BLT','Crystals & Oscillators':'JLC_Oscillators_BLT','ICs':'JLC_Misc_BLT','Other':'JLC_Misc_BLT'};+const selected=new Set();+function symLibFor(c){return TYPE_TO_SYMLIB[typeOf(c)]||'JLC_Misc_BLT';}+function toggleSel(slug,on){on?selected.add(slug):selected.delete(slug);updateToolbar();}+function updateToolbar(){+ const n=selected.size;+ const cnt=document.getElementById('selcount'); if(cnt)cnt.textContent=n?`${n} selected · clear`:'select all';+ const b=document.getElementById('btn-addlib'); if(b){b.disabled=n===0;b.textContent=n?`Add ${n} to Library`:'Add to Library';}+ const sa=document.getElementById('selall'); if(sa)sa.checked=curDeps.length>0&&curDeps.every(s=>selected.has(s)); }-function selectTab(key){- curTab=key; offset=0; done=false; loading=false; seen=new Set();- tabs.forEach(t=>t._el&&t._el.classList.toggle('sel',t.key===key));- const t=tabs.find(x=>x.key===key);- document.getElementById('srcline').textContent=t?srcText(t):'';- rowsEl.innerHTML=''; loadMore();+async function loadList(fresh){+ const btn=document.getElementById('refresh'); if(btn&&fresh)btn.classList.add('spin');+ if(!fresh) rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading boards from wiki…</div>';+ try{+ const r=await fetch('api/components'+(fresh?'?refresh=1':'')).then(r=>r.json());+ all=r.components||[]; me=r.me||''; bySlug={}; all.forEach(c=>bySlug[c.slug]=c);+ render();+ }catch(e){+ rowsEl().innerHTML='<div class="row" style="color:#f87171;cursor:default">failed to load — retry</div>';+ }finally{ if(btn)btn.classList.remove('spin'); } }--async function loadMore(){- if(loading||done||!curTab) return; loading=true;- const q=document.getElementById('q').value.trim();- const only=document.getElementById('only').checked?1:0;- const url=`api/list?tab=${encodeURIComponent(curTab)}&offset=${offset}&limit=${LIMIT}&q=${encodeURIComponent(q)}&complete=${only}`;- let r; try{ r=await fetch(url).then(x=>x.json()); }catch(e){ loading=false; return; }- const items=r.items||[];- const fresh=items.filter(it=>!seen.has(it.token)); fresh.forEach(it=>seen.add(it.token));- fresh.forEach(addRow);- offset = (typeof r.next==='number') ? r.next : offset+items.length;- done = !r.more;- loading=false;- fillPips(fresh);- if(done){- if(!rowsEl.querySelector('.row')) rowsEl.insertAdjacentHTML('beforeend','<div class="endmsg">no components</div>');- else rowsEl.insertAdjacentHTML('beforeend','<div class="endmsg">— end —</div>');- } else if(rowsEl.scrollHeight<=rowsEl.clientHeight+40){ loadMore(); } // fill the viewport+function render(){ boardSlug?renderDeps():renderBoards(); }+function renderBoards(){+ document.getElementById('toolbar').style.display='none';selected.clear();+ document.getElementById('crumb').innerHTML='<span class="cactive">Projects</span>';+ document.getElementById('q').placeholder='filter projects…';+ const q=qval(),el=rowsEl();el.innerHTML='';+ const boards=all.filter(isBoard);+ const mineAll=boards.filter(c=>c.owner&&c.owner===me);+ const pubAll=boards.filter(c=>!(c.owner&&c.owner===me));+ // My projects — a normal list (filtered by the top box)+ const mine=mineAll.filter(c=>!q||c.slug.includes(q)||(c.title||'').toLowerCase().includes(q));+ const h=document.createElement('div');h.className='cathead';h.textContent=`My projects · ${mine.length}`;el.appendChild(h);+ if(!mine.length){const d=document.createElement('div');d.className='row';d.style.cssText='color:#8b93a3;cursor:default;line-height:1.5';d.innerHTML='none yet — push a board tagged <b>board</b>/<b>molecule</b> to your wiki, then press ↻';el.appendChild(d);}+ mine.forEach(c=>el.appendChild(makeRow(c.slug,c.title||c.slug,c.slug,openBoard,'board')));+ // Public projects — type a query, press Enter or the button to load matches+ const wrap=document.createElement('div');wrap.className='pubsearch';+ wrap.innerHTML=`<div class="cathead">Public projects</div><div class="pubrow"><input id="pubq" placeholder="search public projects"><button id="pubgo" title="search">⏎</button></div><div id="pubresults"></div>`;+ el.appendChild(wrap);+ const pubq=document.getElementById('pubq');pubq.value=pubQuery;+ const renderPub=()=>{+ const pr=document.getElementById('pubresults');pr.innerHTML='';const pq=pubQuery.trim().toLowerCase();+ if(!pq)return;+ const m=pubAll.filter(c=>c.slug.includes(pq)||(c.title||'').toLowerCase().includes(pq)||(c.brief||'').toLowerCase().includes(pq));+ if(!m.length){pr.innerHTML=`<div class="row" style="color:#8b93a3;cursor:default">no public projects match “${pubQuery.trim()}”</div>`;return;}+ m.forEach(c=>pr.appendChild(makeRow(c.slug,c.title||c.slug,c.slug,openBoard,'board')));+ };+ const doPub=()=>{pubQuery=pubq.value;renderPub();};+ pubq.onkeydown=e=>{if(e.key==='Enter')doPub();};+ document.getElementById('pubgo').onclick=doPub;+ renderPub();+}+async function openBoard(slug){+ boardSlug=slug; curDeps=[]; selected.clear();+ const title=(bySlug[slug]||{}).title||slug;+ document.getElementById('crumb').innerHTML=`<a href="#" onclick="backToBoards();return false">← Boards</a> / <span class="cactive">${title}</span>`;+ rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading dependencies…</div>';+ try{ const r=await fetch('api/board/'+slug).then(r=>r.json()); curDeps=(r.deps||[]).map(d=>d.slug); }+ catch(e){ curDeps=[]; }+ renderDeps(); }-function addRow(it){- const d=document.createElement('div'); d.className='row'; d.dataset.token=it.token;- d.innerHTML=`<div class="meta"><div class="mpn">${esc(it.title)}</div><div class="sub">${esc(it.id)}</div></div>`- +`<div class="pips" data-token="${esc(it.token)}" title="symbol · footprint · 3D"><div class="pip" data-k="s"></div><div class="pip" data-k="f"></div><div class="pip" data-k="d"></div></div>`;- d.onclick=()=>openPart(it,d);- rowsEl.appendChild(d);+// Infer a component's type from its tags + brief + title (most parts carry no+// explicit category, so keywords in the brief are the signal).+const TYPE_RULES=[+ [/microcontroller|\bmcu\b|\bmpu\b|\bsoc\b/,'Microcontrollers'],+ [/regulator|\bldo\b|dc-?dc|buck|boost|power management|\bpmic\b/,'Power ICs'],+ [/mosfet|\bfet\b/,'MOSFETs'],+ [/transistor|\bbjt\b|\bnpn\b|\bpnp\b/,'Transistors'],+ [/\bled\b/,'LEDs'],+ [/diode|schottky|zener|\btvs\b|rectifier/,'Diodes'],+ [/mlcc|capacitor|\bcap\b|tantalum/,'Capacitors'],+ [/resistor|thick film|thin film/,'Resistors'],+ [/inductor|chip coil|\bcoil\b|ferrite|choke|\bbead\b/,'Inductors'],+ [/crystal|oscillator|resonator/,'Crystals & Oscillators'],+ [/connector|header|receptacle|socket|\bjack\b|\busb\b/,'Connectors'],+ [/switch|button|tactile/,'Switches'],+ [/\bfuse\b/,'Fuses'],+ [/amplifier|op-?amp|\bdriver\b|sensor|\badc\b|\bdac\b|transceiver|controller|\bgate\b|logic|\bic\b/,'ICs'],+];+const TYPE_ORDER=['Microcontrollers','ICs','Power ICs','MOSFETs','Transistors','Diodes','LEDs','Resistors','Capacitors','Inductors','Crystals & Oscillators','Connectors','Switches','Fuses','Other'];+function typeOf(c){+ const hay=(((c.tags||[]).join(' '))+' '+(c.brief||'')+' '+(c.title||'')+' '+(c.slug||'')).toLowerCase();+ for(const [re,label] of TYPE_RULES) if(re.test(hay)) return label;+ return 'Other'; }-async function fillPips(items){- if(!items.length) return;- const ids=items.map(i=>i.token).join(',');- let r; try{ r=await fetch('api/complete?ids='+encodeURIComponent(ids)).then(x=>x.json()); }catch(e){ return; }- const c=r.complete||{};- items.forEach(it=>{- const box=rowsEl.querySelector(`.pips[data-token="${cssq(it.token)}"]`); if(!box) return;- const f=c[it.token]||{};- ['s','f','d'].forEach(k=>{const p=box.querySelector(`.pip[data-k="${k}"]`); if(p)p.classList.toggle('on',!!f[k]);});+function renderDeps(){+ document.getElementById('q').placeholder='filter components…';+ document.getElementById('toolbar').style.display='flex';+ const q=qval(),el=rowsEl();el.innerHTML='';+ if(!curDeps.length){el.innerHTML='<div class="row" style="color:#8b93a3;cursor:default">this board lists no component dependencies</div>';updateToolbar();return;}+ const items=curDeps.map(s=>({slug:s,c:bySlug[s]||{slug:s}}))+ .filter(x=>!q||x.slug.includes(q)||((x.c.title||'').toLowerCase().includes(q))||((x.c.brief||'').toLowerCase().includes(q)));+ const groups={};+ items.forEach(x=>{const t=typeOf(x.c);(groups[t]=groups[t]||[]).push(x);});+ const cats=Object.keys(groups).sort((a,b)=>{const ia=TYPE_ORDER.indexOf(a),ib=TYPE_ORDER.indexOf(b);return (ia<0?99:ia)-(ib<0?99:ib)||a.localeCompare(b);});+ cats.forEach(cat=>{+ const h=document.createElement('div');h.className='cathead';h.textContent=`${cat} · ${groups[cat].length}`;el.appendChild(h);+ groups[cat].forEach(x=>{const on=bySlug[x.slug];el.appendChild(makeRow(x.slug,x.c.title||x.slug,on?x.slug:x.slug+' · not on wiki',openPart,null,true));}); });+ updateToolbar(); }-rowsEl.addEventListener('scroll',()=>{- if(rowsEl.scrollTop+rowsEl.clientHeight>=rowsEl.scrollHeight-120) loadMore();-});-let qto=null;-document.getElementById('q').oninput=()=>{ clearTimeout(qto); qto=setTimeout(()=>selectTab(curTab),250); };-document.getElementById('only').onchange=()=>selectTab(curTab);--async function openPart(it,el){+function backToBoards(){boardSlug=null;curDeps=[];document.getElementById('q').value='';document.querySelector('#view #hint')||(document.getElementById('view').innerHTML='<div id="hint">← pick a board, then a component, to open its viewer</div>');render();}+async function openPart(slug,el){ document.querySelectorAll('.row').forEach(r=>r.classList.remove('sel')); if(el)el.classList.add('sel');- const v=document.getElementById('view'); const tok=it.token;- v.innerHTML='<div id="hint">loading '+esc(it.id)+'…</div>';- let p; try{ p=await fetch('api/part/'+tok).then(x=>x.json()); }catch(e){ v.innerHTML='<div class="err">failed to load</div>'; return; }- const glb=(p&&p.glb)?`<iframe class="embed" src="viewer3d/${tok}" loading="lazy" title="3D"></iframe>`:'<div class="body">no 3D model</div>';- const layers=(p&&p.layers)||[];- const rows=layers.map(l=>`<tr><td><span class="swatch" style="background:${COLORS[l.role]||'#888'}"></span>${l.role}<span style="color:#8b93a3"> ×${l.count}</span></td><td><span class="badge">${l.kicad}</span></td><td><span class="badge">${l.altium}</span></td></tr>`).join('');+ const v=document.getElementById('view');+ v.innerHTML='<div id="hint">loading '+slug+'…</div>';+ const p=await fetch('api/part/'+slug).then(r=>r.json());+ if(p.status!=='ok'){v.innerHTML='<div class="err">'+(p.error||'failed')+'</div>';return;}+ const glb=p.glb?`<model-viewer src="glb/${slug}" camera-controls auto-rotate ar shadow-intensity="1"></model-viewer>`:'<div class="body" style="color:#888;background:#0d0f14">no 3D model</div>';+ let rows=(p.layers||[]).map(l=>`<tr>+ <td><span class="swatch" style="background:${COLORS[l.role]||'#888'}"></span>${l.role}<span style="color:#8b93a3"> ×${l.count}</span></td>+ <td><span class="badge">${l.kicad}</span></td>+ <td><span class="badge">${l.altium}</span></td></tr>`).join(''); v.innerHTML=`- <h2 class="title">${esc((p&&p.mpn)||it.title)} <small>· ${(p&&p.pins)||0} pins · ${(p&&p.pads)||0} pads</small></h2>+ <h2 class="title">${p.mpn} <small>· ${p.pins} pins · ${p.pads} pads</small></h2> <div class="cols">- <div class="card"><h3>Schematic Symbol <small>· via adom-symbol</small></h3><iframe class="embed" src="embed/symbol/${tok}" loading="lazy" title="symbol"></iframe></div>- <div class="card"><h3>Footprint <small>· via adom-footprint</small></h3><iframe class="embed" src="embed/footprint/${tok}" loading="lazy" title="footprint"></iframe></div>+ <div class="card"><h3>Schematic Symbol</h3><div class="body">${p.symbol_svg}<span class="hint2">drag · scroll to zoom</span></div></div>+ <div class="card"><h3>Footprint</h3>+ <div class="lfilter">${(p.layers||[]).map(l=>`<label class="chip"><input type="checkbox" checked data-key="${l.key}"><span class="sw" style="background:${COLORS[l.role]||'#888'}"></span>${l.role}</label>`).join('')}</div>+ <div class="body" id="fpbody">${p.footprint_svg}<span class="hint2">drag · scroll to zoom</span></div></div> <div class="card"><h3>3D Model</h3>${glb}</div> </div>- ${rows?`<div class="stacktitle">Footprint layer stackup — what each element becomes per EDA tool</div><table><thead><tr><th>Element (neutral role)</th><th>KiCad layer</th><th>Altium layer</th></tr></thead><tbody>${rows}</tbody></table>`:''}`;+ <div class="stacktitle">Footprint layer stackup — what each element becomes per EDA tool</div>+ <table><thead><tr><th>Element (neutral role)</th><th>KiCad layer</th><th>Altium layer</th></tr></thead>+ <tbody>${rows}</tbody></table>`;+ v.querySelectorAll('.card .body').forEach(makePannable);+ // footprint layer filter — toggle visibility of each layer's SVG elements+ v.querySelectorAll('.lfilter input').forEach(cb=>{+ cb.onchange=()=>{+ v.querySelectorAll('#fpbody svg .ly-'+cb.dataset.key).forEach(el=>{el.style.display=cb.checked?'':'none';});+ };+ });+ setupHighlight(v);+}++// Hover a pin (symbol) or pad (footprint) → highlight it + show its number and+// name/description in a tooltip. Pins/pads carry data-num + data-name.+function setupHighlight(v){+ let tip=document.getElementById('tip');+ if(!tip){tip=document.createElement('div');tip.id='tip';document.body.appendChild(tip);}+ const clear=box=>box.querySelectorAll('.pinhi').forEach(x=>x.classList.remove('pinhi'));+ v.querySelectorAll('.card .body').forEach(box=>{+ box.addEventListener('pointermove',e=>{+ const hit=e.target.closest('[data-num]');+ clear(box);+ if(hit){+ hit.classList.add('pinhi');+ const num=hit.dataset.num, nm=hit.dataset.name;+ const kind=hit.closest('#fpbody')?'Pad':'Pin';+ tip.innerHTML=(nm&&nm!=='~')?`<b>${kind} ${num}</b> · ${nm}`:`<b>${kind} ${num}</b>`;+ tip.style.display='block';+ tip.style.left=(e.clientX+14)+'px';+ tip.style.top=(e.clientY+14)+'px';+ } else { tip.style.display='none'; }+ });+ box.addEventListener('pointerleave',()=>{clear(box);tip.style.display='none';});+ });+}++// Drag-to-pan + scroll-to-zoom on an SVG viewer box (pointer capture so a drag+// that leaves the box keeps tracking). Re-assigning on* handlers avoids stacking.+function makePannable(box){+ const svg=box.querySelector('svg');+ if(!svg) return;+ let tx=0,ty=0,scale=1,sx=0,sy=0,drag=false;+ const apply=()=>{svg.style.transform=`translate(${tx}px,${ty}px) scale(${scale})`;};+ box.onpointerdown=e=>{drag=true;sx=e.clientX-tx;sy=e.clientY-ty;box.setPointerCapture(e.pointerId);box.style.cursor='grabbing';};+ box.onpointermove=e=>{if(drag){tx=e.clientX-sx;ty=e.clientY-sy;apply();}};+ box.onpointerup=box.onpointercancel=()=>{drag=false;box.style.cursor='grab';};+ box.onwheel=e=>{e.preventDefault();const f=e.deltaY<0?1.12:0.89;scale=Math.max(0.25,Math.min(12,scale*f));apply();};+ box.ondblclick=()=>{tx=ty=0;scale=1;apply();};+}+// ---- selection controls + add-to-library / add-project modal ----+document.getElementById('selall').onchange=(e)=>{const on=e.target.checked;curDeps.forEach(s=>on?selected.add(s):selected.delete(s));renderDeps();};+function previewTable(plan){+ return `<table><thead><tr><th>Component</th><th>Type</th><th>Symbol → library</th><th>Footprint → library</th></tr></thead><tbody>${+ plan.map(p=>`<tr><td>${p.title}</td><td>${p.type}</td><td><span class="lib">${p.symLib}.kicad_sym</span></td><td><span class="lib">${p.fpLib}.pretty</span></td></tr>`).join('')}</tbody></table>`; }-loadTabs();+function buildPlan(slugs,projectLib,projName){+ return slugs.map(s=>{const c=bySlug[s]||{slug:s,title:s};const t=typeOf(c);+ return {slug:s,title:c.title||s,type:t,symLib:projectLib?projName:symLibFor(c),fpLib:projectLib?projName:'adom_lbr'};});+}+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;+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)+const DEST_FOLDERS=['/home/barrett-land/Documents/KiCAD/Projects','/home/barrett-land/Documents/Adom/Molecules/Other Molecules','/home/barrett-land/Documents/Adom/Electrical','/home/barrett-land/Downloads'];+function openAddLib(){+ const slugs=[...selected]; if(!slugs.length)return;+ const plan=buildPlan(slugs,false);+ document.getElementById('modal-dest').innerHTML='';+ document.getElementById('modal-title').textContent='Add to Library';+ document.getElementById('modal-sub').textContent=`${slugs.length} component(s) → your existing KiCad libraries`;+ document.getElementById('modal-opts').innerHTML=`<div class="opt sel" data-eda="kicad"><input type="radio" name="eda" checked> KiCad</div><div class="opt" data-eda="altium"><input type="radio" name="eda"> Altium (soon)</div>`;+ const go=document.getElementById('modal-go');+ selOpt('eda','eda',v=>{document.getElementById('modal-preview').innerHTML=v==='altium'?'<div class="note">Altium add-to-library isn\'t wired up yet — pick KiCad for now.</div>':previewTable(plan);go.disabled=v==='altium';});+ document.getElementById('modal-preview').innerHTML=previewTable(plan);+ go.disabled=false;go.textContent='Yes, add it';+ modalGo=()=>doApply(plan,'kicad',false);+ showModal();+}+function openAddProj(){+ const slugs=curDeps.slice();+ const projName=((bySlug[boardSlug]||{}).title||boardSlug).replace(/[^A-Za-z0-9_-]+/g,'_');+ let mode='personal';+ document.getElementById('modal-title').textContent='Add Project to my Computer';+ document.getElementById('modal-sub').textContent=`schematic + ${slugs.length} component(s) — symbols & footprints resolved so it opens clean`;+ document.getElementById('modal-opts').innerHTML=`<div class="opt sel" data-mode="personal"><input type="radio" name="mode" checked> Integrate into my libraries</div><div class="opt" data-mode="project"><input type="radio" name="mode"> Project-only library</div>`;+ // editable destination + recommended-folder presets+ document.getElementById('modal-dest').innerHTML=`<label>Deliver project to</label><div class="destrow"><select id="destsel">${DEST_FOLDERS.map(f=>`<option value="${f}">${f.replace('/home/barrett-land','~')}</option>`).join('')}</select><input id="destpath" spellcheck="false"></div>`;+ const destpath=document.getElementById('destpath'), destsel=document.getElementById('destsel');+ destpath.value=DEST_FOLDERS[0]+'/'+boardSlug;+ destsel.onchange=()=>{destpath.value=destsel.value+'/'+boardSlug;};+ const draw=()=>{+ const plan=buildPlan(slugs,mode==='project',projName);+ document.getElementById('modal-preview').innerHTML=`<div class="note">${mode==='project'?'A self-contained <b>'+projName+'</b> symbol + footprint library is created for this project — your personal libraries stay untouched.':'Symbols merge into your existing per-type libraries; footprints go to <b>adom_lbr.pretty</b>. The schematic library references get registered so nothing shows as broken.'}</div>`+previewTable(plan);+ modalGo=()=>doApply(plan,'kicad',mode==='project',boardSlug,document.getElementById('destpath').value);+ };+ selOpt('mode','mode',v=>{mode=v;draw();});+ document.getElementById('modal-go').disabled=false;document.getElementById('modal-go').textContent='Yes, add the project';+ draw();showModal();+}+async function doApply(plan,eda,project,board,dest){+ const go=document.getElementById('modal-go');go.disabled=true;go.textContent='Starting…';+ const opts=document.getElementById('modal-opts');+ const start=await fetch('api/apply',{method:'POST',headers:{'Content-Type':'application/json'},+ body:JSON.stringify({target:eda,project:!!project,board:board||null,dest:dest||'',items:plan.map(p=>({slug:p.slug,symLib:p.symLib,fpLib:p.fpLib}))})}).then(r=>r.json()).catch(()=>({status:'error',error:'request failed'}));+ if(start.status!=='started'){alert(start.error||'failed');go.textContent='Yes, add it';go.disabled=false;return;}+ // live status bar above the preview+ let bar=document.getElementById('applybar');+ if(!bar){bar=document.createElement('div');bar.id='applybar';bar.className='note';opts.parentNode.insertBefore(bar,opts.nextSibling);}+ const poll=setInterval(async()=>{+ let p; try{p=await fetch('api/apply-progress').then(r=>r.json());}catch(e){return;}+ const pct=p.total?` · ${p.current}/${p.total}`:'';+ go.textContent=(p.done?'✓ ':'')+(p.stage||'Working…').replace(/…$/,'').slice(0,22)+'…';+ bar.innerHTML=`<b>${p.stage||'Working…'}</b>${pct}`;+ if(p.done){+ clearInterval(poll);+ const r=p.result||{}, pf=(r.project&&r.project.files||[]);+ go.textContent=`✓ Added ${r.count||0}`;+ const folder=(r.project&&r.project.folder||'').replace('/home/barrett-land','~');+ bar.innerHTML=`<b>✓ Done.</b> Merged ${r.count||0} symbol(s) into your libraries.`++ (pf.length?`<br>Delivered ${pf.length} project file(s) → <span class="lib">${folder}</span>`:'')++ ((r.errors&&r.errors.length)?`<br><span style="color:#f87171">${r.errors.length} issue(s): ${r.errors.slice(0,2).join('; ')}</span>`:'');+ setTimeout(hideModal,pf.length||(r.errors&&r.errors.length)?3600:1600);+ }+ },350);+}+document.getElementById('btn-addlib').onclick=openAddLib;+document.getElementById('btn-addproj').onclick=openAddProj;++document.getElementById('q').oninput=render;+document.getElementById('refresh').onclick=()=>loadList(true);+loadList(); </script> </body> </html>
src/manage.rs+565−371@@ -9,15 +9,17 @@ //! GET /api/part/<slug> {part, symbol_svg, footprint_svg, layers, glb} //! GET /glb/<slug> proxied 3D model (GLB) -use altium_codec::{AdomLbrPart, Footprint, FpGraphic, FpLayer, Graphic, Symbol};+use altium_codec::{export_kicad_footprint, export_kicad_symbol, AdomLbrPart, Footprint, FpGraphic, FpLayer, Graphic, Symbol}; use anyhow::Result; use serde_json::{json, Value};-use std::io::Cursor;+use std::io::{Cursor, Read}; use std::process::Command; use std::sync::Mutex; use tiny_http::{Header, Method, Response, Server}; const WIKI: &str = "https://wiki.adom.inc";+// TODO: detect the signed-in wiki user rather than hard-coding this owner.+const MY_OWNER: &str = "barrett-land"; /// The wiki component list is slow (~8s) but stable — fetch it once, serve /// instantly after. First click blocks; the rest are snappy.@@ -34,345 +36,61 @@ fn cached_list() -> String { } pub fn run(port: u16) -> Result<()> {- let server = Server::http(format!("0.0.0.0:{port}"))- .map_err(|e| anyhow::anyhow!("bind {port}: {e}"))?;+ // Loopback by default: /api/apply writes files and runs commands on the+ // user's machine via the bridge, so it must not be LAN-reachable.+ // ADOM_LBR_BIND=0.0.0.0 opts back in (e.g. Hydrogen Desktop port-forward).+ let bind = std::env::var("ADOM_LBR_BIND").unwrap_or_else(|_| "127.0.0.1".into());+ let server = Server::http(format!("{bind}:{port}"))+ .map_err(|e| anyhow::anyhow!("bind {bind}:{port}: {e}"))?; println!("OK: adom-lbr Manager on http://localhost:{port}"); println!("Hint: open it in a browser, or `adom-desktop desktop_open_url {{\"url\":\"http://localhost:{port}\"}}`."); println!("Hint: the component list is fetched from {WIKI}; click a row to open the viewer."); for mut req in server.incoming_requests() { let method = req.method().clone(); let url = req.url().to_string();- let resp = route(&method, &url);+ let mut body = String::new();+ if matches!(method, Method::Post) {+ // /api/apply bodies are small JSON — cap far above any real payload.+ let _ = req.as_reader().take(1024 * 1024).read_to_string(&mut body);+ }+ let resp = route(&method, &url, &body); let _ = req.respond(resp); } Ok(()) } -fn route(method: &Method, url: &str) -> Response<Cursor<Vec<u8>>> {- let (path, query) = url.split_once('?').unwrap_or((url, ""));- let q = |key: &str| -> Option<String> {- query.split('&').find_map(|kv| {- let (k, v) = kv.split_once('=')?;- if k == key { Some(urldecode(v)) } else { None }- })- };+fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {+ let path = url.split('?').next().unwrap_or(url); match (method, path) {+ (Method::Post, "/api/apply") => json_resp(&start_apply(body).to_string()),+ (Method::Get, "/api/apply-progress") => json_resp(&get_progress().to_string()), (Method::Get, "/") => html(INDEX_HTML),- (Method::Get, "/api/tabs") => json_resp(&tabs_config().to_string()),- (Method::Get, "/api/list") => {- let tab = q("tab").unwrap_or_else(|| "public".into());- let offset = q("offset").and_then(|s| s.parse().ok()).unwrap_or(0usize);- let limit = q("limit").and_then(|s| s.parse().ok()).unwrap_or(50usize).min(100);- let search = q("q").unwrap_or_default();- let complete = q("complete").as_deref() == Some("1");- json_resp(&list_for_tab(&tab, offset, limit, &search, complete).to_string())+ (Method::Get, "/api/components") => {+ // ?refresh=1 busts the server-side cache so newly-pushed components appear+ if url.contains("refresh=1") {+ *LIST_CACHE.lock().unwrap() = None;+ }+ json_resp(&cached_list()) }- (Method::Get, "/api/complete") => json_resp(&complete_batch(&q("ids").unwrap_or_default()).to_string()), (Method::Get, p) if p.starts_with("/api/part/") => {- let (source, id) = parse_token(&p["/api/part/".len()..]);- json_resp(&get_part(&source, &id).to_string())+ let slug = &p["/api/part/".len()..];+ json_resp(&get_part(slug).to_string())+ }+ (Method::Get, p) if p.starts_with("/api/board/") => {+ let slug = &p["/api/board/".len()..];+ json_resp(&board_deps(slug).to_string()) } (Method::Get, p) if p.starts_with("/glb/") => {- let (source, id) = parse_token(&p["/glb/".len()..]);- match fetch_component_bytes(&source, &id, "glb") {+ let slug = &p["/glb/".len()..];+ match list_page_files(slug).iter().find(|n| n.ends_with(".glb")).and_then(|n| wiki_bytes(slug, n)) { Some(b) => Response::from_data(b).with_header(hdr("model/gltf-binary")), None => Response::from_string("no glb").with_status_code(404), } }- (Method::Get, p) if p.starts_with("/embed/symbol/") => {- let (source, id) = parse_token(&p["/embed/symbol/".len()..]);- embed_view(&source, &id, "sym", "adom-symbol")- }- (Method::Get, p) if p.starts_with("/embed/footprint/") => {- let (source, id) = parse_token(&p["/embed/footprint/".len()..]);- embed_view(&source, &id, "fp", "adom-footprint")- }- (Method::Get, "/js/adom-3d-viewer.min.js") =>- Response::from_data(VIEWER_JS.to_vec()).with_header(hdr("application/javascript")),- (Method::Get, "/js/environmentSpecular.env") =>- Response::from_data(VIEWER_ENV.to_vec()).with_header(hdr("application/octet-stream")),- (Method::Get, p) if p.starts_with("/viewer3d/") => viewer3d_page(&p["/viewer3d/".len()..]), _ => Response::from_string("not found").with_status_code(404), } } -fn urldecode(s: &str) -> String {- let s = s.replace('+', " ");- let b = s.as_bytes();- let mut out = String::new();- let mut i = 0;- while i < b.len() {- if b[i] == b'%' && i + 2 < b.len() {- if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) { out.push(byte as char); i += 3; continue; }- }- out.push(b[i] as char); i += 1;- }- out-}-fn urlenc(s: &str) -> String {- s.bytes().map(|b| match b {- b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),- _ => format!("%{:02X}", b),- }).collect()-}--/// A component is `(source, id)`. Encoded in URLs as `source:id` (one path-/// segment; slugs/MPNs contain no ':' or '/'). A bare token defaults to wiki.-fn parse_token(tok: &str) -> (String, String) {- let t = tok.trim_end_matches('/');- match t.split_once(':') {- Some((sc, id)) => (sc.to_string(), id.to_string()),- None => ("wiki".to_string(), t.to_string()),- }-}--// ── Identity (for the Mine / Org tabs) ──-static WHOAMI: Mutex<Option<(String, Vec<String>)>> = Mutex::new(None);-fn whoami() -> (String, Vec<String>) {- if let Some(v) = WHOAMI.lock().unwrap().as_ref() { return v.clone(); }- let mut res = (String::new(), Vec::new());- if let Ok(out) = Command::new("adom-wiki").args(["whoami", "--json"]).output() {- if let Ok(v) = serde_json::from_slice::<Value>(&out.stdout) {- let name = v.pointer("/data/user/displayName").and_then(|x| x.as_str()).unwrap_or("").to_string();- let orgs = v.pointer("/data/orgs").and_then(|x| x.as_array())- .map(|a| a.iter().filter_map(|o| o.get("name").and_then(|n| n.as_str()).map(String::from)).collect())- .unwrap_or_default();- res = (name, orgs);- }- }- *WHOAMI.lock().unwrap() = Some(res.clone());- res-}--// ── Local library: the standard $HOME/project/library (fallback chip-fetcher) ──-fn library_root() -> Option<std::path::PathBuf> {- let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".into());- for c in [std::env::var("ADOM_LIBRARY").ok(),- Some(format!("{home}/project/library")),- Some(format!("{home}/project/chip-fetcher/library"))].into_iter().flatten() {- let pb = std::path::PathBuf::from(&c);- if pb.is_dir() && !local_scan(&pb).is_empty() { return Some(pb); }- }- None-}-fn local_scan(root: &std::path::Path) -> Vec<String> {- let mut out = Vec::new();- if let Ok(rd) = std::fs::read_dir(root) {- for e in rd.flatten() {- let pb = e.path();- if pb.is_dir() {- let mpn = e.file_name().to_string_lossy().to_string();- if pb.join(format!("{mpn}.kicad_sym")).exists() || pb.join(format!("{mpn}.kicad_mod")).exists() {- out.push(mpn);- }- }- }- }- out.sort();- out-}-fn local_components() -> Vec<String> { library_root().map(|r| local_scan(&r)).unwrap_or_default() }--// ── Per-component files = completeness signal AND the exact filename to fetch ──-#[derive(Clone, Default)]-struct CompFiles { sym: Option<String>, fp: Option<String>, glb: Option<String> }-static FILES_CACHE: Mutex<Option<std::collections::HashMap<String, CompFiles>>> = Mutex::new(None);--fn component_files(source: &str, id: &str) -> CompFiles {- let key = format!("{source}:{id}");- if let Some(m) = FILES_CACHE.lock().unwrap().as_ref() { if let Some(c) = m.get(&key) { return c.clone(); } }- let cf = if source == "local" {- let root = library_root();- let pick = |ext: &str| root.as_ref().and_then(|r| {- let name = format!("{id}.{ext}");- if r.join(id).join(&name).exists() { Some(name) } else { None }- });- CompFiles { sym: pick("kicad_sym"), fp: pick("kicad_mod"), glb: pick("glb") }- } else {- let mut cf = CompFiles::default();- if let Some(body) = curl(&format!("{WIKI}/api/v1/pages/{id}/files")) {- if let Ok(v) = serde_json::from_slice::<Value>(&body) {- if let Some(files) = v.get("files").and_then(|f| f.as_array()) {- for f in files {- let n = f.get("name").and_then(|n| n.as_str()).unwrap_or("");- let nl = n.to_lowercase();- if nl.contains(".altium.") || nl.contains(".fusion.") { continue; }- if nl.ends_with(".kicad_sym") && cf.sym.is_none() { cf.sym = Some(n.to_string()); }- else if nl.ends_with(".kicad_mod") && cf.fp.is_none() { cf.fp = Some(n.to_string()); }- else if nl.ends_with(".glb") && cf.glb.is_none() { cf.glb = Some(n.to_string()); }- }- }- }- }- cf- };- FILES_CACHE.lock().unwrap().get_or_insert_with(std::collections::HashMap::new).insert(key, cf.clone());- cf-}-fn fetch_component_text(source: &str, id: &str, which: &str) -> Option<String> {- let cf = component_files(source, id);- let name = match which { "sym" => cf.sym, "fp" => cf.fp, _ => cf.glb }?;- if source == "local" { library_root().and_then(|r| std::fs::read_to_string(r.join(id).join(&name)).ok()) }- else { wiki_text(id, &name) }-}-fn fetch_component_bytes(source: &str, id: &str, which: &str) -> Option<Vec<u8>> {- let cf = component_files(source, id);- let name = match which { "sym" => cf.sym, "fp" => cf.fp, _ => cf.glb }?;- if source == "local" { library_root().and_then(|r| std::fs::read(r.join(id).join(&name)).ok()) }- else { wiki_bytes(id, &name) }-}-fn complete_batch(ids: &str) -> Value {- let mut out = serde_json::Map::new();- for tok in ids.split(',').filter(|s| !s.is_empty()) {- let (source, id) = parse_token(tok);- let cf = component_files(&source, &id);- out.insert(tok.to_string(), json!({"s": cf.sym.is_some(), "f": cf.fp.is_some(), "d": cf.glb.is_some()}));- }- json!({ "status": "ok", "complete": out })-}--// ── Tabs + listing ──-fn tabs_config() -> Value {- let (name, orgs) = whoami();- let mut tabs = vec![json!({"key":"local","label":"Local","source":"local"})];- if !name.is_empty() { tabs.push(json!({"key":"mine","label":name,"source":"wiki"})); }- for org in &orgs { tabs.push(json!({"key":format!("org:{org}"),"label":org,"source":"wiki"})); }- tabs.push(json!({"key":"public","label":"Public","source":"wiki"}));- json!({ "status": "ok", "tabs": tabs })-}--static PAGES_CACHE: Mutex<Option<Vec<Value>>> = Mutex::new(None);-fn wiki_pages_all() -> Vec<Value> {- if let Some(v) = PAGES_CACHE.lock().unwrap().as_ref() { return v.clone(); }- let mut all: Vec<Value> = Vec::new();- let mut offset = 0usize;- loop {- let body = match curl(&format!("{WIKI}/api/v1/pages?type=component&limit=200&offset={offset}")) { Some(b) => b, None => break };- let v: Value = match serde_json::from_slice(&body) { Ok(v) => v, Err(_) => break };- let pages = v.get("pages").and_then(|p| p.as_array()).cloned().unwrap_or_default();- let n = pages.len();- all.extend(pages);- offset += n;- let total = v.get("total").and_then(|t| t.as_u64()).unwrap_or(0) as usize;- if n == 0 || all.len() >= total || all.len() >= 5000 { break; }- }- *PAGES_CACHE.lock().unwrap() = Some(all.clone());- all-}-fn page_item(pg: &Value) -> Value {- let slug = pg.get("slug").and_then(|s| s.as_str()).unwrap_or("");- let title = pg.get("title").and_then(|s| s.as_str()).unwrap_or(slug);- json!({"source":"wiki","id":slug,"token":format!("wiki:{slug}"),"title":title})-}-fn matches(pg: &Value, q: &str) -> bool {- if q.is_empty() { return true; }- let s = pg.get("slug").and_then(|s| s.as_str()).unwrap_or("").to_lowercase();- let t = pg.get("title").and_then(|s| s.as_str()).unwrap_or("").to_lowercase();- s.contains(q) || t.contains(q)-}--/// List a tab. Local/Mine/Org are bounded (filter the cached set, paginate here).-/// Public browse is server-paged (`/pages` offset); Public search is server-/// relevance (`/search`, top-N). `complete` drops parts missing sym/fp/3d.-/// Total count of a bounded tab (local/mine/org) BEFORE pagination — so the-/// scroll `more` flag is right even when the complete-filter shrinks a page.-fn next_total(tab: &str, ql: &str, name: &str) -> usize {- if tab == "local" {- local_components().into_iter().filter(|m| ql.is_empty() || m.to_lowercase().contains(ql)).count()- } else if tab == "mine" {- wiki_pages_all().iter().filter(|pg| pg.get("author_name").and_then(|a| a.as_str()) == Some(name) && matches(pg, ql)).count()- } else if let Some(org) = tab.strip_prefix("org:") {- wiki_pages_all().iter().filter(|pg| pg.get("org_name").and_then(|a| a.as_str()) == Some(org) && matches(pg, ql)).count()- } else { 0 }-}--fn list_for_tab(tab: &str, offset: usize, limit: usize, search: &str, complete: bool) -> Value {- let (name, _) = whoami();- let ql = search.to_lowercase();- let (mut items, mut more): (Vec<Value>, bool) = if tab == "local" {- let all: Vec<Value> = local_components().into_iter()- .filter(|m| ql.is_empty() || m.to_lowercase().contains(&ql))- .map(|m| json!({"source":"local","id":m,"token":format!("local:{m}"),"title":m}))- .collect();- let total = all.len();- (all.into_iter().skip(offset).take(limit).collect(), offset + limit < total)- } else if tab == "mine" {- let all: Vec<Value> = wiki_pages_all().iter()- .filter(|pg| pg.get("author_name").and_then(|a| a.as_str()) == Some(name.as_str()) && matches(pg, &ql))- .map(page_item).collect();- let total = all.len();- (all.into_iter().skip(offset).take(limit).collect(), offset + limit < total)- } else if let Some(org) = tab.strip_prefix("org:") {- let all: Vec<Value> = wiki_pages_all().iter()- .filter(|pg| pg.get("org_name").and_then(|a| a.as_str()) == Some(org) && matches(pg, &ql))- .map(page_item).collect();- let total = all.len();- (all.into_iter().skip(offset).take(limit).collect(), offset + limit < total)- } else if !search.is_empty() {- // Public SEARCH — server relevance, top-N (no offset paging).- let mut v = Vec::new();- if let Some(b) = curl(&format!("{WIKI}/api/v1/search?q={}&type=component&limit={}", urlenc(search), limit.max(50))) {- if let Ok(j) = serde_json::from_slice::<Value>(&b) {- if let Some(rs) = j.get("results").and_then(|r| r.as_array()) { for r in rs { v.push(page_item(r)); } }- }- }- (v, false)- } else {- // Public BROWSE — server-paged.- let mut v = Vec::new();- if let Some(b) = curl(&format!("{WIKI}/api/v1/pages?type=component&limit={limit}&offset={offset}")) {- if let Ok(j) = serde_json::from_slice::<Value>(&b) {- if let Some(pgs) = j.get("pages").and_then(|p| p.as_array()) { for pg in pgs { v.push(page_item(pg)); } }- }- }- let got = v.len();- (v, got == limit)- };-- // Next offset to request = how many SLOTS this page consumed (before the- // complete-filter drops any), so scroll pagination stays correct.- let raw = items.len();- let next = if tab == "public" && search.is_empty() { offset + raw }- else if tab == "public" { offset }- else { offset + limit };- if complete {- items.retain(|it| {- let sc = it.get("source").and_then(|x| x.as_str()).unwrap_or("");- let id = it.get("id").and_then(|x| x.as_str()).unwrap_or("");- let cf = component_files(sc, id);- cf.sym.is_some() && cf.fp.is_some() && cf.glb.is_some()- });- }- let _ = more;- let more = if tab == "public" && search.is_empty() { raw == limit }- else if tab == "public" { false }- else { offset + limit < next_total(tab, &ql, &name) };- json!({ "status": "ok", "items": items, "more": more, "next": next })-}--/// The canonical Adom Babylon viewer bundle + its HDRI, vendored so the 3D-/// column is self-contained (no CDN, no external host — the strict-CSP-safe way).-const VIEWER_JS: &[u8] = include_bytes!("adom-3d-viewer.min.js");-const VIEWER_ENV: &[u8] = include_bytes!("environmentSpecular.env");--/// A minimal interactive 3D page for one component, iframed by the 3D column.-/// Uses RELATIVE asset paths (`../js/…`, `../glb/…`) so it resolves correctly-/// behind the code-server `/proxy/<port>/` prefix (absolute `/js` would not).-fn viewer3d_page(slug: &str) -> Response<Cursor<Vec<u8>>> {- let slug = slug.trim_end_matches('/');- let html = format!(- "<!doctype html><html><head><meta charset=\"utf-8\">\-<style>html,body{{margin:0;height:100%;background:#161b22;overflow:hidden}}#w{{width:100vw;height:100vh}}</style></head>\-<body><div id=\"w\"></div>\-<script src=\"../js/adom-3d-viewer.min.js\"></script>\-<script>window.addEventListener('load',function(){{try{{window.Adom3DViewer.init(document.getElementById('w'),\-{{modelUrl:'../glb/{slug}',environmentUrl:'../js/environmentSpecular.env',zUp:true,showGround:true,showViewCube:true}});}}\-catch(e){{document.body.innerHTML='<pre style=\"color:#f87171;font:12px monospace;padding:16px\">'+e.message+'</pre>';}}}});</script>\-</body></html>"- );- Response::from_string(html).with_header(hdr("text/html; charset=utf-8"))-}- // --------------------------------------------------------------------------- // Wiki access (via curl — no HTTP-client dep) // ---------------------------------------------------------------------------@@ -415,7 +133,13 @@ fn list_components() -> Value { } let title = pg.get("title").and_then(|s| s.as_str()).unwrap_or(&slug).to_string(); let brief = pg.get("brief").and_then(|s| s.as_str()).unwrap_or("").to_string();- items.push(json!({"slug": slug, "title": title, "brief": brief}));+ // tags may arrive as a JSON-string or a real array+ let tags = pg.get("tags").map(|t| match t.as_str() {+ Some(s) => serde_json::from_str::<Value>(s).unwrap_or_else(|_| json!([])),+ None => t.clone(),+ }).unwrap_or_else(|| json!([]));+ let owner = pg.get("author_name").and_then(|s| s.as_str()).unwrap_or("").to_string();+ items.push(json!({"slug": slug, "title": title, "brief": brief, "tags": tags, "owner": owner})); } } }@@ -425,71 +149,112 @@ fn list_components() -> Value { items.push(json!({"slug": s, "title": up(s), "brief": ""})); } }- json!({ "status": "ok", "components": items,+ json!({ "status": "ok", "components": items, "me": MY_OWNER, "hints": ["click a component to open its viewer"] }) } -/// Render a component's canonical INTERACTIVE viewer by shelling out to the-/// authoritative tool's `embed` (`adom-symbol` / `adom-footprint`) on the wiki-/// source file. Self-contained HTML, iframed by the Manager — no external assets,-/// no home-grown SVG. Requires the tool to be installed (declared as a dep).-fn embed_view(source: &str, id: &str, which: &str, tool: &str) -> Response<Cursor<Vec<u8>>> {- let page = |msg: &str, code: u16| {- Response::from_string(format!(- "<body style='margin:0;background:#0d1117;color:#8b949e;font:13px -apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh'><div>{msg}</div></body>"- )).with_header(hdr("text/html; charset=utf-8")).with_status_code(code)- };- let text = match fetch_component_text(source, id, which) {- Some(t) if !t.trim_start().starts_with('<') && !t.contains("\"error\"") => t,- _ => return page(&format!("no {} for {id}", if which == "sym" { "symbol" } else { "footprint" }), 200),- };- let ext = if which == "sym" { "kicad_sym" } else { "kicad_mod" };- let base = std::env::temp_dir().join(format!("adomlbr-mgr-{source}-{id}"));- let infile = base.with_extension(ext);- let outfile = base.with_extension("embed.html");- if std::fs::write(&infile, text.as_bytes()).is_err() { return page("temp write failed", 500); }- let ok = Command::new(tool)- .args(["embed", "--file", infile.to_str().unwrap_or(""), "--out", outfile.to_str().unwrap_or("")])- .output().map(|o| o.status.success()).unwrap_or(false);- if !ok { return page(&format!("{tool} embed unavailable — is {tool} installed?"), 200); }- match std::fs::read(&outfile) {- Ok(b) => Response::from_data(b).with_header(hdr("text/html; charset=utf-8")),- Err(_) => page("embed read failed", 500),- }-}--/// Build a component's part (source-aware) for the layer-stackup + counts.-fn get_part(source: &str, id: &str) -> Value {- let (part, src) = match load_part(source, id) {+/// Fetch a component and build its adom-lbr part FRESH on every call (nothing is+/// cached), then render symbol/footprint SVG on the fly. The canonical adom-lbr+/// JSON is the primary source; if the page has none, we import the KiCad files.+fn get_part(slug: &str) -> Value {+ let mpn = up(slug);+ let (part, src): (AdomLbrPart, &str) = match load_part(slug, &mpn) { Ok(v) => v,- Err(e) => return json!({"status":"error","error":e}),+ Err(e) => {+ return json!({"status":"error","error":e,+ "hints":["the page needs an adom-lbr JSON, or <MPN>.kicad_sym + <MPN>.kicad_mod"]})+ } };- let cf = component_files(source, id);+ let has_glb = list_page_files(slug).iter().any(|n| n.ends_with(".glb")); json!({ "status": "ok",- "id": id,+ "slug": slug, "mpn": part.mpn,- "source_kind": src,+ "source": src, // "adom-lbr json" or "kicad (generated)"+ "symbol_svg": symbol_svg(&part.symbol), // rendered on the fly, every click+ "footprint_svg": footprint_svg(&part.footprint, &pin_names(&part.symbol)), "layers": layer_stackup(&part.footprint),- "glb": cf.glb.is_some(),+ "glb": has_glb, "pins": part.symbol.pins.len(), "pads": part.footprint.pads.len(), }) } -fn load_part(source: &str, id: &str) -> Result<(AdomLbrPart, &'static str), String> {- let stem = if source == "local" { id.to_string() } else { up(id) };- for name in [format!("{stem}.adom-lbr.json"), format!("{stem}.json"), "adom-lbr.json".to_string()] {- let txt = if source == "local" {- library_root().and_then(|r| std::fs::read_to_string(r.join(id).join(&name)).ok())- } else { wiki_text(id, &name) };- if let Some(txt) = txt {- if let Ok(p) = serde_json::from_str::<AdomLbrPart>(&txt) { return Ok((p, "adom-lbr json")); }+/// A board/molecule's component dependencies — the other wiki components it's+/// built from. Read from the board's **`package.json` `dependencies`** (the same+/// npm-style manifest the wiki dependency graph is built from, so the two agree).+/// Values may be `{slug: ver}`, `[slug,…]`, or `[{slug|ref|name}, …]`; owner+/// prefixes (`adom/foo`) are stripped to the slug.+fn board_deps(slug: &str) -> Value {+ let bare = |k: &str| k.rsplit('/').next().unwrap_or(k).trim().to_string();+ let mut deps: Vec<String> = vec![];+ if let Some(txt) = wiki_text(slug, "package.json") {+ if let Ok(v) = serde_json::from_str::<Value>(&txt) {+ deps = match v.get("dependencies") {+ Some(Value::Object(m)) => m.keys().map(|k| bare(k)).collect(),+ Some(Value::Array(a)) => a+ .iter()+ .filter_map(|d| {+ d.as_str().map(|s| s.to_string()).or_else(|| {+ d.get("slug").or_else(|| d.get("ref")).or_else(|| d.get("name")).and_then(|x| x.as_str()).map(|s| s.to_string())+ })+ })+ .map(|s| bare(&s))+ .collect(),+ _ => vec![],+ }; } }- match (fetch_component_text(source, id, "sym"), fetch_component_text(source, id, "fp")) {- (Some(sy), Some(m)) => altium_codec::import_kicad(&sy, &m, id).map(|p| (p, "kicad (generated)")).map_err(|e| e.to_string()),- _ => Err(format!("no adom-lbr JSON or KiCad files for {id}")),+ let items: Vec<Value> = deps.into_iter().map(|s| json!({"slug": s})).collect();+ json!({ "status": "ok", "board": slug, "deps": items,+ "hints": ["deps come from the board's package.json dependencies (same as the wiki dependency graph)"] })+}++/// List the file names on a page (robust to any MPN→filename convention — the+/// slug can't be uppercased to the filename when the MPN has `/`, `.`, etc.).+fn list_page_files(slug: &str) -> Vec<String> {+ let body = match curl(&format!("{WIKI}/api/v1/pages/{slug}/files")) {+ Some(b) => b,+ None => return vec![],+ };+ let v: Value = match serde_json::from_slice(&body) {+ Ok(v) => v,+ Err(_) => return vec![],+ };+ v.get("files")+ .and_then(|f| f.as_array())+ .map(|a| {+ a.iter()+ .filter_map(|f| f.get("path").or_else(|| f.get("name")).and_then(|n| n.as_str()).map(String::from))+ .collect()+ })+ .unwrap_or_default()+}++/// Load a part, preferring the canonical adom-lbr JSON on the page; else generate+/// it from the KiCad files. Discovers the ACTUAL filenames from the page listing+/// (never guesses from the slug). Returns the part + where it came from.+fn load_part(slug: &str, _mpn: &str) -> Result<(AdomLbrPart, &'static str), String> {+ let files = list_page_files(slug);+ let find = |suf: &str| files.iter().find(|n| n.ends_with(suf)).cloned();+ // 1) canonical adom-lbr JSON+ if let Some(name) = find(".adom-lbr.json").or_else(|| find(".adomlbr.json")) {+ if let Some(txt) = wiki_text(slug, &name) {+ match serde_json::from_str::<AdomLbrPart>(&txt) {+ Ok(p) => return Ok((p, "adom-lbr json")),+ Err(e) => return Err(format!("{name} is not valid adom-lbr JSON: {e}")),+ }+ }+ }+ // 2) fall back to the KiCad source files+ match (+ find(".kicad_sym").and_then(|n| wiki_text(slug, &n)),+ find(".kicad_mod").and_then(|n| wiki_text(slug, &n)),+ ) {+ (Some(s), Some(m)) => altium_codec::import_kicad(&s, &m, &up(slug))+ .map(|p| (p, "kicad (generated)"))+ .map_err(|e| e.to_string()),+ _ => Err(format!("no adom-lbr JSON or KiCad files on {slug}")), } } @@ -633,19 +398,40 @@ fn symbol_svg(sym: &Symbol) -> String { } } }+ // Show pin numbers/names on ICs; hide on passives (respect the source flag,+ // else default by pin count — same rule the KiCad exporter uses).+ let passive = sym.pins.len() <= 2;+ let show_num = !sym.pin_numbers_hidden.unwrap_or(passive);+ let show_name = !sym.pin_names_hidden.unwrap_or(passive); for p in &sym.pins { let len = p.length.unwrap_or(200) as f64; let (dx, dy) = match (p.rotation / 90) % 4 { 0 => (len, 0.0), 1 => (0.0, len), 2 => (-len, 0.0), _ => (0.0, -len) };- let (x2, y2) = (p.x as f64 + dx, p.y as f64 + dy);- let (x1, y1) = (p.x as f64, fy(p.y as f64));- // grouped so hover highlights the pin + reads its number/name; the fat- // transparent line is the (easy-to-hit) hover target.+ let (x1, y1) = (p.x as f64, fy(p.y as f64)); // free (connection) end+ let (x2, y2) = (p.x as f64 + dx, fy(p.y as f64 + dy)); // body end, SVG space body += &format!( "<g class='pingroup' data-num='{}' data-name='{}'>\ <line class='pinvis' x1='{:.1}' y1='{:.1}' x2='{:.1}' y2='{:.1}' stroke='#dfe4ec' stroke-width='4'/>\ <line x1='{:.1}' y1='{:.1}' x2='{:.1}' y2='{:.1}' stroke='transparent' stroke-width='26' pointer-events='stroke'/></g>",- esc_attr(&p.number), esc_attr(&p.name), x1, y1, x2, fy(y2), x1, y1, x2, fy(y2)+ esc_attr(&p.number), esc_attr(&p.name), x1, y1, x2, y2, x1, y1, x2, y2 );+ let ln = ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt().max(1.0);+ let (ux, uy) = ((x2 - x1) / ln, (y2 - y1) / ln); // free→body unit+ let (px, py) = (-uy, ux);+ // KiCad's default pin text is 1.27mm = 50 mil; match that (nudge with length).+ let fs = (len * 0.5).clamp(45.0, 60.0);+ if show_num {+ body += &format!(+ "<text x='{:.1}' y='{:.1}' font-size='{:.0}' fill='#9fb0c8' text-anchor='middle' dominant-baseline='central'>{}</text>",+ x1 + ux * ln * 0.5 + px * fs * 0.75, y1 + uy * ln * 0.5 + py * fs * 0.75, fs, esc_attr(&p.number)+ );+ }+ if show_name && p.name != "~" && !p.name.is_empty() {+ let anchor = if ux > 0.3 { "start" } else if ux < -0.3 { "end" } else { "middle" };+ body += &format!(+ "<text x='{:.1}' y='{:.1}' font-size='{:.0}' fill='#e6e9ef' text-anchor='{}' dominant-baseline='central'>{}</text>",+ x2 + ux * fs * 0.5, y2 + uy * fs * 0.5, fs, anchor, esc_attr(&p.name)+ );+ } } format!( "<svg xmlns='http://www.w3.org/2000/svg' viewBox='{:.0} {:.0} {:.0} {:.0}' style='width:100%;height:100%'>{}</svg>",@@ -718,6 +504,414 @@ fn empty_svg(msg: &str) -> String { format!("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 40'><text x='50' y='24' text-anchor='middle' fill='#888' font-size='8'>{msg}</text></svg>") } +// ---------------------------------------------------------------------------+// Add to Library — convert selected components → the user's KiCad libraries,+// delivered to their machine via the Adom Desktop bridge.+// ---------------------------------------------------------------------------++// TODO: detect from the bridge rather than hard-coding this user's home.+const KICAD_SYM_DIR: &str = "/home/barrett-land/Documents/KiCAD/Symbols";+const KICAD_FP_DIR: &str = "/home/barrett-land/Documents/KiCAD/Footprints";+const KICAD_PROJ_DIR: &str = "/home/barrett-land/Documents/KiCAD/Projects";+const SCRATCH: &str = "/tmp/adom-lbr-apply";++const PYTHON_SELFCONTAIN: &str = include_str!("selfcontain.py");++/// Fetch every KiCad project file on a board's wiki page (`.kicad_pro`,+/// `.kicad_pcb`, root + hierarchical `.kicad_sch`). When `project_only`, make the+/// project self-contained: extract the embedded symbols/footprints into a project+/// library, re-point every `lib_id`/footprint reference to it, and write the+/// project-local sym/fp-lib-tables. Then deliver everything to `dest`.+fn fetch_project_files(board: &str, dest: &str, project_only: bool) -> (String, Vec<String>) {+ let dest = if dest.trim().is_empty() { format!("{KICAD_PROJ_DIR}/{board}") } else { dest.trim().to_string() };+ let work = format!("{SCRATCH}/proj-{}", board.replace(['/', ' '], "_"));+ let _ = std::fs::remove_dir_all(&work);+ let _ = std::fs::create_dir_all(&work);+ let mut got = vec![];+ let listing = match curl(&format!("{WIKI}/api/v1/pages/{board}/files")) {+ Some(b) => b,+ None => return (dest, got),+ };+ let v: Value = match serde_json::from_slice(&listing) {+ Ok(v) => v,+ Err(_) => return (dest, got),+ };+ let files = v.get("files").and_then(|f| f.as_array()).cloned().unwrap_or_default();+ let mut names = vec![];+ for f in &files {+ let name = f.get("path").or_else(|| f.get("name")).and_then(|n| n.as_str()).unwrap_or("");+ if name.ends_with(".kicad_pro") || name.ends_with(".kicad_pcb") || name.ends_with(".kicad_sch") {+ if let Some(bytes) = wiki_bytes(board, name) {+ let base = name.rsplit('/').next().unwrap_or(name).to_string();+ if std::fs::write(format!("{work}/{base}"), bytes).is_ok() {+ names.push(base);+ }+ }+ }+ }+ if names.is_empty() {+ return (dest, got);+ }+ ad_shell(&format!("mkdir -p {}", sh_quote(&dest)));++ if project_only {+ // self-contain: run the Python helper, which rewrites files in `work`,+ // creates <board>.kicad_sym / .pretty / lib-tables, and reports the split.+ let libnick = board.replace(['/', ' '], "_");+ let script = format!("{work}/_selfcontain.py");+ let _ = std::fs::write(&script, PYTHON_SELFCONTAIN);+ let out = Command::new("python3")+ .arg(&script)+ .arg(&work)+ .arg(&libnick)+ .args(&names)+ .output();+ if let Ok(o) = out {+ if let Ok(v) = serde_json::from_slice::<Value>(&o.stdout) {+ let root: Vec<String> = v.get("root").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();+ let pretty: Vec<String> = v.get("pretty").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();+ if !root.is_empty() {+ ad_send(&root, &dest);+ }+ if !pretty.is_empty() {+ let pdir = format!("{dest}/{libnick}.pretty");+ ad_shell(&format!("mkdir -p {}", sh_quote(&pdir)));+ ad_send(&pretty, &pdir);+ }+ got = root.iter().chain(pretty.iter()).filter_map(|p| p.rsplit('/').next().map(String::from)).collect();+ return (dest, got);+ }+ }+ }++ // personal mode (or self-contain failed): deliver the files as fetched.+ let locals: Vec<String> = names.iter().map(|n| format!("{work}/{n}")).collect();+ ad_send(&locals, &dest);+ (dest, names)+}++/// Single-quote a string for safe interpolation into an `sh -c` command run on+/// the user's machine. Everything is inert inside single quotes except `'`,+/// which becomes the standard `'\''` splice.+fn sh_quote(s: &str) -> String {+ format!("'{}'", s.replace('\'', r"'\''"))+}++/// Library nicknames become file/dir names on the user's machine and are+/// interpolated into shell commands — restrict to a boring identifier charset.+fn valid_lib(s: &str) -> bool {+ !s.is_empty()+ && s.len() <= 64+ && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))+}++/// Wiki slugs (`owner/name`): same conservative charset plus `/`.+fn valid_slug(s: &str) -> bool {+ !s.is_empty()+ && s.len() <= 128+ && !s.contains("..")+ && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))+}++/// Pull a file off the user's machine to the container; None if it doesn't+/// exist OR the pull failed — callers that overwrite must disambiguate via+/// `ad_file_exists` before treating None as "absent".+fn ad_pull(remote: &str) -> Option<Vec<u8>> {+ let _ = std::fs::create_dir_all(SCRATCH);+ let args = json!({"filePaths": [remote], "saveTo": SCRATCH}).to_string();+ Command::new("adom-desktop").args(["pull_file", &args]).output().ok()?;+ let base = remote.rsplit('/').next()?;+ std::fs::read(format!("{SCRATCH}/{base}")).ok()+}++/// Send container files to a directory on the user's machine.+fn ad_send(files: &[String], dest: &str) -> bool {+ let args = json!({"filePaths": files, "dest": dest}).to_string();+ Command::new("adom-desktop")+ .args(["send_files", &args])+ .output()+ .map(|o| o.status.success())+ .unwrap_or(false)+}++fn ad_shell(cmd: &str) {+ let args = json!({"command": cmd}).to_string();+ let _ = Command::new("adom-desktop").args(["shell_execute", &args]).output();+}++/// Run a shell command on the user's machine and return its captured reply+/// (whole adom-desktop stdout); None if the bridge call itself failed.+fn ad_shell_out(cmd: &str) -> Option<String> {+ let args = json!({"command": cmd}).to_string();+ let out = Command::new("adom-desktop").args(["shell_execute", &args]).output().ok()?;+ if !out.status.success() {+ return None;+ }+ Some(String::from_utf8_lossy(&out.stdout).to_string())+}++/// Does a file exist on the user's machine? None = couldn't determine+/// (bridge/desktop error) — callers must treat that as "assume it exists".+fn ad_file_exists(path: &str) -> Option<bool> {+ let out = ad_shell_out(&format!("[ -f {} ] && echo __EXISTS__ || echo __ABSENT__", sh_quote(path)))?;+ if out.contains("__EXISTS__") {+ Some(true)+ } else if out.contains("__ABSENT__") {+ Some(false)+ } else {+ None+ }+}++/// Extract the top-level `(symbol "NAME" …)` block (balanced parens) from a+/// single-symbol .kicad_sym → (name, block).+fn extract_symbol_block(t: &str) -> Option<(String, String)> {+ let i = t.find("(symbol \"")?;+ let name = t[i + 9..].split('"').next()?.to_string();+ let b = t.as_bytes();+ let mut depth = 0i32;+ for j in i..b.len() {+ match b[j] {+ b'(' => depth += 1,+ b')' => {+ depth -= 1;+ if depth == 0 {+ return Some((name, t[i..=j].to_string()));+ }+ }+ _ => {}+ }+ }+ None+}++/// Merge symbol blocks into an existing (or new) `.kicad_sym`, skipping names+/// already present.+fn merge_symbols(existing: Option<String>, blocks: &[(String, String)]) -> String {+ let base = existing.filter(|s| s.contains("kicad_symbol_lib")).unwrap_or_else(|| {+ // Default tier (V8) — this lib lands on the user's desktop, whose+ // KiCad (9.0.9) rejects files newer than itself.+ let kv = altium_codec::kicad_export::KicadVersion::default();+ format!(+ "(kicad_symbol_lib\n\t(version {})\n\t(generator \"adom-lbr\")\n\t(generator_version \"{}\")\n)\n",+ kv.sym_version(),+ kv.generator_version().unwrap_or("8.0")+ )+ });+ let mut names = std::collections::HashSet::new();+ let mut idx = 0;+ while let Some(p) = base[idx..].find("(symbol \"") {+ let s = idx + p + 9;+ match base[s..].find('"') {+ Some(e) => {+ names.insert(base[s..s + e].to_string());+ idx = s + e;+ }+ None => break,+ }+ }+ let mut add = String::new();+ for (name, block) in blocks {+ if !names.contains(name) {+ add.push('\t');+ add.push_str(block);+ add.push('\n');+ }+ }+ let cut = base.rfind(')').unwrap_or(base.len());+ format!("{}{}{}", &base[..cut], add, &base[cut..])+}++/// POST /api/apply — body {items:[{slug,symLib,fpLib}], target, project?, board?}.+/// Converts each component, merges symbols into the mapped KiCad symbol library,+/// and drops footprints into the footprint `.pretty`, all on the user's machine.+// Live progress for the background apply, polled by the UI via /api/apply-progress.+static PROGRESS: Mutex<Option<Value>> = Mutex::new(None);+fn set_progress(v: Value) {+ *PROGRESS.lock().unwrap() = Some(v);+}+fn get_progress() -> Value {+ PROGRESS.lock().unwrap().clone().unwrap_or_else(|| json!({"stage":"idle","done":true}))+}++/// POST /api/apply — validate, then run the work on a background thread so the UI+/// can poll live stage updates. Returns immediately with `{status:"started"}`.+fn start_apply(body: &str) -> Value {+ let req: Value = match serde_json::from_str(body) {+ Ok(v) => v,+ Err(e) => return json!({"status":"error","error":format!("bad body: {e}")}),+ };+ if req.get("target").and_then(|t| t.as_str()).unwrap_or("kicad") != "kicad" {+ return json!({"status":"error","error":"only KiCad add-to-library is implemented right now"});+ }+ // One apply at a time — concurrent runs share SCRATCH and the PROGRESS cell.+ {+ let p = PROGRESS.lock().unwrap();+ if let Some(v) = p.as_ref() {+ if v.get("done").and_then(|d| d.as_bool()) == Some(false) {+ return json!({"status":"error","error":"an apply is already running",+ "hint":"poll /api/apply-progress until done:true, then retry"});+ }+ }+ }+ let board = req.get("board").and_then(|b| b.as_str()).map(String::from);+ if let Some(b) = &board {+ if !valid_slug(b) {+ return json!({"status":"error","error":format!("invalid board slug: {b:?}")});+ }+ }+ let mut bad: Vec<String> = vec![];+ let items: Vec<(String, String, String)> = req+ .get("items")+ .and_then(|i| i.as_array())+ .map(|a| {+ a.iter()+ .filter_map(|it| {+ let slug = it.get("slug").and_then(|s| s.as_str())?.to_string();+ if slug.is_empty() {+ return None;+ }+ let sym = it.get("symLib").and_then(|s| s.as_str()).unwrap_or("adom_lbr").to_string();+ let fp = it.get("fpLib").and_then(|s| s.as_str()).unwrap_or("adom_lbr").to_string();+ // These become paths + shell args on the user's machine.+ if !valid_slug(&slug) || !valid_lib(&sym) || !valid_lib(&fp) {+ bad.push(slug);+ return None;+ }+ Some((slug, sym, fp))+ })+ .collect()+ })+ .unwrap_or_default();+ if !bad.is_empty() {+ return json!({"status":"error",+ "error":format!("invalid slug or library name on: {}", bad.join(", ")),+ "hint":"library names may only contain [A-Za-z0-9_.-]"});+ }+ if items.is_empty() && board.is_none() {+ return json!({"status":"error","error":"no components selected"});+ }+ let dest = req.get("dest").and_then(|d| d.as_str()).unwrap_or("").to_string();+ if dest.contains(['\n', '\r', '\0']) {+ return json!({"status":"error","error":"invalid dest path"});+ }+ let project_only = req.get("project").and_then(|p| p.as_bool()).unwrap_or(false);+ let total = items.len();+ set_progress(json!({"stage":"Starting…","current":0,"total":total,"done":false}));+ std::thread::spawn(move || run_apply(items, board, dest, project_only));+ json!({"status":"started","total":total})+}++/// The actual work — converts + merges + delivers, updating PROGRESS at each step.+fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest: String, project_only: bool) {+ let _ = std::fs::create_dir_all(SCRATCH);+ use std::collections::BTreeMap;+ let mut by_symlib: BTreeMap<String, Vec<String>> = BTreeMap::new();+ let mut by_fplib: BTreeMap<String, Vec<String>> = BTreeMap::new();+ for (slug, sym, fp) in &items {+ by_symlib.entry(sym.clone()).or_default().push(slug.clone());+ by_fplib.entry(fp.clone()).or_default().push(slug.clone());+ }+ let total = items.len();+ let mut done_n = 0usize;+ let mut added: Vec<Value> = vec![];+ let mut errors: Vec<String> = vec![];+ let prog = |stage: String, cur: usize| set_progress(json!({"stage":stage,"current":cur,"total":total,"done":false}));++ for (symlib, slugs) in &by_symlib {+ let mut blocks: Vec<(String, String)> = vec![];+ for slug in slugs {+ done_n += 1;+ prog(format!("Converting {slug} → {symlib}"), done_n);+ match load_part(slug, &up(slug)) {+ Ok((part, _)) => {+ if let Some(b) = extract_symbol_block(&export_kicad_symbol(&part.symbol)) {+ blocks.push(b);+ }+ }+ Err(e) => errors.push(format!("{slug}: {e}")),+ }+ }+ prog(format!("Merging {} symbol(s) into {symlib}", blocks.len()), done_n);+ let remote = format!("{KICAD_SYM_DIR}/{symlib}.kicad_sym");+ let existing = ad_pull(&remote).map(|b| String::from_utf8_lossy(&b).to_string());+ // NEVER overwrite a library we failed to read. ad_pull returns None for+ // both "absent" and "pull failed" — only proceed with a fresh library+ // when the desktop confirms the file genuinely doesn't exist.+ if existing.is_none() {+ match ad_file_exists(&remote) {+ Some(false) => {} // genuinely absent — start a fresh library+ Some(true) => {+ errors.push(format!(+ "{symlib}.kicad_sym exists on the desktop but could not be pulled — skipped to avoid overwriting it"+ ));+ continue;+ }+ None => {+ errors.push(format!(+ "could not verify {symlib}.kicad_sym on the desktop (bridge error) — skipped to avoid overwriting it"+ ));+ continue;+ }+ }+ }+ let merged = merge_symbols(existing.clone(), &blocks);+ // Never-shrink guard: the merge only appends, so the result must hold at+ // least as many symbols as the library already had.+ let n_syms = |s: &str| s.matches("(symbol \"").count();+ if let Some(ex) = &existing {+ if n_syms(&merged) < n_syms(ex) {+ errors.push(format!("{symlib}.kicad_sym merge would shrink the library — aborted"));+ continue;+ }+ }+ let local = format!("{SCRATCH}/{symlib}.kicad_sym");+ if let Err(e) = std::fs::write(&local, &merged) {+ errors.push(format!("failed to stage {symlib}.kicad_sym locally: {e}"));+ continue;+ }+ if ad_send(&[local], KICAD_SYM_DIR) {+ for (name, _) in &blocks {+ added.push(json!({"symbol": name, "symLib": symlib}));+ }+ } else {+ errors.push(format!("failed to write {symlib}.kicad_sym"));+ }+ }++ for (fplib, slugs) in &by_fplib {+ prog(format!("Delivering footprints → {fplib}.pretty"), total);+ let dest = format!("{KICAD_FP_DIR}/{fplib}.pretty");+ ad_shell(&format!("mkdir -p {}", sh_quote(&dest)));+ let mut files = vec![];+ for slug in slugs {+ if let Ok((part, _)) = load_part(slug, &up(slug)) {+ let name = if part.footprint.name.is_empty() { up(slug) } else { part.footprint.name.clone() };+ let local = format!("{SCRATCH}/{}.kicad_mod", name.replace(['/', ' '], "_"));+ match std::fs::write(&local, export_kicad_footprint(&part.footprint)) {+ Ok(()) => files.push(local),+ Err(e) => errors.push(format!("{slug}: failed to stage footprint locally: {e}")),+ }+ }+ }+ if !files.is_empty() && !ad_send(&files, &dest) {+ errors.push(format!("failed to deliver footprints to {fplib}.pretty"));+ }+ }++ let project = match &board {+ Some(b) => {+ prog(if project_only { "Building self-contained project (symbols, footprints, lib-tables)…".into() } else { "Fetching KiCad project files (schematic, PCB, sheets)…".to_string() }, total);+ let (folder, files) = fetch_project_files(b, &dest, project_only);+ json!({"folder": folder, "files": files})+ }+ None => json!(null),+ };++ let result = json!({"status":"ok","count":added.len(),"added":added,"errors":errors,"project":project});+ set_progress(json!({"stage":"Done","current":total,"total":total,"done":true,"result":result}));+}+ // --------------------------------------------------------------------------- // HTTP helpers // ---------------------------------------------------------------------------
src/selfcontain.pyadded+140@@ -0,0 +1,140 @@+#!/usr/bin/env python3+"""Make a delivered KiCad project self-contained.++Extracts the symbols embedded in the schematics' (lib_symbols ...) and the+footprints embedded in the .kicad_pcb into a project library named <libnick>,+re-points every lib_id / footprint reference to that library, and writes+project-local sym-lib-table / fp-lib-table (using ${KIPRJMOD}). Reads/writes the+files in <work>; prints JSON {root:[...], pretty:[...], symbols, footprints}.++usage: selfcontain.py <work_dir> <libnick> <file...>+"""+import sys, os, re, json++work, nick = sys.argv[1], sys.argv[2]+names = sys.argv[3:]+++def balanced(s, start):+ d = 0+ for i in range(start, len(s)):+ if s[i] == '(':+ d += 1+ elif s[i] == ')':+ d -= 1+ if d == 0:+ return i + 1+ return len(s)+++def top_symbols(section):+ """Top-level (symbol "NAME" ...) blocks inside a section (skips nested)."""+ out, i = [], 0+ while True:+ p = section.find('(symbol "', i)+ if p < 0:+ break+ ns = p + len('(symbol "')+ ne = section.find('"', ns)+ e = balanced(section, p)+ out.append((section[ns:ne], section[p:e]))+ i = e+ return out+++def repoint(content, needle, nick):+ """Rewrite the library part of `needle "LIB:NAME"` occurrences to `nick`."""+ out, i = [], 0+ n = len(needle)+ while True:+ p = content.find(needle, i)+ if p < 0:+ out.append(content[i:])+ break+ vs = p + n+ ve = content.find('"', vs)+ val = content[vs:ve]+ out.append(content[i:vs])+ out.append(nick + ':' + val.split(':', 1)[1] if ':' in val else val)+ i = ve+ return ''.join(out)+++schs = [n for n in names if n.endswith('.kicad_sch')]+pcbs = [n for n in names if n.endswith('.kicad_pcb')]++# ---- 1. collect symbols from every schematic's lib_symbols ----+symbols = {}+for f in schs:+ c = open(os.path.join(work, f), encoding='utf-8').read()+ ls = c.find('(lib_symbols')+ if ls < 0:+ continue+ section = c[ls:balanced(c, ls)]+ for name, block in top_symbols(section):+ bare = name.split(':', 1)[1] if ':' in name else name+ if bare not in symbols:+ symbols[bare] = block.replace('"' + name + '"', '"' + bare + '"', 1)++lib = '(kicad_symbol_lib\n\t(version 20231120)\n\t(generator "adom-lbr")\n'+for b in symbols.values():+ lib += '\t' + b + '\n'+lib += ')\n'+open(os.path.join(work, nick + '.kicad_sym'), 'w', encoding='utf-8').write(lib)++# ---- 2. re-point the schematics ----+for f in schs:+ path = os.path.join(work, f)+ c = open(path, encoding='utf-8').read()+ c = repoint(c, '(lib_id "', nick)+ c = repoint(c, '(symbol "', nick) # lib_symbols keys (the colon'd ones)+ c = repoint(c, '(property "Footprint" "', nick)+ open(path, 'w', encoding='utf-8').write(c)++# ---- 3. extract footprints from the PCB into <nick>.pretty ----+pretty = os.path.join(work, nick + '.pretty')+os.makedirs(pretty, exist_ok=True)+pfiles = []+UUID = re.compile(r'\s*\(uuid "[^"]*"\)')+NET = re.compile(r'\s*\(net \d+ "[^"]*"\)')+ATPLACE = re.compile(r'\(at [-\d.eE ]+\)')+HEADER = '\n\t(version 20240108)\n\t(generator "adom-lbr")\n\t(generator_version "9.0")'+for f in pcbs:+ path = os.path.join(work, f)+ c = open(path, encoding='utf-8').read()+ i, seen = 0, set()+ while True:+ p = c.find('(footprint "', i)+ if p < 0:+ break+ ns = p + len('(footprint "')+ ne = c.find('"', ns)+ name = c[ns:ne]+ bare = name.split(':', 1)[1] if ':' in name else name+ e = balanced(c, p)+ if bare not in seen:+ seen.add(bare)+ blk = c[p:e].replace('"' + name + '"', '"' + bare + '"', 1)+ blk = ATPLACE.sub('', blk, count=1) # drop the footprint's board placement (first (at ...))+ blk = UUID.sub('', blk) # drop uuids (instance-specific)+ blk = NET.sub('', blk) # drop pad nets (instance-specific)+ # a *library* footprint needs a header right after the name+ blk = blk.replace('(footprint "' + bare + '"', '(footprint "' + bare + '"' + HEADER, 1)+ open(os.path.join(pretty, bare + '.kicad_mod'), 'w', encoding='utf-8').write(blk + '\n')+ pfiles.append(os.path.join(pretty, bare + '.kicad_mod'))+ i = e+ c = repoint(c, '(footprint "', nick)+ open(path, 'w', encoding='utf-8').write(c)++# ---- 4. project-local lib tables ----+open(os.path.join(work, 'sym-lib-table'), 'w', encoding='utf-8').write(+ '(sym_lib_table\n (version 7)\n (lib (name "%s")(type "KiCad")(uri "${KIPRJMOD}/%s.kicad_sym")(options "")(descr "adom-lbr self-contained project symbols"))\n)\n' % (nick, nick))+open(os.path.join(work, 'fp-lib-table'), 'w', encoding='utf-8').write(+ '(fp_lib_table\n (version 7)\n (lib (name "%s")(type "KiCad")(uri "${KIPRJMOD}/%s.pretty")(options "")(descr "adom-lbr self-contained project footprints"))\n)\n' % (nick, nick))++root = [os.path.join(work, f) for f in names] + [+ os.path.join(work, nick + '.kicad_sym'),+ os.path.join(work, 'sym-lib-table'),+ os.path.join(work, 'fp-lib-table'),+]+print(json.dumps({'root': root, 'pretty': pfiles, 'symbols': len(symbols), 'footprints': len(pfiles)}))
src/server.rs+14−4@@ -98,17 +98,25 @@ fn state_json() -> String { pub fn run(port: u16) -> Result<()> { *EVAL_RESULTS.lock().unwrap() = Some(HashMap::new()); *CONSOLE.lock().unwrap() = Some(Vec::new());- let server = Server::http(format!("0.0.0.0:{port}")).map_err(|e| anyhow::anyhow!("bind {port}: {e}"))?;+ // Loopback by default: /eval queues JS for the user's browser, /deliver+ // pushes files to their desktop, /shutdown kills the app — none of that+ // should be LAN-reachable. ADOM_LBR_BIND=0.0.0.0 opts back in.+ let bind = std::env::var("ADOM_LBR_BIND").unwrap_or_else(|_| "127.0.0.1".into());+ let server = Server::http(format!("{bind}:{port}")).map_err(|e| anyhow::anyhow!("bind {bind}:{port}: {e}"))?; eprintln!("[adom-lbr] serving on http://127.0.0.1:{port}"); // app-creator §6a — best-effort HD port registration (internal/proxy-only). register_port(port, "adom-lbr", "EAGLE .lbr linter live app (AI-drivable)"); + // Cap POST bodies: the biggest legitimate payload is a large .lbr; anything+ // beyond this is a bug or abuse, not a library.+ const MAX_BODY: u64 = 32 * 1024 * 1024; for mut req in server.incoming_requests() { let url = req.url().split('?').next().unwrap_or("/").to_string(); let method = req.method().clone(); let mut body = String::new(); if matches!(method, Method::Post) {- let _ = req.as_reader().read_to_string(&mut body);+ use std::io::Read;+ let _ = req.as_reader().take(MAX_BODY).read_to_string(&mut body); } let resp = route(&method, &url, &body); let _ = req.respond(resp);@@ -222,8 +230,10 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec } } if let Ok(id) = rest.parse::<u64>() {- let map = EVAL_RESULTS.lock().unwrap();- if let Some(r) = map.as_ref().and_then(|m| m.get(&id)) {+ let mut map = EVAL_RESULTS.lock().unwrap();+ // remove, don't get: results are one-shot; leaving them in the+ // map leaks for the life of the server.+ if let Some(r) = map.as_mut().and_then(|m| m.remove(&id)) { return json_resp(&json!({ "done": true, "result": r }).to_string()); } return json_resp("{\"done\":false}");