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
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
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>",