← Commit history

2.8.0: ordering-variant + extends symbol resolution, validate catches a dangling connect, generate takes a pin map, and a build.sh that keeps this page in step with the tarball

Ray ·74646ee60c ·2mo ago ·parent 7f503e2
7 files changed +453−30
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.7.1"+version = "2.8.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.7.1+2.8.0
build.shadded+63
@@ -0,0 +1,63 @@+#!/usr/bin/env bash+# Build and stage the adom-lbr release binary.+#+# It exists because the published binary once could not be rebuilt from the repo+# (adom/adom-lbr, 2026-08-21). Three things had drifted at once: the version was+# bumped in package.json alone, the vendored Babylon bundle was invisible in every+# source diff because it rides in via include_str!, and the wiki page's src/ was+# never synced with the GitHub tree the tarball was built from. This script makes+# the first and third mechanical, and checks the second.+#+#   ./build.sh            build, verify, stage bin/adom-lbr+#   ./build.sh 2.8.0      set the version everywhere first, then do that+set -euo pipefail+cd "$(dirname "$0")"++if [ $# -ge 1 ]; then+  V="$1"+  echo "[build] version -> $V (VERSION, Cargo.toml, package.json together)"+  printf '%s\n' "$V" > VERSION+  sed -i "0,/^version = \".*\"/s//version = \"$V\"/" Cargo.toml+  python3 - "$V" <<'PY'+import json, sys+p = json.load(open("package.json")); p["version"] = sys.argv[1]+json.dump(p, open("package.json", "w"), indent=2); open("package.json", "a").write("\n")+PY+fi++V="$(cat VERSION | tr -d '[:space:]')"+CARGO_V="$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2)"+PKG_V="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])')"+if [ "$V" != "$CARGO_V" ] || [ "$V" != "$PKG_V" ]; then+  echo "[build] FAIL version drift: VERSION=$V Cargo.toml=$CARGO_V package.json=$PKG_V" >&2+  echo "        run './build.sh <version>' to set all three at once" >&2+  exit 1+fi+echo "[build] version $V agrees across VERSION, Cargo.toml and package.json"++echo "[build] test"+cargo test --release --quiet++echo "[build] release"+cargo build --release --quiet++mkdir -p bin && cp target/release/adom-lbr bin/adom-lbr && chmod +x bin/adom-lbr++REPORTED="$(./bin/adom-lbr --version | awk '{print $2}')"+[ "$REPORTED" = "$V" ] || { echo "[build] FAIL binary reports $REPORTED, expected $V" >&2; exit 1; }++# The Babylon viewer is compiled in through include_str!, so it never shows up in+# a source diff. A binary that lost it still builds and still runs, it just serves+# a dead 3D pane, which is how three releases shipped before anyone noticed.+HITS="$(grep -a -c BABYLON bin/adom-lbr || true)"+[ "${HITS:-0}" -gt 100 ] || { echo "[build] FAIL vendored 3D viewer missing (BABYLON hits=$HITS)" >&2; exit 1; }++printf '[build] OK  %s  %.2f MB  BABYLON=%s\n' "$V" \+  "$(stat -c%s bin/adom-lbr | awk '{print $1/1048576}')" "$HITS"+cat <<HINT++[build] next, and do BOTH or the page stops matching the tarball:+  git push origin main+  adom-wiki repo push adom/adom-lbr --files <changed source> -m "..."   # keep the page's src/ in step+  adom-wiki pkg publish --org adom                                      # ships bin/adom-lbr+HINT
crates/altium-codec/src/kicad.rs+140−4
@@ -23,6 +23,49 @@ pub use crate::sexpr::{parse_root, Sx}; // Symbol import // --------------------------------------------------------------------------- +/// Does a KiCad library symbol name cover this MPN through its `x` placeholders?+///+/// `x`/`X` in the LIBRARY name is the wildcard (that is the direction KiCad uses:+/// `STM32F103C8Tx` is the library entry, `STM32F103C8T6` the ordered part). Every+/// other character must match case-insensitively, and the lengths must agree, so+/// `STM32F103C_8-B_Tx` does not swallow an unrelated part.+fn kicad_variant_matches(lib_name: &str, wanted: &str) -> bool {+    let (l, w): (Vec<char>, Vec<char>) = (lib_name.chars().collect(), wanted.chars().collect());+    if l.len() != w.len() {+        return false;+    }+    // a name that is all wildcard would match anything — require a real stem+    if !l.iter().any(|c| !c.eq_ignore_ascii_case(&'x')) {+        return false;+    }+    l.iter()+        .zip(w.iter())+        .all(|(a, b)| a.eq_ignore_ascii_case(&'x') || a.eq_ignore_ascii_case(b))+}++/// Follow `(extends "BASE")` to the symbol that actually carries the drawing.+///+/// KiCad derives ordering variants from one base symbol: the derived entry holds+/// only properties, and its units (pins + graphics) live in the parent. Returns+/// the input unchanged when it is not derived or the base is missing; the hop+/// count caps a malformed cycle.+fn resolve_extends<'a>(syms: &[&'a Sx], sym: &'a Sx) -> &'a Sx {+    let mut cur = sym;+    for _ in 0..8 {+        let Some(base) = cur.child("extends").and_then(|e| e.atom_at(1)) else {+            return cur;+        };+        match syms+            .iter()+            .find(|s| s.atom_at(1).is_some_and(|n| n.eq_ignore_ascii_case(base)))+        {+            Some(parent) => cur = parent,+            None => return cur,+        }+    }+    cur+}+ fn import_symbol(text: &str, wanted: &str) -> Result<Symbol> {     let root = parse_root(text)?;     // The file's declared format version drives version-scoped semantics@@ -42,6 +85,19 @@ fn import_symbol(text: &str, wanted: &str) -> Result<Symbol> {         _ => syms             .iter()             .find(|s| s.atom_at(1).is_some_and(|n| n.eq_ignore_ascii_case(wanted)))+            // KiCad names an ordering-variant family with `x` standing in for the+            // varying character(s): `STM32F103C8Tx` covers …T6/T7/T8. An exact miss+            // is therefore normal for a real MPN, so fall back to the wildcard match+            // and take it only when exactly one symbol claims the part.+            .or_else(|| {+                let mut hits = syms+                    .iter()+                    .filter(|s| s.atom_at(1).is_some_and(|n| kicad_variant_matches(n, wanted)));+                match (hits.next(), hits.next()) {+                    (Some(one), None) => Some(one),+                    _ => None,+                }+            })             .copied()             .with_context(|| {                 let names: Vec<_> = syms.iter().filter_map(|s| s.atom_at(1)).collect();@@ -53,6 +109,10 @@ fn import_symbol(text: &str, wanted: &str) -> Result<Symbol> {             })?,     };     let name = sym.atom_at(1).unwrap_or("SYM").to_string();+    // KiCad derived symbol: `(extends "BASE")` keeps its own properties but+    // inherits the DRAWING — pins, graphics, pin visibility — from the base.+    // Reading only the derived symbol yields a part with zero pins.+    let drawing: &Sx = resolve_extends(&syms, sym);      // designator prefix from the Reference property — of THIS symbol only     let mut prefix = "U".to_string();@@ -66,9 +126,9 @@ fn import_symbol(text: &str, wanted: &str) -> Result<Symbol> {         }     } -    // all pins anywhere under the symbol+    // all pins anywhere under the drawing symbol     let mut pin_lists = Vec::new();-    sym.collect("pin", &mut pin_lists);+    drawing.collect("pin", &mut pin_lists);     let mut pins = Vec::new();     for p in pin_lists {         let at = p.child("at").map(|a| a.nums()).unwrap_or_default();@@ -107,7 +167,7 @@ fn import_symbol(text: &str, wanted: &str) -> Result<Symbol> {     // Preserve the source's pin-number / pin-name visibility (KiCad     // `(pin_numbers (hide yes))`). Absent → shown (KiCad default).     let has_hide = |key: &str| -> bool {-        match sym.child(key) {+        match drawing.child(key) {             None => false,             Some(node) => node                 .child("hide")@@ -121,7 +181,7 @@ fn import_symbol(text: &str, wanted: &str) -> Result<Symbol> {         name,         pins,         body: None,-        graphics: import_symbol_graphics(sym),+        graphics: import_symbol_graphics(drawing),         designator_prefix: Some(prefix),         footprint: None,         parameters: Vec::new(),@@ -444,3 +504,79 @@ pub fn import_kicad(sym_text: &str, mod_text: &str, mpn: &str) -> Result<AdomLbr         provenance: Some(serde_json::json!({"source":"kicad_import"})),     }) }++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn ordering_variant_x_is_a_wildcard() {+        // the real case: the wiki library ships the family symbol, the page asks+        // for the ordered part+        assert!(kicad_variant_matches("STM32F103C8Tx", "STM32F103C8T6"));+        assert!(kicad_variant_matches("STM32F103C8TX", "stm32f103c8t7"));+        // a differently-shaped family name must not swallow it+        assert!(!kicad_variant_matches("STM32F103C_8-B_Tx", "STM32F103C8T6"));+        // length has to agree — no prefix matching+        assert!(!kicad_variant_matches("STM32F103C8Tx", "STM32F103C8T"));+        assert!(!kicad_variant_matches("STM32F103C8Tx", "STM32F103C8T66"));+        // a non-wildcard mismatch stays a mismatch+        assert!(!kicad_variant_matches("STM32F103CBTx", "STM32F103C8T6"));+        // an all-wildcard name has no stem, so it claims nothing+        assert!(!kicad_variant_matches("xxx", "AB1"));+    }++    #[test]+    fn variant_fallback_picks_the_family_symbol() {+        let lib = r#"(kicad_symbol_lib (version 20231120) (generator "t")+          (symbol "STM32F103C_8-B_Tx" (property "Reference" "U" (at 0 0 0)))+          (symbol "STM32F103C8Tx" (property "Reference" "U" (at 0 0 0))+            (symbol "STM32F103C8Tx_1_1"+              (pin bidirectional line (at -10 0 0) (length 2)+                (name "PA0") (number "1"))))+        )"#;+        let s = import_symbol(lib, "STM32F103C8T6").expect("resolves through the x wildcard");+        assert_eq!(s.name, "STM32F103C8Tx");+        assert_eq!(s.pins.len(), 1);+    }++    #[test]+    fn derived_symbol_inherits_the_base_drawing() {+        // KiCad's real shape: the ordering-variant entry carries properties and+        // extends the base symbol, which owns the pins+        let lib = r#"(kicad_symbol_lib (version 20251024) (generator "t")+          (symbol "STM32F103C8Tx"+            (extends "STM32F103C_8-B_Tx")+            (property "Reference" "U" (at 0 0 0)))+          (symbol "STM32F103C_8-B_Tx" (property "Reference" "U" (at 0 0 0))+            (symbol "STM32F103C_8-B_Tx_1_1"+              (pin bidirectional line (at -10 0 0) (length 2) (name "PA0") (number "1"))+              (pin power_in line (at 0 10 270) (length 2) (name "VDD") (number "2"))))+        )"#;+        let s = import_symbol(lib, "STM32F103C8T6").expect("resolves variant then extends");+        assert_eq!(s.name, "STM32F103C8Tx", "keeps the derived symbol's own name");+        assert_eq!(s.pins.len(), 2, "pins come from the base symbol");+    }++    #[test]+    fn missing_base_leaves_the_symbol_alone() {+        let lib = r#"(kicad_symbol_lib (version 20251024) (generator "t")+          (symbol "A" (extends "NOPE") (property "Reference" "U" (at 0 0 0))+            (symbol "A_1_1" (pin input line (at 0 0 0) (length 2) (name "I") (number "1"))))+          (symbol "B" (property "Reference" "U" (at 0 0 0)))+        )"#;+        let s = import_symbol(lib, "A").expect("dangling extends is not fatal");+        assert_eq!(s.pins.len(), 1);+    }++    #[test]+    fn ambiguous_variant_still_errors() {+        // two family symbols both cover the part → no silent guess+        let lib = r#"(kicad_symbol_lib (version 20231120) (generator "t")+          (symbol "STM32F103C8Tx" (property "Reference" "U" (at 0 0 0)))+          (symbol "STM32F103C8xx" (property "Reference" "U" (at 0 0 0)))+        )"#;+        let e = import_symbol(lib, "STM32F103C8T6").unwrap_err().to_string();+        assert!(e.contains("none is named"), "reports the miss: {e}");+    }+}
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.7.1",+  "version": "2.8.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+183−17
@@ -47,6 +47,14 @@ enum Commands {         /// Ignored for the `.mm.svg` contract (placement is exact).         #[arg(long, default_value_t = 0.38)]         symbol_art_scale: f64,+        /// JSON map from SYMBOL PIN NUMBER to FOOTPRINT PAD NAME, for parts where+        /// the two disagree: `{"S1": "SH"}`. KiCad binds a symbol pin's NUMBER to a+        /// footprint pad's NAME, so when a part names them differently (a USB-C+        /// shield pin "S1" against a pad "SH") there is otherwise no way to express+        /// the intended correspondence and the connect dangles. Defaults to+        /// `pinmap.json` beside the symbol when that file exists.+        #[arg(long)]+        pin_map: Option<PathBuf>,     },     /// Lint an existing .lbr file for Fusion 360 compatibility     Lint {@@ -1025,7 +1033,7 @@ fn symbol_art_block(art: &SymbolArt, body: Option<&KicadBody>, scale_frac: f64)     } } -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>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64, pin_map: &std::collections::HashMap<String, String>) -> String {     let mut xml = String::with_capacity(8192);      xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");@@ -1217,9 +1225,13 @@ fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option      // Generate connect mappings: gate pin name → pad number     for (i, pin) in sym.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.+        let pad = pin_map.get(&pin.number).unwrap_or(&pin.number);         xml.push_str(&format!(             "        <connect gate=\"G$1\" pin=\"{}\" pad=\"{}\"/>\n",-            esc_xml(&deduped_names[i]), esc_xml(&pin.number)+            esc_xml(&deduped_names[i]), esc_xml(pad)         ));     } @@ -1263,8 +1275,14 @@ pub(crate) fn lint_lbr(content: &str) -> LintResult {         errors.push(LintError { code: "missing-devicesets".into(), message: "No <devicesets> section found".into() });     } -    // Check for empty packages-    if content.contains("<packages></packages>") || content.contains("<packages/>") {+    // Check for empty packages. Matching the literal "<packages></packages>" missed+    // a library whose section held only whitespace or a comment, which shipped with+    // zero packages while its .kicad_mod had two perfectly good SMD pads. Ask the+    // real question instead: is there a package in there at all?+    if content.contains("<packages>") && !content.contains("<package name=") {+        errors.push(LintError { code: "empty-packages".into(), message: "Packages section is empty — Fusion will show 'No Components'".into() });+    }+    if content.contains("<packages/>") {         errors.push(LintError { code: "empty-packages".into(), message: "Packages section is empty — Fusion will show 'No Components'".into() });     } @@ -1349,20 +1367,77 @@ pub(crate) fn lint_lbr(content: &str) -> LintResult {         }     } -    // Check pad names match pin numbers in connects-    let connect_re = regex_lite::Regex::new(r#"<connect[^>]*pad="([^"]+)""#).unwrap();-    let smd_re = regex_lite::Regex::new(r#"<smd name="([^"]+)""#).unwrap();-    let pad_names: std::collections::HashSet<_> = smd_re.captures_iter(content).map(|c| c[1].to_string()).collect();-    for cap in connect_re.captures_iter(content) {-        let pad = &cap[1];-        if !pad_names.contains(pad) && !pad_names.is_empty() {-            warnings.push(LintError {-                code: "connect-pad-missing".into(),-                message: format!("Connect references pad '{}' which doesn't exist in the package", pad),-            });+    // Connect PAD integrity (adom/adom-lbr#9). This was a warning that only looked+    // at <smd>, so it stayed silent on through-hole parts and never failed the run:+    // a USB-C receptacle shipped with <connect pin="SHIELD" pad="S1"/> against a+    // package whose shield pad is "SH", and validate called the library complete.+    // The shield connected to nothing and every visual check still passed, which is+    // exactly the kind of defect that only shows up in hardware.+    //+    // So: an ERROR, counting BOTH <smd> and <pad> (through-hole), resolved against+    // the package the device actually names rather than globally, and the message+    // names the pads nothing connects to, since that is the half that tells you the+    // fix ("SH" is obviously the intended target).+    let package_block_re = regex_lite::Regex::new(r#"(?s)<package name="([^"]*)">(.*?)</package>"#).unwrap();+    let pad_name_re = regex_lite::Regex::new(r#"<(?:smd|pad) name="([^"]+)""#).unwrap();+    let mut pkg_pads: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();+    for pc in package_block_re.captures_iter(content) {+        let pads: Vec<String> = pad_name_re.captures_iter(&pc[2]).map(|c| c[1].to_string()).collect();+        pkg_pads.insert(pc[1].to_string(), pads);+    }++    let device_re = regex_lite::Regex::new(r#"(?s)<device name="[^"]*" package="([^"]*)">(.*?)</device>"#).unwrap();+    let connect_pad_re = regex_lite::Regex::new(r#"<connect[^>]*pin="([^"]*)"[^>]*pad="([^"]+)""#).unwrap();+    for dev in device_re.captures_iter(content) {+        let pkg = dev[1].to_string();+        let body = &dev[2];+        let Some(pads) = pkg_pads.get(&pkg) else { continue };   // package missing is its own error+        if pads.is_empty() { continue; }+        let pad_set: std::collections::HashSet<&String> = pads.iter().collect();+        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();+        let mut dangling: Vec<(String, String)> = Vec::new();+        for c in connect_pad_re.captures_iter(body) {+            let (pin, pad) = (c[1].to_string(), c[2].to_string());+            if pad_set.contains(&pad) { used.insert(pad); } else { dangling.push((pin, pad)); }+        }+        if !dangling.is_empty() {+            // One name, once: a USB-C shield is four tabs all named SH, and+            // "SH, SH, SH, SH" reads like four different problems.+            let mut seen_unref: std::collections::HashSet<&str> = std::collections::HashSet::new();+            let unref: Vec<&str> = pads.iter()+                .filter(|p| !used.contains(*p))+                .map(|p| p.as_str())+                .filter(|p| seen_unref.insert(p))+                .collect();+            for (pin, pad) in &dangling {+                errors.push(LintError {+                    code: "connect-pad-unresolved".into(),+                    message: format!(+                        "pin \"{}\" -> pad \"{}\" (no such pad in package '{}'{}). \+Symbol pin numbers and footprint pad names disagree for this part: supply a pin map \+(generate --pin-map) or rename the pad in the footprint.",+                        pin, pad, pkg,+                        if unref.is_empty() { String::new() }+                        else { format!("; unreferenced pads in package: {}", unref.join(", ")) }+                    ),+                });+            }         }     } +    // A published .lbr must not carry <package3d>/urn: that is an account-scoped+    // pointer into the minting user's Autodesk cloud, not embedded geometry, so it+    // resolves to nothing for anyone else. Ship the .step beside the library and let+    // each viewer's Fusion mint its own 3D.+    if content.contains("<package3d") || content.contains("wip_urn") || content.contains("urn:adsk") {+        errors.push(LintError {+            code: "package3d-urn".into(),+            message: "Library carries a <package3d>/Autodesk urn — that is a pointer into the \+minting account's cloud, not geometry, and resolves to nothing for anyone else. Remove it and \+ship the .step alongside the .lbr.".into(),+        });+    }+     // Check XML is well-formed     if content.matches('<').count() != content.matches('>').count() {         errors.push(LintError { code: "malformed-xml".into(), message: "Unbalanced XML angle brackets".into() });@@ -1470,7 +1545,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, symbol_art, symbol_art_scale, pin_map } => {             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);@@ -1510,7 +1585,33 @@ fn main() {                 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);+            // Pin map: an explicit --pin-map wins, else pinmap.json beside the+            // symbol, so a page that publishes the sidecar just works.+            let pin_map_path = pin_map.clone().or_else(|| {+                sym.as_ref()+                    .and_then(|p| p.parent())+                    .map(|d| d.join("pinmap.json"))+                    .filter(|p| p.exists())+            });+            let pin_map: std::collections::HashMap<String, String> = match &pin_map_path {+                Some(path) => {+                    let raw = fs::read_to_string(path).unwrap_or_else(|e| {+                        eprintln!("ERROR: Cannot read pin map {}: {}", path.display(), e);+                        process::exit(1);+                    });+                    serde_json::from_str(&raw).unwrap_or_else(|e| {+                        eprintln!("ERROR: {} is not a JSON object of \"pin number\": \"pad name\": {}", path.display(), e);+                        process::exit(1);+                    })+                }+                None => std::collections::HashMap::new(),+            };+            if !pin_map.is_empty() {+                println!("HINT: pin map applied ({} entries) from {}", pin_map.len(),+                    pin_map_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default());+            }++            let lbr = generate_lbr(&parsed, fp_content.as_deref(), art.as_ref(), symbol_art_scale, &pin_map);              // Lint before writing             let lint = lint_lbr(&lbr);@@ -1975,3 +2076,68 @@ fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String>     println!("OK: 3-column interactive embed ({pin_count} pins) → {}", out.display());     Ok(()) }++#[cfg(test)]+mod lint_tests {+    use super::lint_lbr;++    /// A library shaped like the one that shipped broken: a USB-C receptacle whose+    /// symbol calls the shield pin S1 while the footprint names that pad SH.+    fn lbr(connect_pad: &str, extra_pkg: &str) -> String {+        format!(+            r#"<eagle><drawing><library>+<packages><package name="TYPEC"><smd name="A4" x="0" y="0"/><smd name="SH" x="3" y="0"/>{extra}</package></packages>+<symbols><symbol name="TYPEC"><pin name="VBUS" x="0" y="0"/><pin name="SHIELD" x="0" y="5"/></symbol></symbols>+<devicesets><deviceset name="TYPEC"><gates><gate name="G$1" symbol="TYPEC" x="0" y="0"/></gates>+<devices><device name="" package="TYPEC"><connects>+<connect gate="G$1" pin="VBUS" pad="A4"/>+<connect gate="G$1" pin="SHIELD" pad="{pad}"/>+</connects><technologies><technology name=""/></technologies></device></devices>+</deviceset></devicesets>+</library></drawing></eagle>"#,+            pad = connect_pad, extra = extra_pkg+        )+    }++    #[test]+    fn dangling_connect_pad_fails_and_names_the_unreferenced_pad() {+        let r = lint_lbr(&lbr("S1", ""));+        assert!(!r.passed, "a connect pointing at a pad that does not exist must FAIL, not warn");+        let e = r.errors.iter().find(|e| e.code == "connect-pad-unresolved").expect("connect-pad-unresolved");+        assert!(e.message.contains("\"SHIELD\"") && e.message.contains("\"S1\""), "names the pin and the pad: {}", e.message);+        assert!(e.message.contains("unreferenced pads in package: SH"),+            "names the pad nothing connects to, which is the half that shows the fix: {}", e.message);+    }++    #[test]+    fn resolved_connect_passes() {+        let r = lint_lbr(&lbr("SH", ""));+        assert!(r.errors.iter().all(|e| e.code != "connect-pad-unresolved"), "SH resolves: {:?}", r.errors);+    }++    #[test]+    fn through_hole_pads_count_too() {+        // the old check read <smd> only, so every through-hole part passed blind+        let tht = lbr("SH", "").replace(r#"<smd name="SH" x="3" y="0"/>"#, r#"<pad name="SH" x="3" y="0"/>"#);+        assert!(lint_lbr(&tht).errors.iter().all(|e| e.code != "connect-pad-unresolved"),+            "a <pad> (through-hole) resolves a connect just like an <smd>");+    }++    #[test]+    fn published_library_must_not_carry_an_autodesk_urn() {+        let r = lint_lbr(&lbr("SH", r#"<package3d name="X" urn="urn:adsk.wipprod:fs.file:co.abc"/>"#));+        assert!(!r.passed);+        assert!(r.errors.iter().any(|e| e.code == "package3d-urn"), "{:?}", r.errors);+    }++    #[test]+    fn empty_packages_is_caught_through_whitespace() {+        // the literal "<packages></packages>" match missed a section holding only+        // whitespace, and such a library shipped with zero packages+        let empty = lbr("SH", "").replace(+            r#"<package name="TYPEC"><smd name="A4" x="0" y="0"/><smd name="SH" x="3" y="0"/></package>"#,+            "\n\n  ");+        let r = lint_lbr(&empty);+        assert!(r.errors.iter().any(|e| e.code == "empty-packages"), "{:?}", r.errors);+    }+}
src/manage.rs+64−6
@@ -81,8 +81,19 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {             json_resp(&board_deps(slug).to_string())         }         (Method::Get, p) if p.starts_with("/glb/") => {+            // `?file=<name>` picks one of the page's models (see `glbs` in+            // /api/part); without it the first .glb on the page wins. The match+            // arm sees the query-stripped path, so read the query off `url`.+            let want: Option<&str> = url+                .split_once('?')+                .and_then(|(_, q)| q.split('&').find_map(|kv| kv.strip_prefix("file=")));             let slug = &p["/glb/".len()..];-            match list_page_files(slug).iter().find(|n| n.ends_with(".glb")).and_then(|n| wiki_bytes(slug, n)) {+            let files = list_page_files(slug);+            let pick = match want {+                Some(w) => files.iter().find(|n| n.as_str() == w),+                None => files.iter().find(|n| n.ends_with(".glb")),+            };+            match pick.filter(|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),             }@@ -165,7 +176,12 @@ fn get_part(slug: &str) -> Value {                 "hints":["the page needs an adom-lbr JSON, or <MPN>.kicad_sym + <MPN>.kicad_mod"]})         }     };-    let has_glb = list_page_files(slug).iter().any(|n| n.ends_with(".glb"));+    // every .glb the page carries — a part page often has both the bare chip and+    // a board render, and only the caller knows which one it wants to show+    let glbs: Vec<String> = list_page_files(slug)+        .into_iter()+        .filter(|n| n.ends_with(".glb"))+        .collect();     json!({         "status": "ok",         "slug": slug,@@ -174,7 +190,9 @@ fn get_part(slug: &str) -> Value {         "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": has_glb,+        "glb": !glbs.is_empty(),+        "glbs": glbs,+        "pinmap": pin_map(&part),            // the 4th pane: pin -> pad, by number         "pins": part.symbol.pins.len(),         "pads": part.footprint.pads.len(),     })@@ -441,6 +459,38 @@ fn symbol_svg(sym: &Symbol) -> String { }  /// number → pin name, so a pad can show the connected pin's name on hover.+/// The pin -> pad map, in symbol pin order: what the pin-map pane renders and+/// what makes the other three panes cross-linkable by pin number. Pads with no+/// symbol pin (a mounting or thermal pad) are appended so the table stays a+/// complete account of the footprint.+fn pin_map(part: &AdomLbrPart) -> Value {+    let pad_of: std::collections::HashMap<&str, &str> = part+        .connect+        .iter()+        .map(|c| (c.pin.as_str(), c.pad.as_str()))+        .collect();+    let mut rows: Vec<Value> = part+        .symbol+        .pins+        .iter()+        .map(|p| {+            json!({+                "num": p.number,+                "name": p.name,+                "pad": pad_of.get(p.number.as_str()).copied().unwrap_or(""),+            })+        })+        .collect();+    let seen: std::collections::HashSet<&str> =+        part.symbol.pins.iter().map(|p| p.number.as_str()).collect();+    for pad in &part.footprint.pads {+        if !seen.contains(pad.number.as_str()) {+            rows.push(json!({"num": pad.number, "name": "", "pad": pad.number}));+        }+    }+    Value::Array(rows)+}+ fn pin_names(sym: &Symbol) -> std::collections::HashMap<String, String> {     sym.pins         .iter()@@ -490,10 +540,18 @@ fn footprint_svg(fp: &Footprint, names: &std::collections::HashMap<String, Strin         let name = names.get(&p.number).cloned().unwrap_or_default();         body += &format!("<rect class='ly-copper pad' data-num='{}' data-name='{}' x='{:.3}' y='{:.3}' width='{:.3}' height='{:.3}' rx='{:.3}' fill='{}' stroke='#00000033' stroke-width='0.03' opacity='0.94'/>",             esc_attr(&p.number), esc_attr(&name), p.x_mm - p.w_mm / 2.0, p.y_mm - p.h_mm / 2.0, p.w_mm, p.h_mm, rx, col);-        // pad number, centred on the pad (dark on copper) — matches the CAD mockup+        // pad number, centred on the pad (dark on copper) — matches the CAD mockup.+        // A tall pad (the top/bottom rows of a QFP) gets the number turned a quarter+        // turn so it reads ALONG the pad instead of spilling over both edges.         let fs = (p.w_mm.min(p.h_mm) * 0.6).clamp(0.35, 1.1);-        body += &format!("<text class='padnum' x='{:.3}' y='{:.3}' font-size='{:.3}' fill='#1c1206' text-anchor='middle' dominant-baseline='central' pointer-events='none' font-family='ui-monospace,monospace' font-weight='700'>{}</text>",-            p.x_mm, p.y_mm, fs, esc_attr(&p.number));+        let turned = p.h_mm > p.w_mm * 1.35;+        let rot = if turned {+            format!(" transform='rotate(-90 {:.3} {:.3})'", p.x_mm, p.y_mm)+        } else {+            String::new()+        };+        body += &format!("<text class='padnum' x='{:.3}' y='{:.3}' font-size='{:.3}'{} fill='#1c1206' text-anchor='middle' dominant-baseline='central' pointer-events='none' font-family='ui-monospace,monospace' font-weight='700'>{}</text>",+            p.x_mm, p.y_mm, fs, rot, esc_attr(&p.number));     }     format!(         "<svg xmlns='http://www.w3.org/2000/svg' viewBox='{:.3} {:.3} {:.3} {:.3}' style='width:100%;height:100%'>{}</svg>",