← Commit history

2.20.0: generate --alt-sym, alternate symbols as extra gates of one deviceset

John Lauer ·9fea18d1ce ·1mo ago ·parent 6f3917a
7 files changed +192−76
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-lbr"-version = "2.19.0"+version = "2.20.0" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.19.0"+version = "2.20.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." 
SKILL.md+23
@@ -134,6 +134,29 @@ two columns: inputs left, outputs and bidirectional right, power grouped bottom-left, buses contiguous, every name horizontal. `keep` preserves the .kicad_sym placement verbatim; `two-column` forces the re-layout. +`--alt-sym path[=GATE[=art.svg]]` (repeatable, 2.20.0) adds ALTERNATE SYMBOLS of the+SAME part as extra GATES of the one deviceset: one component in Fusion's Content+Manager with several symbols under it, one footprint, one 3D package (John,+2026-08-29: "one chip called CH340G and it has 10 symbols under it, then 1+footprint, and 1 3d chip"), instead of N look-alike components. Every gate gets+its own `<connect>` set to the same pads; the primary symbol is the gate placed on+ADD (`addlevel="next"`), the alternates are `addlevel="can"` (INVOKE picks one) and+sit side by side in the deviceset so the component preview stays readable. GATE+defaults to the alternate's symbol name with `<primary>-` stripped+(`CH340G-SQ.kicad_sym` becomes gate `SQ`); reusing the primary .kicad_sym under a+new GATE renames the emitted symbol `<name>-<GATE>` (that is how `=CHIP=outline.svg`+makes the chip-outline twin of the standard symbol). An alternate must have the+same pin count as the primary. Proven live in Fusion 2026-08-29: CH340G with ten+gates (std, 2C, SQ, 2CG, SQG and their chip-outline twins) imported, bound to one+STEP and proved by fusion-bridge 1.9.273.++```+adom-lbr generate --sym CH340G.kicad_sym --fp CH340G.kicad_mod --part-metadata page.json \+  --alt-sym "CH340G.kicad_sym=CHIP=CH340G-3d-outline-bold.svg" \+  --alt-sym CH340G-SQ.kicad_sym --alt-sym "CH340G-SQ.kicad_sym=SQ-CHIP=CH340G-3d-outline-bold.svg" \+  --alt-sym CH340G-2CG.kicad_sym --alt-sym CH340G-SQG.kicad_sym -o CH340G.lbr+```+ Duplicate PAD names (KiCad same-net arrays: thermal vias, ground paddles, shield tabs) are resolved to the EAGLE `@`-suffix convention (`17`, `17@2`, `17@3`, ...) with one space-separated multi-pad `<connect>` binding the whole
VERSION+1−1
@@ -1 +1 @@-2.19.0+2.20.0
bin/adom-lbr
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.19.0",+  "version": "2.20.0",   "type": "app",   "description": "adom-lbr \u2014 the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad \u21c4 Altium \u21c4 EAGLE/Fusion) through one canonical adom-lbr JSON \u2014 symbol + footprint + 3D + pin\u2194pad 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": {},
src/main.rs+165−72
@@ -99,6 +99,18 @@ enum Commands {         /// verbatim.         #[arg(long, default_value = "auto")]         symbol_layout: String,+        /// ALTERNATE SYMBOLS for the SAME part: each becomes one more GATE of the one+        /// deviceset, on the one package, with its own connects, so Fusion shows ONE+        /// component with several symbols under it (John, 2026-08-29: "one chip called+        /// CH340G and it has 10 symbols under it, then 1 footprint, and 1 3d chip") instead+        /// of ten look-alike components. Repeatable. Form: `path[=GATE[=art.svg]]`.+        /// GATE defaults to the symbol's name with the primary name and a dash stripped+        /// (`CH340G-SQ.kicad_sym` -> gate `SQ`); when the same .kicad_sym is reused under a+        /// new GATE the emitted symbol is renamed `<name>-<GATE>`. An `art.svg` is that+        /// gate's own --symbol-art. Gates are addlevel `can`: the primary symbol places on+        /// ADD, the others are one INVOKE away.+        #[arg(long = "alt-sym")]+        alt_sym: Vec<String>,     },     /// Lint an existing .lbr file for Fusion 360 compatibility     Lint {@@ -1310,7 +1322,89 @@ fn part_attributes(path: &std::path::Path) -> Vec<(String, String)> {     out } +/// One extra gate of the deviceset: an alternate symbol for the same part.+pub(crate) struct AltGate {+    pub sym: ParsedSymbol,+    pub gate: String,+    pub art: Option<SymbolArt>,+}++/// The `<symbol>` block of ONE symbol: body, chip drawing, pins, group labels.+/// Returns the pin names as emitted (deduplicated); the connects must reuse them.+fn emit_symbol_block(xml: &mut String, sym: &ParsedSymbol, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64) -> Vec<String> {+    xml.push_str(&format!("<symbol name=\"{}\">\n", esc_xml(&sym.name)));++    if let Some(body) = &sym.body {+        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.left, body.top, body.right, body.top));+        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.right, body.top, body.right, body.bottom));+        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.right, body.bottom, body.left, body.bottom));+        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\">&gt;NAME</text>\n", name_y));+    xml.push_str(&format!("  <text x=\"0\" y=\"{}\" size=\"1.27\" layer=\"96\" align=\"top-center\">&gt;VALUE</text>\n", value_y));++    let deduped_names = dedup_pin_names(&sym.pins);+    for (i, pin) in sym.pins.iter().enumerate() {+        xml.push_str(&format!(+            "  <pin name=\"{}\" x=\"{}\" y=\"{}\" length=\"{}\" direction=\"{}\"{}/>\n",+            esc_xml(&deduped_names[i]), pin.x, pin.y,+            pin_length_keyword(pin.length),+            pin_direction(&pin.pin_type),+            pin_rotation(pin.angle)+        ));+    }++    let body_center_x = sym.body.as_ref().map(|b| (b.left + b.right) / 2.0).unwrap_or(0.0);+    let body_left = sym.body.as_ref().map(|b| b.left).unwrap_or(-10.0);+    let body_right = sym.body.as_ref().map(|b| b.right).unwrap_or(10.0);+    for label in &sym.group_labels {+        let (lx, align) = if label.x > body_center_x {+            (body_right - 1.0, " align=\"right\"")+        } else {+            (body_left + 1.0, "")+        };+        xml.push_str(&format!(+            "  <text x=\"{}\" y=\"{}\" size=\"0.762\" layer=\"94\" font=\"vector\" ratio=\"15\"{}>{}</text>\n",+            lx, label.y, align, esc_xml(&label.text)+        ));+    }++    xml.push_str("</symbol>\n");+    deduped_names+}+ pub(crate) fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64, pin_map: &std::collections::HashMap<String, String>, part_attrs: &[(String, String)]) -> String {+    generate_lbr_gates(sym, fp_content, symbol_art, symbol_art_scale, pin_map, part_attrs, &[])+}++pub(crate) fn generate_lbr_gates(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64, pin_map: &std::collections::HashMap<String, String>, part_attrs: &[(String, String)], alts: &[AltGate]) -> String {     let mut xml = String::with_capacity(8192);      xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");@@ -1429,71 +1523,10 @@ pub(crate) fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_      // Symbols section     xml.push_str("<symbols>\n");-    xml.push_str(&format!("<symbol name=\"{}\">\n", esc_xml(&sym.name)));--    if let Some(body) = &sym.body {-        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.left, body.top, body.right, body.top));-        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.right, body.top, body.right, body.bottom));-        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.right, body.bottom, body.left, body.bottom));-        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\">&gt;NAME</text>\n", name_y));-    xml.push_str(&format!("  <text x=\"0\" y=\"{}\" size=\"1.27\" layer=\"96\" align=\"top-center\">&gt;VALUE</text>\n", value_y));--    let deduped_names = dedup_pin_names(&sym.pins);-    for (i, pin) in sym.pins.iter().enumerate() {-        xml.push_str(&format!(-            "  <pin name=\"{}\" x=\"{}\" y=\"{}\" length=\"{}\" direction=\"{}\"{}/>\n",-            esc_xml(&deduped_names[i]), pin.x, pin.y,-            pin_length_keyword(pin.length),-            pin_direction(&pin.pin_type),-            pin_rotation(pin.angle)-        ));-    }--    let body_center_x = sym.body.as_ref().map(|b| (b.left + b.right) / 2.0).unwrap_or(0.0);-    let body_left = sym.body.as_ref().map(|b| b.left).unwrap_or(-10.0);-    let body_right = sym.body.as_ref().map(|b| b.right).unwrap_or(10.0);-    for label in &sym.group_labels {-        let (lx, align) = if label.x > body_center_x {-            (body_right - 1.0, " align=\"right\"")-        } else {-            (body_left + 1.0, "")-        };-        xml.push_str(&format!(-            "  <text x=\"{}\" y=\"{}\" size=\"0.762\" layer=\"94\" font=\"vector\" ratio=\"15\"{}>{}</text>\n",-            lx, label.y, align, esc_xml(&label.text)-        ));-    }--    xml.push_str("</symbol>\n");+    let deduped_names = emit_symbol_block(&mut xml, sym, symbol_art, symbol_art_scale);+    let alt_names: Vec<Vec<String>> = alts.iter()+        .map(|a| emit_symbol_block(&mut xml, &a.sym, a.art.as_ref(), symbol_art_scale))+        .collect();     xml.push_str("</symbols>\n");      // Deviceset with COMPLETE device (package + connects)@@ -1502,14 +1535,32 @@ pub(crate) fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_     xml.push_str(&format!("<deviceset name=\"{}\" prefix=\"{}\">\n", esc_xml(&sym.name), esc_xml(ref_prefix)));     xml.push_str(&format!("  <description>{}</description>\n", esc_xml(&sym.description)));     xml.push_str("  <gates>\n");-    xml.push_str(&format!("    <gate name=\"G$1\" symbol=\"{}\" x=\"0\" y=\"0\"/>\n", esc_xml(&sym.name)));+    xml.push_str(&format!("    <gate name=\"G$1\" symbol=\"{}\" x=\"0\" y=\"0\"{}/>\n", esc_xml(&sym.name),+        if alts.is_empty() { "" } else { " addlevel=\"next\"" }));+    // Alternate gates sit SIDE BY SIDE in the deviceset (Fusion's component preview draws+    // every gate at its offset; ten gates at 0,0 were an unreadable pile on the first run).+    let width = |p: &ParsedSymbol| p.body.as_ref().map(|b| b.right - b.left).unwrap_or(20.0) + 2.0 * 7.62;+    let mut gx = width(sym) / 2.0;+    for a in alts {+        let w = width(&a.sym);+        gx += w / 2.0 + 5.08;+        let x = (gx / 2.54).round() * 2.54;   // keep gate origins on the 0.1in grid+        xml.push_str(&format!("    <gate name=\"{}\" symbol=\"{}\" x=\"{:.2}\" y=\"0\" addlevel=\"can\"/>\n", esc_xml(&a.gate), esc_xml(&a.sym.name), x));+        gx += w / 2.0;+    }     xml.push_str("  </gates>\n");     xml.push_str("  <devices>\n");     xml.push_str(&format!("    <device name=\"\" package=\"{}\">\n", esc_xml(&sym.name)));     xml.push_str("      <connects>\n"); -    // Generate connect mappings: gate pin name → pad number-    for (i, pin) in sym.pins.iter().enumerate() {+    // Generate connect mappings: gate pin name → pad number, for the primary gate and+    // then for every alternate gate (same pads: they are the same physical pins).+    let mut gate_pins: Vec<(&str, &ParsedSymbol, &Vec<String>)> = vec![("G$1", sym, &deduped_names)];+    for (a, names) in alts.iter().zip(alt_names.iter()) {+        gate_pins.push((a.gate.as_str(), &a.sym, names));+    }+    for (gate_name, gsym, gnames) in gate_pins {+    for (i, pin) in gsym.pins.iter().enumerate() {         // The pad reference is the symbol pin's NUMBER, which is the KiCad         // convention and right for almost every part. A pin map is the escape         // hatch for the ones where the footprint names that pad something else.@@ -1523,10 +1574,11 @@ pub(crate) fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_             None => pad.clone(),         };         xml.push_str(&format!(-            "        <connect gate=\"G$1\" pin=\"{}\" pad=\"{}\"/>\n",-            esc_xml(&deduped_names[i]), esc_xml(&pad_ref)+            "        <connect gate=\"{}\" pin=\"{}\" pad=\"{}\"/>\n",+            esc_xml(gate_name), esc_xml(&gnames[i]), esc_xml(&pad_ref)         ));     }+    }      xml.push_str("      </connects>\n");     // Attributes ride on <technology>, which is where Fusion's BOM export and the@@ -1887,7 +1939,7 @@ fn main() {     let cli = Cli::parse();      match cli.command {-        Commands::Generate { sym, fp, output, name, symbol_art, symbol_art_scale, pin_map, part_metadata, symbol_layout } => {+        Commands::Generate { sym, fp, output, name, symbol_art, symbol_art_scale, pin_map, part_metadata, symbol_layout, alt_sym } => {             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);@@ -1986,7 +2038,48 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or                 }             } -            let lbr = generate_lbr(&parsed, fp_content.as_deref(), art.as_ref(), symbol_art_scale, &pin_map, &part_attrs);+            // Alternate gates: `path[=GATE[=art]]`, each one more symbol of THIS part.+            let mut alts: Vec<AltGate> = Vec::new();+            for spec in &alt_sym {+                let mut it = spec.splitn(3, '=');+                let path = PathBuf::from(it.next().unwrap_or_default());+                let gate_opt = it.next().filter(|g| !g.is_empty()).map(|g| g.to_string());+                let art_opt = it.next().filter(|a| !a.is_empty() && *a != "-").map(PathBuf::from);+                let content = fs::read_to_string(&path).unwrap_or_else(|e| {+                    eprintln!("ERROR: Cannot read --alt-sym {}: {}", path.display(), e);+                    process::exit(1);+                });+                let mut alt = parse_kicad_sym(&content, None).unwrap_or_else(|e| {+                    eprintln!("ERROR: --alt-sym {}: {}", path.display(), e);+                    process::exit(1);+                });+                let gate = gate_opt.unwrap_or_else(|| {+                    let pfx = format!("{}-", parsed.name);+                    alt.name.strip_prefix(&pfx).map(|s| s.to_string()).unwrap_or_else(|| alt.name.clone())+                });+                if alt.name == parsed.name || alts.iter().any(|a| a.sym.name == alt.name) {+                    alt.name = format!("{}-{}", parsed.name, gate);+                }+                if alt.pins.len() != parsed.pins.len() {+                    eprintln!("ERROR: --alt-sym {} has {} pins, the primary symbol has {}: an alternate symbol must be the SAME part (same pin numbers)", path.display(), alt.pins.len(), parsed.pins.len());+                    process::exit(1);+                }+                let art = match art_opt.as_ref().map(|p| resolve_symbol_art(p)) {+                    Some(Ok(a)) => Some(a),+                    Some(Err(e)) => { eprintln!("ERROR: --alt-sym art: {e}"); process::exit(1); }+                    None => None,+                };+                if alts.iter().any(|a| a.gate == gate) || gate == "G$1" {+                    eprintln!("ERROR: duplicate gate name {:?} in --alt-sym; give it path=GATE", gate);+                    process::exit(1);+                }+                alts.push(AltGate { sym: alt, gate, art });+            }+            if !alts.is_empty() {+                println!("HINT: {} alternate symbol(s) as extra gates of {}: {}", alts.len(), parsed.name,+                    alts.iter().map(|a| format!("{} ({})", a.gate, a.sym.name)).collect::<Vec<_>>().join(", "));+            }+            let lbr = generate_lbr_gates(&parsed, fp_content.as_deref(), art.as_ref(), symbol_art_scale, &pin_map, &part_attrs, &alts);              // Lint before writing             let lint = lint_lbr(&lbr);