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.
generate: emit fp_poly as <polygon> (pin-1 marker) and part attributes on <technology> #3
Two gaps the fusion-bridge 1.9.232 end-to-end run surfaced, both in the emitter rather than in the component data.
1. fp_poly was never parsed — no pin-1 marker reaches Fusion (#933)
package_graphics() handled fp_circle, fp_rect, fp_line and fp_arc; its own docstring listed exactly those. KiCad draws the pin-1 marker as a small filled polygon on F.SilkS, and the gullwing generator draws the whole F.Fab body outline as one, so both were dropped silently while the geometry looked perfect.
Scope: across 2,540 published component pages, 2,473 (97%) mark pin 1 with an F.SilkS polygon, 24 use only an F.Fab chamfer, 43 (DIP/TO-92/TO-220) draw none and rely on pad-1 position. So one missing shape accounted for essentially the whole library.
Now emitted as EAGLE <polygon> with Y negated, consistent with how pads are already handled:
<polygon width="0.1200" layer="21">
<vertex x="-2.6000" y="2.4700"/><vertex x="-2.8400" y="2.8000"/><vertex x="-2.3600" y="2.8000"/>
</polygon>
against the KiCad source (-2.6,-2.47) (-2.84,-2.8) (-2.36,-2.8) on SOIC-8_3.9x4.9mm_P1.27mm.
2. Part attributes were never emitted — geometry with nothing orderable
<technologies><technology name=""/></technologies> was hardcoded, so every generated .lbr shipped zero attributes. Confirmed on ray/adr4525 and ray/a4950: no MPN, no MANUFACTURER, no distributor part numbers.
generate now reads a wiki component page.json beside the symbol — the same auto-discovery pinmap.json already uses — or an explicit --part-metadata, and emits:
<technologies>
<technology name="">
<attribute name="MPN" value="ADR4525" constant="no"/>
<attribute name="MANUFACTURER" value="Analog Devices" constant="no"/>
<attribute name="MOUSER_PN" value="584-ADR4525BRZ-R7" constant="no"/>
<attribute name="DIGIKEY_PN" value="505-ADR4525BRZ-ND" constant="no"/>
<attribute name="LCSC_PN" value="C123456" constant="no"/>
</technology>
</technologies>
Distributor numbers are read from distributor_links as either a bare string or an object with pn / part_number, since both shapes are already in use, and from flat mouser_pn / digikey_pn / lcsc_pn keys. A part with no metadata still generates and emits the previous empty technology, with a hint naming what is missing rather than an error.
Deliberately not changed
eagle_safe_name() already exists and dedup_pin_names() already calls it, so illegal pin names are handled in main — I wrote a duplicate before finding it and removed mine. Worth knowing that the published 2.16.0 binary predates it: EXPOSED PAD survives into the .lbr on 16 published pages built with it, and the newer validate correctly reports illegal-pin-name. Those pages need regenerating, not a code change.
The <technology name="..."> variant structure (baseline vs automotive sharing geometry, each with its own DESCRIPTION) is not implemented — that spec lives in the fusion-part-metadata skill, which I could not read from here, and guessing at the shape seemed worse than leaving it to a follow-up.
Verification
./build.sh 2.18.0— passes, tests included. Note the repo had pre-existing version drift (VERSION2.17.1 vspackage.json2.16.0) whichbuild.shrejects; all three are now 2.18.0.- Regression across
adr4525,al8843sp-13,ld1117s33ctr,at7456e,mc7805cdtrkg: identical pin counts before/after, allvalidateandlintclean, polygons and attributes present, zero illegal pin names. - The no-metadata path still emits the original empty technology.
What still needs doing by someone with GitHub access
build.sh says to do both halves or the page stops matching the tarball. I could only do the wiki half — git push origin main and adom-wiki pkg publish --org adom still need running. This PR is source only - PRs carry text, so the rebuilt bin/adom-lbr (2.50 MB, verified reporting 2.18.0) is not in it and ships with pkg publish.
Diff Skip to comments (2)
@@ -1,2740 +1,2830 @@⋯ 80 unchanged lines ⋯ /// `pinmap.json` beside the symbol when that file exists. #[arg(long)] pin_map: Option<PathBuf>,- /// Schematic-symbol pin layout policy. `auto` (default): a symbol that- /// mirrors the physical PACKAGE (pins on all four sides, rotated names)- /// is re-laid into EE-convention two columns — inputs left, outputs and- /// bidirectional right, power grouped bottom-left, every name- /// horizontal, buses contiguous. `two-column` forces the re-layout even- /// for a left/right source; `keep` preserves the .kicad_sym placement- /// verbatim.- #[arg(long, default_value = "auto")]- symbol_layout: String,- },- /// Lint an existing .lbr file for Fusion 360 compatibility- Lint {- /// Path to the .lbr file- path: PathBuf,- },- /// Assemble many single-part .lbr files into ONE multi-deviceset library.- /// Packages and symbols dedupe by content, name collisions rename with a- /// numeric suffix, lint-failing inputs are refused, and the assembled- /// document must itself pass lint. Prints a JSON summary.- Assemble {- /// Output .lbr path- #[arg(long, short = 'o')]- out: PathBuf,- /// Library description (default: Adom Cloud Library)- #[arg(long)]- name: Option<String>,- /// Input part .lbr files- inputs: Vec<PathBuf>,- },- /// Validate a .lbr file has complete deviceset (symbol + package + connects)- Validate {- /// Path to the .lbr file- path: PathBuf,- },- /// Import an EAGLE .lbr and convert to KiCad .kicad_sym- Import {- /// Path to the .lbr file- path: PathBuf,- /// Output .kicad_sym file path (default: <name>.kicad_sym)- #[arg(short, long)]- output: Option<PathBuf>,- },- /// Verify a .kicad_sym via service-kicad (headless KiCad validation)- Check {- /// Path to the .kicad_sym file- path: PathBuf,- },- /// Export a Fusion-style 3-column INTERACTIVE HTML package — symbol | footprint- /// | 3D, each pan/zoomable, with a pin→pad mapping table carrying the datasheet- /// descriptions below. Self-contained for embedding in an iframe (e.g. a wiki- /// component page). Assembles from a chip directory's renders + extracted data.- Embed {- /// Chip directory (a chip-fetcher library entry) to assemble the viewer from- #[arg(long)]- dir: PathBuf,- /// Output .html path (default: <mpn>-lbr-embed.html in the dir)- #[arg(long)]- out: Option<PathBuf>,- },- /// Export per-slot interactive viewers (viewers/symbol.html + footprint.html)- /// with the cross-highlight bus + tooltips + layer toggles, for a wiki- /// component page's native viewer slots. CAD is rendered from the wiki slug;- /// pin descriptions come from <MPN>-symbol.extracted.json in --dir.- ExportViewers {- /// Wiki page slug (renders symbol/footprint from its CAD)- #[arg(long)]- slug: String,- /// Local chip dir holding <MPN>-symbol.extracted.json (pin type + descriptions)- #[arg(long)]- dir: PathBuf,- /// Output directory for the viewer HTML files (default: ./viewers)- #[arg(long)]- out: Option<PathBuf>,- /// Omit the symbol slot's built-in pin table (use when the page carries its- /// own pin table, e.g. a rich readme datasheet table, to avoid duplication)- #[arg(long)]- no_pin_table: bool,- },- /// Export ONE configurable, cross-linked viewer combining symbol + footprint +- /// 3D + pin-map in a declared layout (columns | quad | single). Which panes and- /// per-pane options (e.g. the pin-map description column) are configurable via- /// flags or a page.json `lbrView` block — same spirit as the wiki `sections`- /// allowlist. Renders CAD from the hub/slug; pin descriptions come from- /// <MPN>-symbol.extracted.json in --dir.- View {- /// Wiki page slug (or hub <MPN>.adom-lbr.json in --dir)- #[arg(long)]- slug: String,- /// Local chip dir holding the hub JSON + <MPN>-symbol.extracted.json + .glb- #[arg(long)]- dir: PathBuf,- /// Output .html path (default: <MPN>-lbr-<layout>.html in --dir)- #[arg(long)]- out: Option<PathBuf>,- /// Layout mode: columns | quad | single- #[arg(long, default_value = "quad")]- layout: String,- /// Comma-separated panes in order (default: all available). e.g. symbol,footprint,3d,pins- #[arg(long)]- panes: Option<String>,- /// Show the pin-map description column (default: on for quad/single, off for columns)- #[arg(long)]- description: Option<bool>,- /// Show the pin-map type roll-up footer (default: on for quad)- #[arg(long)]- rollup: Option<bool>,- /// Read layout/panes/paneOpts from a page.json `lbrView` block (overrides the flags)- #[arg(long)]- config: Option<PathBuf>,- },- /// Run the live app server (AI-drivable: /state, /load, /eval, /console)- Serve {- /// Port to listen on- #[arg(long, default_value_t = 8784)]- port: u16,- /// Optional .lbr to load into the view on startup- #[arg(long)]- file: Option<PathBuf>,- },- /// adom-lbr Manager: browse wiki components, view symbol/footprint/3D + the- /// footprint layer stackup (KiCad + Altium). Renders SVG on the fly per click.- /// Foreground alias of `dashboard serve --foreground`.- Manage {- /// Port to listen on- #[arg(long, default_value_t = 8785)]- port: u16,- },- /// The adom-lbr Dashboard (the Manager plus live status, settings and the- /// Hydrogen Dock Bar contract). `dashboard serve --print-url` is what the- /// dock runs: idempotent, version-aware, self-backgrounding.- Dashboard {- #[command(subcommand)]- cmd: DashboardCmd,- },- /// Import a KiCad symbol + footprint into the canonical adom-lbr JSON (the hub).- ImportKicad {- /// Path to the .kicad_sym file- #[arg(long)]- sym: PathBuf,- /// Path to the .kicad_mod footprint file- #[arg(long)]- fp: PathBuf,- /// Manufacturer part number / component id- #[arg(long, default_value = "PART")]- mpn: String,- /// Output adom-lbr JSON path (default: <mpn>.adom-lbr.json)- #[arg(long)]- out: Option<PathBuf>,- /// JSON map from SYMBOL PIN NUMBER to FOOTPRINT PAD NAME, for parts- /// where the two disagree (a USB-C shield pin "S1" against a pad "SH").- /// Defaults to `pinmap.json` beside the symbol when that file exists.- #[arg(long)]- pin_map: Option<PathBuf>,- },- /// Convert an adom-lbr JSON into native Altium .SchLib + .PcbLib (no Altium needed).- ExportAltium {- /// Path to the adom-lbr JSON- part: PathBuf,- /// Output directory (files named <mpn>.SchLib / <mpn>.PcbLib)- #[arg(long, default_value = ".")]- out_dir: PathBuf,- },- /// Convert an adom-lbr JSON into one self-contained Altium .IntLib.- ExportIntlib {- /// Path to the adom-lbr JSON- part: PathBuf,- /// Output .IntLib path (default: <mpn>.IntLib)- #[arg(long)]- out: Option<PathBuf>,- },- /// Convert an adom-lbr JSON into KiCad .kicad_sym + .kicad_mod.- ExportKicad {- /// Path to the adom-lbr JSON- part: PathBuf,- /// Output directory (files named <mpn>.kicad_sym / <mpn>.kicad_mod)- #[arg(long, default_value = ".")]- out_dir: PathBuf,- },- /// Add an adom-lbr JSON's symbol into an EXISTING Altium .SchLib (non-destructive merge).- AddToSchlib {- /// Existing .SchLib to merge into- existing: PathBuf,- /// Path to the adom-lbr JSON (or a bare Symbol / array of Symbols)- part: PathBuf,- /// Output .SchLib path (default: merged.SchLib)- #[arg(long)]- out: Option<PathBuf>,- },- /// Convert an Altium .SchLib / .PcbLib / .IntLib into the canonical adom-lbr JSON.- /// Pass a .SchLib or .PcbLib and the other half via --pcblib/--schlib, or one .IntLib.- ImportAltium {- /// Path to a .SchLib, .PcbLib, or .IntLib- file: PathBuf,- /// The .PcbLib half (when `file` is a .SchLib)- #[arg(long)]- pcblib: Option<PathBuf>,- /// The .SchLib half (when `file` is a .PcbLib)- #[arg(long)]- schlib: Option<PathBuf>,- /// MPN / component id for the output part (default: the symbol's name)- #[arg(long, default_value = "")]- mpn: String,- /// Output adom-lbr JSON path (default: <mpn>.adom-lbr.json)- #[arg(long)]- out: Option<PathBuf>,- },- /// Install skill + bash completions- Install,- /// Check health- Health,- /// Generate shell completions- Completions {- #[arg(value_enum)]- shell: clap_complete::Shell,- },-}--// ── KiCad .kicad_sym parser ──--#[derive(Debug, Serialize, Deserialize)]-struct KicadPin {- name: String,- number: String,- pin_type: String,- x: f64,- y: f64,- angle: u32,- length: f64,-}--#[derive(Debug, Serialize, Deserialize)]-struct KicadBody {- left: f64,- top: f64,- right: f64,- bottom: f64,-}--#[derive(Debug, Serialize, Deserialize)]-struct GroupLabel {- text: String,- x: f64,- y: f64,-}--#[derive(Debug, Serialize, Deserialize)]-struct ParsedSymbol {- name: String,- reference: String,- value: String,- footprint: String,- description: String,- pins: Vec<KicadPin>,- body: Option<KicadBody>,- group_labels: Vec<GroupLabel>,- graphics: Vec<SymGraphic>,-}--pub(crate) fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedSymbol, String> {- let name = if let Some(n) = name_override {- n.to_string()- } else {- let re = regex_lite::Regex::new(r#"\(symbol "([^"]+)""#).unwrap();- re.captures(content)- .and_then(|c| c.get(1))- .map(|m| m.as_str().to_string())- .ok_or("Cannot find symbol name in .kicad_sym")?- };-- let mut pins = Vec::new();- // The pin angle may be an int (`180`) or a float (`180.0`) depending on the- // KiCad version that wrote the symbol; accept both. The shape/electrical- // tokens may also be hyphenated (e.g. `power_in`, `no_connect`).- let pin_re = regex_lite::Regex::new(- r#"(?s)\(pin\s+([\w-]+)\s+[\w-]+\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)\s+\(length\s+([-\d.]+)\).*?\(name\s+"([^"]*)".*?\(number\s+"([^"]*)""#- ).unwrap();-- for cap in pin_re.captures_iter(content) {- pins.push(KicadPin {- pin_type: cap[1].to_string(),- x: cap[2].parse().unwrap_or(0.0),- y: cap[3].parse().unwrap_or(0.0),- angle: cap[4].parse::<f64>().unwrap_or(0.0).round() as u32,- length: cap[5].parse().unwrap_or(2.54),- name: cap[6].to_string(),- number: cap[7].to_string(),- });- }-- let body = {- let rect_re = regex_lite::Regex::new(- r#"\(rectangle\s+\(start\s+([-\d.]+)\s+([-\d.]+)\)\s+\(end\s+([-\d.]+)\s+([-\d.]+)\)"#- ).unwrap();- rect_re.captures(content).map(|c| KicadBody {- left: c[1].parse().unwrap_or(0.0),- top: c[2].parse().unwrap_or(0.0),- right: c[3].parse().unwrap_or(0.0),- bottom: c[4].parse().unwrap_or(0.0),- })- };-- // 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.]+)"#- ).unwrap();- for cap in text_re.captures_iter(content) {- group_labels.push(GroupLabel {- text: cap[1].to_string(),- x: cap[2].parse().unwrap_or(0.0),- y: cap[3].parse().unwrap_or(0.0),- });- }-- let prop = |key: &str| -> String {- let re = regex_lite::Regex::new(&format!(r#"\(property\s+"{}"\s+"([^"]*)""#, key)).unwrap();- re.captures(content)- .and_then(|c| c.get(1))- .map(|m| m.as_str().to_string())- .unwrap_or_default()- };-- Ok(ParsedSymbol {- name,- reference: prop("Reference"),- value: prop("Value"),- footprint: prop("Footprint"),- description: prop("Description"),- pins,- body,- group_labels,- graphics,- })-}--// ── EAGLE .lbr parser (import) ──--fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> {- let pin_re = regex_lite::Regex::new(- r#"<pin name="([^"]+)" x="([^"]+)" y="([^"]+)" length="([^"]+)" direction="([^"]+)"([^/]*)/>"#- ).unwrap();-- let sym_name_re = regex_lite::Regex::new(r#"<symbol name="([^"]+)">"#).unwrap();- let name = sym_name_re.captures(content)- .and_then(|c| c.get(1))- .map(|m| m.as_str().to_string())- .ok_or("Cannot find <symbol name=\"...\"> in .lbr")?;-- let desc_re = regex_lite::Regex::new(r#"<description>([^<]*)</description>"#).unwrap();- let description = desc_re.captures(content)- .and_then(|c| c.get(1))- .map(|m| m.as_str().to_string())- .unwrap_or_default()- .replace("&", "&").replace("<", "<").replace(">", ">")- .replace(""", "\"").replace("'", "'");-- let prefix_re = regex_lite::Regex::new(r#"<deviceset[^>]*prefix="([^"]+)""#).unwrap();- let reference = prefix_re.captures(content)- .and_then(|c| c.get(1))- .map(|m| m.as_str().to_string())- .unwrap_or_else(|| "U".to_string());-- let rect_re = regex_lite::Regex::new(- r#"<wire x1="([^"]+)" y1="([^"]+)" x2="([^"]+)" y2="([^"]+)" width="[^"]*" layer="94"/>"#- ).unwrap();-- let mut min_x = f64::MAX;- let mut min_y = f64::MAX;- let mut max_x = f64::MIN;- let mut max_y = f64::MIN;- let mut has_body = false;- for cap in rect_re.captures_iter(content) {- has_body = true;- let x1: f64 = cap[1].parse().unwrap_or(0.0);- let y1: f64 = cap[2].parse().unwrap_or(0.0);- let x2: f64 = cap[3].parse().unwrap_or(0.0);- let y2: f64 = cap[4].parse().unwrap_or(0.0);- min_x = min_x.min(x1).min(x2);- min_y = min_y.min(y1).min(y2);- max_x = max_x.max(x1).max(x2);- max_y = max_y.max(y1).max(y2);- }-- let body = if has_body {- Some(KicadBody { left: min_x, top: max_y, right: max_x, bottom: min_y })- } else {- None- };-- let connect_re = regex_lite::Regex::new(- r#"<connect gate="[^"]*" pin="([^"]+)" pad="([^"]+)"/>"#- ).unwrap();- let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();- for cap in connect_re.captures_iter(content) {- pin_to_pad.insert(cap[1].to_string(), cap[2].to_string());- }-- let length_val = |kw: &str| -> f64 {- match kw {- "point" => 0.0,- "short" => 2.54,- "middle" => 5.08,- "long" => 7.62,- _ => 2.54,- }- };-- let eagle_to_kicad_dir = |d: &str| -> &'static str {- match d {- "pwr" => "power_in",- "in" => "input",- "out" => "output",- "io" => "bidirectional",- "pas" => "passive",- "nc" => "unconnected",- "hiz" => "tri_state",- "oc" => "open_collector",- _ => "bidirectional",- }- };-- let rot_re = regex_lite::Regex::new(r#"rot="R(\d+)""#).unwrap();-- let mut pins = Vec::new();- for cap in pin_re.captures_iter(content) {- let pin_name_raw = cap[1].to_string();- let pin_name = pin_name_raw.replace("@2", "").replace("@3", "")- .replace("@4", "").replace("@5", "").replace("@6", "")- .replace("@7", "").replace("@8", "").replace("@9", "");-- let pad_number = pin_to_pad.get(&pin_name_raw)- .cloned()- .unwrap_or_else(|| "0".to_string());-- let angle = rot_re.captures(&cap[6])- .and_then(|c| c.get(1))- .and_then(|m| m.as_str().parse::<u32>().ok())- .unwrap_or(0);-- pins.push(KicadPin {- name: pin_name,- number: pad_number,- pin_type: eagle_to_kicad_dir(&cap[5]).to_string(),- x: cap[2].parse().unwrap_or(0.0),- y: cap[3].parse().unwrap_or(0.0),- angle,- length: length_val(&cap[4]),- });- }-- Ok(ParsedSymbol {- value: name.clone(),- name,- reference,- footprint: String::new(),- description,- pins,- body,- group_labels: Vec::new(),- graphics: Vec::new(),- })-}--// ── KiCad .kicad_sym generator ──--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");- 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");- s.push_str(" (in_bom yes)\n (on_board yes)\n");-- s.push_str(&format!(" (property \"Reference\" \"{}\"\n (at 0 2.54 0)\n (effects (font (size 1.27 1.27)))\n )\n", sym.reference));- s.push_str(&format!(" (property \"Value\" \"{}\"\n (at 0 -2.54 0)\n (effects (font (size 1.27 1.27)))\n )\n", sym.name));- s.push_str(&format!(" (property \"Footprint\" \"{}\"\n (at 0 0 0)\n (effects (font (size 1.27 1.27)) (hide yes))\n )\n",- sym.footprint.replace('"', "\\\"")- ));- s.push_str(" (property \"Datasheet\" \"\"\n (at 0 0 0)\n (effects (font (size 1.27 1.27)) (hide yes))\n )\n");- if !sym.description.is_empty() {- s.push_str(&format!(" (property \"Description\" \"{}\"\n (at 0 0 0)\n (effects (font (size 1.27 1.27)) (hide yes))\n )\n",- sym.description.replace('"', "\\\"")- ));- }-- s.push_str(&format!(" (symbol \"{}_0_{}\"\n", sym.name, 1));-- if let Some(body) = &sym.body {- s.push_str(&format!(" (rectangle (start {} {}) (end {} {})\n (stroke (width 0.254) (type default))\n (fill (type background))\n )\n",- body.left, body.top, body.right, body.bottom- ));- }-- for pin in &sym.pins {- let kicad_angle = match pin.angle {- 0 => 0,- 90 => 90,- 180 => 180,- 270 => 270,- _ => 0,- };- 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('"', "\\\""),- pin.number.replace('"', "\\\"")- ));- }-- s.push_str(" )\n");- s.push_str(" )\n)\n");- s-}--// ── EAGLE .lbr generator ──--pub(crate) fn esc_xml(s: &str) -> String {- s.replace('&', "&")- .replace('<', "<")- .replace('>', ">")- .replace('"', """)- .replace('\'', "'")-}--fn pin_direction(kicad_type: &str) -> &'static str {- match kicad_type {- "power_in" => "pwr",- "power_out" => "out",- "input" => "in",- "output" => "out",- "bidirectional" => "io",- "passive" => "pas",- "unconnected" => "nc",- "tri_state" => "hiz",- "open_collector" | "open_emitter" => "oc",- _ => "io",- }-}--fn pin_rotation(angle: u32) -> &'static str {- match angle {- 180 => " rot=\"R180\"",- 90 => " rot=\"R90\"",- 270 => " rot=\"R270\"",- _ => "",- }-}--fn pin_length_keyword(len: f64) -> &'static str {- if len <= 0.0 { "point" }- else if len <= 2.54 { "short" }- else if len <= 5.08 { "middle" }- else { "long" }-}--/// EAGLE names cannot carry spaces or most punctuation; KiCad pin names can-/// ("GND (TAB)" on a real TI part). Fusion refuses the ENTIRE library over one-/// such name, so every emitted pin name passes through this. Illegal runs-/// collapse to one underscore, trimmed at the ends.-fn eagle_safe_name(name: &str) -> String {- let mut out = String::with_capacity(name.len());- let mut last_us = false;- for ch in name.chars() {- let ok = ch.is_ascii_alphanumeric() || matches!(ch, '_' | '+' | '-' | '.' | '/' | '!' | '@' | '$' | '#');- if ok { out.push(ch); last_us = false; }- else if !last_us { out.push('_'); last_us = true; }- }- let trimmed = out.trim_matches('_').to_string();- if trimmed.is_empty() { "P".to_string() } else { trimmed }-}--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() {- eagle_safe_name(n)- } 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 seen: std::collections::HashMap<String, u32> = std::collections::HashMap::new();- eff.iter().map(|name| {- if counts[name] == 1 {- name.clone()- } else {- let c = seen.entry(name.clone()).or_insert(0);- *c += 1;- if *c == 1 { name.clone() } else { format!("{}@{}", name, c) }- }- }).collect()-}--/// The first `(footprint "NAME" …)` name in a `.kicad_mod`, if any.-/// EAGLE requires unique pad names within a package, and Fusion's importer-/// refuses the ENTIRE library over a repeat. KiCad legitimately reuses one pad-/// name across a same-net array (thermal vias, ground paddles, shield tabs), so-/// those come in as correct source data. EAGLE's own convention for one logical-/// pin on several physical pads is NAME, NAME@2, NAME@3, ... with the device's-/// connect naming every pad in the group — emit exactly that, keeping the-/// electrical meaning instead of losing it. Returns the per-pad output names-/// (document order) plus original-name → group map for building the connects.-fn dedup_pad_names(names: &[String]) -> (Vec<String>, std::collections::HashMap<String, Vec<String>>) {- let mut counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();- let mut out = Vec::with_capacity(names.len());- let mut groups: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();- for n in names {- let c = counts.entry(n.as_str()).or_insert(0);- *c += 1;- let out_name = if *c == 1 { n.clone() } else { format!("{}@{}", n, c) };- groups.entry(n.clone()).or_default().push(out_name.clone());- out.push(out_name);- }- (out, groups)-}--/// EE-convention symbol re-layout (adom/adom-lbr#5). A .kicad_sym that mirrors-/// the physical package puts pins on all four sides, which forces the top and-/// bottom names to render rotated and scatters logically-related pins to-/// physical corners — a schematic symbol is not a package drawing. Re-lay into-/// two columns: inputs and passives on the LEFT, outputs and bidirectional on-/// the RIGHT, power grouped at the bottom-left, NC at the bottom-right, buses-/// (GPIO0..47, DB0..15) contiguous in numeric order, a blank row between-/// groups, and a body wide enough that opposing names never meet. Every pin-/// name renders horizontal. Returns whether the re-layout was applied.-pub(crate) fn relayout_two_columns(sym: &mut ParsedSymbol, force: bool) -> bool {- // KiCad pin angles: 0 = points right (pin on the LEFT edge), 180 = left- // (RIGHT edge), 90 = up (BOTTOM edge), 270 = down (TOP edge). Top/bottom- // pins are the package-mirror tell.- let four_sided = sym.pins.iter().any(|p| p.angle == 90 || p.angle == 270);- if sym.pins.is_empty() || !(force || four_sided) {- return false;- }-- // side: 0 = left, 1 = right; rank orders the sections top→bottom in a column- fn placement(p: &KicadPin) -> (usize, u8) {- let n = p.name.to_uppercase();- let power = p.pin_type.starts_with("power")- || ["VDD", "VCC", "VSS", "GND", "VBAT", "VBUS", "VSYS", "VREG"].iter().any(|k| n.contains(k));- if power {- return (0, 9);- }- match p.pin_type.as_str() {- "input" => (0, 0),- "output" => (1, 0),- "bidirectional" | "tri_state" | "open_collector" | "open_emitter" => (1, 1),- "no_connect" | "free" => (1, 9),- _ => (0, 1), // passive / unspecified / analog- }- }-- // Natural sort key so DB2 sorts before DB10 and a bus stays contiguous.- fn natural(name: &str) -> (String, u64, String) {- let base = name.trim_end_matches(|c: char| c.is_ascii_digit());- let num: u64 = name[base.len()..].parse().unwrap_or(0);- (base.to_uppercase(), num, name.to_string())- }-- let mut order: Vec<usize> = (0..sym.pins.len()).collect();- order.sort_by(|&a, &b| {- let (pa, pb) = (&sym.pins[a], &sym.pins[b]);- (placement(pa), natural(&pa.name)).cmp(&(placement(pb), natural(&pb.name)))- });-- // Bus-prefix population, to know which prefix runs are real buses.- let mut prefix_count: std::collections::HashMap<(usize, u8, String), u32> = std::collections::HashMap::new();- for p in &sym.pins {- let (side, rank) = placement(p);- *prefix_count.entry((side, rank, natural(&p.name).0)).or_insert(0) += 1;- }-- // Build the two columns; None = blank spacer row between logical groups.- let mut cols: [Vec<Option<usize>>; 2] = [Vec::new(), Vec::new()];- let mut last: [Option<(u8, String)>; 2] = [None, None];- for &i in &order {- let p = &sym.pins[i];- let (side, rank) = placement(p);- let base = natural(&p.name).0;- if let Some((prev_rank, prev_base)) = &last[side] {- let bus_boundary = *prev_base != base- && (prefix_count[&(side, rank, base.clone())] >= 4- || prefix_count[&(side, *prev_rank, prev_base.clone())] >= 4);- if *prev_rank != rank || bus_boundary {- cols[side].push(None);- }- }- last[side] = Some((rank, base));- cols[side].push(Some(i));- }-- // Body wide enough that the longest left + right names never approach each- // other (~1.6 mm per char at EAGLE's default pin-name size, plus a gap).- let widest = |col: &Vec<Option<usize>>| {- col.iter().flatten().map(|&i| sym.pins[i].name.chars().count()).max().unwrap_or(0)- };- if std::env::var("ADOM_LBR_DEBUG_LAYOUT").is_ok() {- eprintln!("DEBUG layout: widest L={} R={}", widest(&cols[0]), widest(&cols[1]));- }- let mut w = ((widest(&cols[0]) + widest(&cols[1])) as f64 * 1.6 + 5.08).max(15.24);- w = (w / 2.54).ceil() * 2.54;-- let rows = cols[0].len().max(cols[1].len());- let y_top = (((rows.saturating_sub(1)) as f64) * 2.54 / 2.0 / 2.54).ceil() * 2.54;- for (side, col) in cols.iter().enumerate() {- for (row, slot) in col.iter().enumerate() {- if let Some(i) = slot {- let p = &mut sym.pins[*i];- p.y = y_top - row as f64 * 2.54;- p.length = 5.08; // EAGLE "middle": numbers outside, names inside- if side == 0 {- p.x = -(w / 2.0) - 5.08;- p.angle = 0;- } else {- p.x = w / 2.0 + 5.08;- p.angle = 180;- }- }- }- }-- sym.body = Some(KicadBody {- left: -(w / 2.0),- right: w / 2.0,- top: y_top + 2.54,- bottom: y_top - ((rows.saturating_sub(1)) as f64) * 2.54 - 2.54,- });- // Source graphics and group labels were positioned for the old layout.- sym.graphics.clear();- sym.group_labels.clear();- true-}--fn first_footprint_name(km: &str) -> Option<String> {- let re = regex_lite::Regex::new(r#"\(footprint\s+"([^"]+)""#).unwrap();- re.captures(km).and_then(|c| c.get(1)).map(|m| m.as_str().to_string())-}--/// Synthesize a placeable symbol from a `.kicad_mod`'s pads when there is no-/// `.kicad_sym`. One symbol pin per unique pad number, balanced left/right, so-/// the resulting EAGLE deviceset has the correct pin/pad names and connects.-fn synth_symbol_from_mod(km: &str, name: &str) -> ParsedSymbol {- // Pad numbers in document order, de-duplicated (thru-hole pairs share a no.).- // Pad ids may be quoted ("A1") or bare (1); skip mechanical pads ("").- let pad_re = regex_lite::Regex::new(r#"\(pad\s+(?:"([^"]*)"|(\S+))"#).unwrap();- let mut nums: Vec<String> = Vec::new();- for c in pad_re.captures_iter(km) {- let n = c.get(1).or_else(|| c.get(2)).map(|m| m.as_str().to_string()).unwrap_or_default();- if !n.is_empty() && !nums.contains(&n) {- nums.push(n);- }- }- let count = nums.len().max(1);- let half = (count + 1) / 2;- let body_h = (half as f64) * 2.54 + 2.54;- let pins: Vec<KicadPin> = nums.iter().enumerate().map(|(i, num)| {- let (x, y, angle) = if i < half {- (-12.7, (half as f64 / 2.0 - i as f64) * 2.54, 0)- } else {- (12.7, (half as f64 / 2.0 - (i - half) as f64) * 2.54, 180)- };- KicadPin {- name: num.clone(),- number: num.clone(),- pin_type: "passive".into(),- x, y, angle, length: 2.54,- }- }).collect();- ParsedSymbol {- name: name.to_string(),- reference: "U".into(),- value: name.to_string(),- footprint: name.to_string(),- description: format!("{name} (symbol synthesized from footprint pads)"),- 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),- }-}--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>) -> String {- let mut xml = String::with_capacity(8192);-- xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");- xml.push_str("<!DOCTYPE eagle SYSTEM \"eagle.dtd\">\n");- xml.push_str("<eagle version=\"9.6.2\">\n<drawing>\n");- xml.push_str("<settings><setting alwaysvectorfont=\"no\"/></settings>\n");- xml.push_str("<grid distance=\"0.1\" unitdist=\"inch\" unit=\"inch\" style=\"lines\" multiple=\"1\" display=\"no\" altdistance=\"0.01\" altunitdist=\"inch\" altunit=\"inch\"/>\n");-- // Layers- xml.push_str(EAGLE_LAYERS);-- xml.push_str("<library>\n");- xml.push_str(&format!("<description>{}</description>\n", esc_xml(&sym.description)));-- // 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();- // Original pad/pin-number name → the @-suffixed pad group it became, for the connects.- let pad_groups: std::collections::HashMap<String, Vec<String>>;- if !mod_pads.is_empty() {- let km = fp_content.unwrap();- xml.push_str(&format!(" <description>{}</description>\n", esc_xml(&sym.name)));- let pad_names: Vec<String> = mod_pads.iter().map(|p| p.name.clone()).collect();- let (out_names, groups) = dedup_pad_names(&pad_names);- pad_groups = groups;- for (i, p) in mod_pads.iter().enumerate() {- if p.smd {- xml.push_str(&format!(- " <smd name=\"{}\" x=\"{:.4}\" y=\"{:.4}\" dx=\"{:.4}\" dy=\"{:.4}\" layer=\"1\"{}/>\n",- esc_xml(&out_names[i]), 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(&out_names[i]), 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- let pad_size = 0.5;- let pitch = 0.5;- let pin_count = sym.pins.len();- let half = (pin_count + 1) / 2;-- let placeholder_names: Vec<String> = sym.pins.iter().map(|p| p.number.clone()).collect();- let (placeholder_out, groups) = dedup_pad_names(&placeholder_names);- pad_groups = groups;- for i in 0..pin_count {- let (px, py) = if i < half {- // Left column- (-(pin_count as f64 * pitch / 4.0), (half as f64 / 2.0 - i as f64) * pitch)- } else {- // Right column- (pin_count as f64 * pitch / 4.0, (half as f64 / 2.0 - (i - half) as f64) * pitch)- };- xml.push_str(&format!(- " <smd name=\"{}\" x=\"{:.3}\" y=\"{:.3}\" dx=\"{:.2}\" dy=\"{:.2}\" layer=\"1\"/>\n",- esc_xml(&placeholder_out[i]), px, py, pad_size, pad_size- ));- }-- // Package outline- let body_w = pin_count as f64 * pitch / 2.0 + 1.0;- let body_h = half as f64 * pitch + 1.0;- xml.push_str(&format!(- " <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",- -body_w / 2.0, body_h / 2.0, body_w / 2.0, body_h / 2.0- ));- xml.push_str(&format!(- " <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",- body_w / 2.0, body_h / 2.0, body_w / 2.0, -body_h / 2.0- ));- xml.push_str(&format!(- " <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",- body_w / 2.0, -body_h / 2.0, -body_w / 2.0, -body_h / 2.0- ));- xml.push_str(&format!(- " <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",- -body_w / 2.0, -body_h / 2.0, -body_w / 2.0, body_h / 2.0- ));-- 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");-- // 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\">>NAME</text>\n", name_y));- xml.push_str(&format!(" <text x=\"0\" y=\"{}\" size=\"1.27\" layer=\"96\" align=\"top-center\">>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");- xml.push_str("</symbols>\n");-- // Deviceset with COMPLETE device (package + connects)- let ref_prefix = if sym.reference.is_empty() { "U" } else { &sym.reference };- xml.push_str("<devicesets>\n");- 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(" </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() {- // 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);- // A same-net pad array (KiCad repeats the name; we emitted NAME, NAME@2,- // ...) connects this one pin to EVERY pad in the group, space-separated —- // standard EAGLE multi-pad connect form.- let pad_ref = match pad_groups.get(pad) {- Some(group) if group.len() > 1 => group.join(" "),- Some(group) => group[0].clone(),- None => pad.clone(),- };- xml.push_str(&format!(- " <connect gate=\"G$1\" pin=\"{}\" pad=\"{}\"/>\n",- esc_xml(&deduped_names[i]), esc_xml(&pad_ref)- ));- }-- xml.push_str(" </connects>\n");- xml.push_str(" <technologies><technology name=\"\"/></technologies>\n");- xml.push_str(" </device>\n");- xml.push_str(" </devices>\n");- xml.push_str("</deviceset>\n");- xml.push_str("</devicesets>\n");-- xml.push_str("</library>\n</drawing>\n</eagle>");- xml-}--// ── Linter ──--#[derive(Debug, Serialize)]-pub(crate) struct LintResult {- pub passed: bool,- pub errors: Vec<LintError>,- pub warnings: Vec<LintError>,-}--#[derive(Debug, Serialize)]-pub(crate) struct LintError {- pub code: String,- pub message: String,-}--pub(crate) fn lint_lbr(content: &str) -> LintResult {- let mut errors = Vec::new();- let mut warnings = Vec::new();-- if !content.contains("<packages>") || !content.contains("</packages>") {- errors.push(LintError { code: "missing-packages".into(), message: "No <packages> section found".into() });- }- if !content.contains("<symbols>") || !content.contains("</symbols>") {- errors.push(LintError { code: "missing-symbols".into(), message: "No <symbols> section found".into() });- }- if !content.contains("<devicesets>") || !content.contains("</devicesets>") {- errors.push(LintError { code: "missing-devicesets".into(), message: "No <devicesets> section found".into() });- }-- // 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() });- }-- // Check for empty connects- if content.contains("<connects/>") || content.contains("<connects></connects>") {- errors.push(LintError { code: "empty-connects".into(), message: "Device has no pin-to-pad connect mappings — Fusion cannot use this component".into() });- }-- // Check device has a package attribute- if content.contains("<device name=\"\">") && !content.contains("package=") {- 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;- }- if pname.chars().any(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '+' | '-' | '.' | '/' | '!' | '@' | '$' | '#'))) {- errors.push(LintError {- code: "illegal-pin-name".into(),- message: format!("Symbol '{}' pin '{}' contains characters an EAGLE name cannot carry (space or punctuation outside _+-./!@$#) — Fusion refuses the whole library over it", sname, pname),- });- }- *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);- }-- // 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),- });- }- }- }- }- }-- // 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();-- // Duplicate pad names within a package (adom/adom-lbr#10). KiCad allows a- // same-net pad array to share one name (thermal vias, ground paddles,- // shield tabs); EAGLE does not, and Fusion's importer refuses the WHOLE- // library over it. generate resolves these to NAME, NAME@2, ... — so a- // file that still carries repeats either predates that or came from- // somewhere else, and it will not import.- let mut pad_counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();- for p in &pads { *pad_counts.entry(p.as_str()).or_insert(0) += 1; }- let mut dup_pads: Vec<(&str, u32)> = pad_counts.iter().filter(|(_, c)| **c > 1).map(|(n, c)| (*n, *c)).collect();- dup_pads.sort();- for (name, count) in dup_pads {- errors.push(LintError {- code: "duplicate-pad-name".into(),- message: format!(- "Pad name '{}' appears {} times in package '{}' — Fusion's importer refuses the \-entire library over duplicate pad names. Use the EAGLE @-suffix convention ({name}, {name}@2, ...) \-with a multi-pad <connect> (adom-lbr generate now emits this).",- name, count, &pc[1]- ),- });- }-- 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 = c[1].to_string();- // pad= may be a space-separated group ("17 17@2 17@3"): the standard- // EAGLE multi-pad connect for one pin on a same-net pad array. Each- // named pad must resolve.- for pad in c[2].split_whitespace() {- let pad = pad.to_string();- if pad_set.contains(&pad) { used.insert(pad); } else { dangling.push((pin.clone(), 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() });- }-- // Fusion-specific checks- if !content.contains("layer=\"94\"") {- warnings.push(LintError { code: "no-symbol-layer".into(), message: "No elements on layer 94 (Symbols) — the symbol body may be invisible".into() });- }-- LintResult {- passed: errors.is_empty(),- errors,- warnings,- }-}--// ── Layers constant ──--const EAGLE_LAYERS: &str = r#"<layers>-<layer number="1" name="Top" color="4" fill="1" visible="yes" active="yes"/>-<layer number="16" name="Bottom" color="1" fill="1" visible="yes" active="yes"/>-<layer number="17" name="Pads" color="2" fill="1" visible="yes" active="yes"/>-<layer number="18" name="Vias" color="2" fill="1" visible="yes" active="yes"/>-<layer number="19" name="Unrouted" color="6" fill="1" visible="yes" active="yes"/>-<layer number="20" name="Dimension" color="15" fill="1" visible="yes" active="yes"/>-<layer number="21" name="tPlace" color="7" fill="1" visible="yes" active="yes"/>-<layer number="22" name="bPlace" color="7" fill="1" visible="yes" active="yes"/>-<layer number="23" name="tOrigins" color="15" fill="1" visible="yes" active="yes"/>-<layer number="24" name="bOrigins" color="15" fill="1" visible="yes" active="yes"/>-<layer number="25" name="tNames" color="7" fill="1" visible="yes" active="yes"/>-<layer number="26" name="bNames" color="7" fill="1" visible="yes" active="yes"/>-<layer number="27" name="tValues" color="7" fill="1" visible="yes" active="yes"/>-<layer number="28" name="bValues" color="7" fill="1" visible="yes" active="yes"/>-<layer number="29" name="tStop" color="7" fill="3" visible="no" active="yes"/>-<layer number="30" name="bStop" color="7" fill="6" visible="no" active="yes"/>-<layer number="31" name="tCream" color="7" fill="4" visible="no" active="yes"/>-<layer number="32" name="bCream" color="7" fill="5" visible="no" active="yes"/>-<layer number="33" name="tFinish" color="6" fill="3" visible="no" active="yes"/>-<layer number="34" name="bFinish" color="6" fill="6" visible="no" active="yes"/>-<layer number="35" name="tGlue" color="7" fill="4" visible="no" active="yes"/>-<layer number="36" name="bGlue" color="7" fill="5" visible="no" active="yes"/>-<layer number="37" name="tTest" color="7" fill="1" visible="no" active="yes"/>-<layer number="38" name="bTest" color="7" fill="1" visible="no" active="yes"/>-<layer number="39" name="tKeepout" color="4" fill="11" visible="no" active="yes"/>-<layer number="40" name="bKeepout" color="1" fill="11" visible="no" active="yes"/>-<layer number="41" name="tRestrict" color="4" fill="10" visible="no" active="yes"/>-<layer number="42" name="bRestrict" color="1" fill="10" visible="no" active="yes"/>-<layer number="43" name="vRestrict" color="2" fill="10" visible="no" active="yes"/>-<layer number="44" name="Drills" color="7" fill="1" visible="no" active="yes"/>-<layer number="45" name="Holes" color="7" fill="1" visible="no" active="yes"/>-<layer number="46" name="Milling" color="3" fill="1" visible="no" active="yes"/>-<layer number="47" name="Measures" color="7" fill="1" visible="no" active="yes"/>-<layer number="48" name="Document" color="7" fill="1" visible="yes" active="yes"/>-<layer number="49" name="Reference" color="7" fill="1" visible="yes" active="yes"/>-<layer number="91" name="Nets" color="2" fill="1" visible="yes" active="yes"/>-<layer number="92" name="Busses" color="1" fill="1" visible="yes" active="yes"/>-<layer number="93" name="Pins" color="2" fill="1" visible="no" active="yes"/>-<layer number="94" name="Symbols" color="4" fill="1" visible="yes" active="yes"/>-<layer number="95" name="Names" color="7" fill="1" visible="yes" active="yes"/>-<layer number="96" name="Values" color="7" fill="1" visible="yes" active="yes"/>-<layer number="97" name="Info" color="7" fill="1" visible="yes" active="yes"/>-<layer number="98" name="Guide" color="6" fill="1" visible="yes" active="yes"/>-</layers>-"#;--// ── Main ──--/// Read a text file or exit with a clear error.-fn read_or_exit(path: &std::path::Path) -> String {- fs::read_to_string(path).unwrap_or_else(|e| {- eprintln!("ERROR: read {}: {e}", path.display());- process::exit(1);- })-}--/// Load an adom-lbr JSON into the canonical part.-pub(crate) fn load_part(path: &std::path::Path) -> altium_codec::AdomLbrPart {- let json = read_or_exit(path);- serde_json::from_str(&json).unwrap_or_else(|e| {- eprintln!("ERROR: parse adom-lbr JSON {}: {e}", path.display());- process::exit(1);- })-}--/// Seed the symbol's Altium property fields from the part identity (Value drawn;-/// Manufacturer/MPN/Package hidden), skipping empties + names already present.-fn seed_parameters(symbol: &mut altium_codec::Symbol, part: &altium_codec::AdomLbrPart) {- use altium_codec::Parameter;- let mut push = |name: &str, value: &str, visible: bool| {- if value.is_empty()- || symbol.parameters.iter().any(|p| p.name.eq_ignore_ascii_case(name))- {- return;- }- symbol.parameters.push(Parameter { name: name.into(), value: value.into(), visible });- };- push("Value", &part.value, true);- push("Manufacturer", &part.manufacturer, false);- push("Manufacturer Part Number", &part.mpn, false);- push("Package", &part.package, false);-}--fn main() {- let cli = Cli::parse();-- match cli.command {- Commands::Generate { sym, fp, output, name, symbol_art, symbol_art_scale, pin_map, symbol_layout } => {- 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);- process::exit(1);- })- });-- let mut parsed = match (&sym, &fp_content) {- (Some(sym_path), _) => {- let sym_content = fs::read_to_string(sym_path).unwrap_or_else(|e| {- eprintln!("ERROR: Cannot read {}: {}", sym_path.display(), e);- eprintln!("Hint: Check the .kicad_sym path exists.");- process::exit(1);- });- parse_kicad_sym(&sym_content, name.as_deref()).unwrap_or_else(|e| {- eprintln!("ERROR: {}", e);- process::exit(1);- })- }- (None, Some(fp)) => {- // Footprint-only: synthesize a placeable symbol from the pads.- let syn_name = name.clone()- .or_else(|| first_footprint_name(fp))- .unwrap_or_else(|| "FOOTPRINT".to_string());- synth_symbol_from_mod(fp, &syn_name)- }- (None, None) => {- eprintln!("ERROR: Generate needs at least --sym or --fp.");- eprintln!("Hint: `adom-lbr generate --fp part.kicad_mod` synthesizes a symbol from the pads.");- process::exit(1);- }- };-- 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,- };- // 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());- }-- match symbol_layout.as_str() {- "keep" => {}- "auto" | "two-column" => {- if art.is_some() {- // Symbol art is placed in the source symbol's own- // coordinate space; re-laying the pins would orphan it.- if symbol_layout == "two-column" {- eprintln!("WARN: --symbol-layout two-column ignored: --symbol-art is placed in the source symbol's coordinates");- }- } else if relayout_two_columns(&mut parsed, symbol_layout == "two-column") {- println!("HINT: symbol re-laid into two columns (source mirrored the package: pins on top/bottom). --symbol-layout keep preserves the original.");- }- }- other => {- eprintln!("ERROR: --symbol-layout must be auto, two-column, or keep (got '{}')", other);- process::exit(1);- }- }-- 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);- for err in &lint.errors {- eprintln!("LINT ERROR: [{}] {}", err.code, err.message);- }- for warn in &lint.warnings {- eprintln!("LINT WARN: [{}] {}", warn.code, warn.message);- }-- let out_path = output.unwrap_or_else(|| {- out_default.with_extension("lbr")- });-- fs::write(&out_path, &lbr).unwrap_or_else(|e| {- eprintln!("ERROR: Cannot write {}: {}", out_path.display(), e);- process::exit(1);- });-- println!("OK: Generated {} ({} pins, {} bytes)", out_path.display(), parsed.pins.len(), lbr.len());- println!("HINT: Lint {} with {} error(s), {} warning(s)", if lint.passed { "PASSED" } else { "FAILED" }, lint.errors.len(), lint.warnings.len());-- if !lint.passed {- process::exit(2);- }- }-- Commands::Lint { path } => {- let content = fs::read_to_string(&path).unwrap_or_else(|e| {- eprintln!("ERROR: Cannot read {}: {}", path.display(), e);- eprintln!("Hint: Check the path exists. Use `ls` to verify.");- process::exit(1);- });-- let result = lint_lbr(&content);- if result.passed {- println!("OK: Lint passed with 0 error(s), {} warning(s)", result.warnings.len());- } else {- eprintln!("ERROR: Lint failed with {} error(s)", result.errors.len());- for err in &result.errors {- eprintln!(" [{}] {}", err.code, err.message);- }- eprintln!("Hint: Fix the errors above, then re-run `adom-lbr lint {}`", path.display());- }- for warn in &result.warnings {- eprintln!("WARN: [{}] {}", warn.code, warn.message);- }- // Also output JSON for programmatic consumers- let json = serde_json::to_string_pretty(&result).unwrap();- println!("{}", json);-- if !result.passed {- process::exit(2);- }- }-- Commands::Assemble { out, name, inputs } => {- assemble::run(&out, name.as_deref(), &inputs);- }- Commands::Validate { path } => {- let content = fs::read_to_string(&path).unwrap_or_else(|e| {- eprintln!("ERROR: Cannot read {}: {}", path.display(), e);- process::exit(1);- });-- let result = lint_lbr(&content);- if result.passed {- println!("OK: {} is a valid EAGLE .lbr with complete deviceset", path.display());- } else {- eprintln!("FAIL: {} has {} error(s)", path.display(), result.errors.len());- for err in &result.errors {- eprintln!(" [{}] {}", err.code, err.message);- }- process::exit(2);- }- }-- Commands::Import { path, output } => {- let content = fs::read_to_string(&path).unwrap_or_else(|e| {- eprintln!("ERROR: Cannot read {}: {}", path.display(), e);- process::exit(1);- });-- let parsed = parse_eagle_lbr(&content).unwrap_or_else(|e| {- eprintln!("ERROR: {}", e);- process::exit(1);- });-- let kicad_sym = generate_kicad_sym(&parsed);-- let out_path = output.unwrap_or_else(|| {- path.with_extension("kicad_sym")- });-- fs::write(&out_path, &kicad_sym).unwrap_or_else(|e| {- eprintln!("ERROR: Cannot write {}: {}", out_path.display(), e);- process::exit(1);- });-- println!("OK: Imported {} → {} ({} pins)", path.display(), out_path.display(), parsed.pins.len());- println!("HINT: Run 'adom-lbr check {}' to validate via service-kicad", out_path.display());- }-- Commands::Check { path } => {- let content = fs::read_to_string(&path).unwrap_or_else(|e| {- eprintln!("ERROR: Cannot read {}: {}", path.display(), e);- process::exit(1);- });-- let mut errors: Vec<String> = Vec::new();-- if !content.contains("(kicad_symbol_lib") {- errors.push("Missing (kicad_symbol_lib) header — not a valid .kicad_sym file".into());- }- if !content.contains("(symbol \"") {- errors.push("No (symbol) definitions found".into());- }-- let pin_count = content.matches("(pin ").count();- if pin_count == 0 {- errors.push("No pins found — symbol has no electrical connections".into());- }-- if !content.contains("(property \"Reference\"") {- errors.push("Missing Reference property".into());- }- if !content.contains("(property \"Value\"") {- errors.push("Missing Value property".into());- }-- // Check for duplicate pin names within each symbol- let sym_re = regex_lite::Regex::new(r#"\(pin \w+ \w+ \(at [^)]+\) \(length [^)]+\) \(name "([^"]+)""#).unwrap();- let mut pin_names: std::collections::HashMap<String, usize> = std::collections::HashMap::new();- for cap in sym_re.captures_iter(&content) {- *pin_names.entry(cap[1].to_string()).or_insert(0) += 1;- }- let dups: Vec<_> = pin_names.iter().filter(|(_, &c)| c > 1).collect();- if !dups.is_empty() && !dups.iter().all(|(n, _)| n.contains('@') || n.as_str() == "~") {- let dup_list: Vec<_> = dups.iter().filter(|(n, _)| !n.contains('@') && n.as_str() != "~").map(|(n, c)| format!("{} (x{})", n, c)).collect();- if !dup_list.is_empty() {- // Duplicate pin names are valid in KiCad for power pins (DVDD, IOVDD, GND).- // Warn but don't error — EAGLE import will need @N suffixes.- eprintln!("WARN: Duplicate pin names (will need @N suffix for EAGLE): {}", dup_list.join(", "));- }- }-- if errors.is_empty() {- println!("OK: {} validated — {} pins, {} unique pin names", path.display(), pin_count, pin_names.len());- } else {- eprintln!("ERROR: {} failed validation with {} error(s)", path.display(), errors.len());- for e in &errors {- eprintln!(" {}", e);- }- eprintln!("Hint: Fix the errors above, then re-run `adom-lbr check {}`", path.display());- process::exit(2);- }- }-- Commands::ExportViewers { slug, dir, out, no_pin_table } => {- let out_dir = out.unwrap_or_else(|| PathBuf::from("viewers"));- if let Err(e) = manage::export_viewers(&slug, &dir, &out_dir, no_pin_table) {- eprintln!("ERROR: {e}"); std::process::exit(1);- }- }- Commands::Embed { dir, out } => {- if let Err(e) = cmd_embed(&dir, out) {- eprintln!("ERROR: {e:#}");- process::exit(1);- }- }- Commands::View { slug, dir, out, layout, panes, description, rollup, config } => {- let (layout, panes_vec, description, rollup) =- resolve_view_cfg(layout, panes, description, rollup, config.as_deref());- if let Err(e) = manage::export_composed(&slug, &dir, out, &layout, &panes_vec, description, rollup) {- eprintln!("ERROR: {e}");- process::exit(1);- }- }- Commands::Manage { port } => {- if let Err(e) = manage::run(port) {- eprintln!("ERROR: {e}");- process::exit(1);- }- }- Commands::Dashboard { cmd } => match cmd {- DashboardCmd::Serve { port, print_url, foreground } => {- // A foreign service on the default port moves us over rather- // than killing the launch (John's box, 2026-08-25).- let port = match dock::resolve_port(port) {- Ok(p) => p,- Err(e) => { eprintln!("ERROR: {e}"); process::exit(2); }- };- match dock::serve_entry(port, print_url, foreground) {- Ok(true) => {}- Ok(false) => {- if let Err(e) = manage::run(port) {- // Lost a bind race? If it is us after all, this launch still succeeded.- if dock::running_version(port).is_some() {- if print_url { println!("{}", dock::direct_url(port)); }- return;- }- eprintln!("ERROR: adom-lbr dashboard cannot serve on port {port}: {e}. Something else owns it; pick another with --port.");- process::exit(2);- }- }- Err(e) => {- eprintln!("ERROR: {e}");- process::exit(2);- }- }- }- DashboardCmd::Status { port } => {- let out = process::Command::new("curl").args(["-s", "--max-time", "3", &format!("http://127.0.0.1:{port}/api/status")]).output();- match out {- Ok(o) if o.status.success() && !o.stdout.is_empty() => println!("{}", String::from_utf8_lossy(&o.stdout)),- _ => { eprintln!("ERROR: no adom-lbr dashboard answering on port {port}"); eprintln!("Hint: adom-lbr dashboard serve --print-url"); process::exit(2); }- }- }- DashboardCmd::Say => {- for p in dock::say_phrases() { println!("{p}"); }- }- },- Commands::Serve { port, file } => {- if let Some(f) = file {- match fs::read_to_string(&f) {- Ok(content) => server::set_state(server::analyze_lbr(&content)),- Err(e) => {- eprintln!("ERROR: Cannot read {}: {}", f.display(), e);- process::exit(1);- }- }- }- if let Err(e) = server::run(port) {- eprintln!("ERROR: {e:#}");- process::exit(1);- }- }-- Commands::ImportKicad { sym, fp, mpn, out, pin_map } => {- let sym_txt = read_or_exit(&sym);- let fp_txt = read_or_exit(&fp);- // Same pin-map convention as generate: explicit --pin-map wins, else- // a pinmap.json sidecar beside the symbol just works.- let pin_map_path = pin_map.or_else(|| {- sym.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) => serde_json::from_str(&read_or_exit(path)).unwrap_or_else(|e| {- eprintln!("ERROR: {} is not a JSON object of \"pin number\": \"pad name\": {}", path.display(), e);- process::exit(1);- }),- None => Default::default(),- };- 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 (part, dropped) = altium_codec::import_kicad_with_pin_map(&sym_txt, &fp_txt, &mpn, &pin_map)- .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });- if !dropped.is_empty() {- // A pin that matches no pad is a broken netlist in EVERY export- // target — say so loudly instead of letting it vanish (adom-lbr#9).- let referenced: std::collections::HashSet<&str> =- part.connect.iter().map(|c| c.pad.as_str()).collect();- let mut unref: Vec<&str> = part.footprint.pads.iter()- .map(|p| p.number.as_str())- .filter(|n| !n.is_empty() && !referenced.contains(n))- .collect();- unref.sort();- unref.dedup();- eprintln!("WARN: {} symbol pin(s) match no footprint pad and were left out of connect[]: {}",- dropped.len(), dropped.join(", "));- if !unref.is_empty() {- eprintln!(" unreferenced pads in footprint: {} — if one of these is the intended target, supply --pin-map (or a pinmap.json sidecar)", unref.join(", "));- }- }- let outp = out.unwrap_or_else(|| PathBuf::from(format!("{mpn}.adom-lbr.json")));- fs::write(&outp, serde_json::to_string_pretty(&part).unwrap()).ok();- 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());- }-- Commands::ExportAltium { part, out_dir } => {- let p = load_part(&part);- let mut symbol = p.symbol.clone();- seed_parameters(&mut symbol, &p);- let sym_bytes = altium_codec::encode_schlib(&[symbol])- .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });- let fp_bytes = altium_codec::encode_pcblib(&p.footprint)- .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();- println!("OK: wrote {} ({} pins) + {} ({} pads)",- sp.display(), p.symbol.pins.len(), pp.display(), p.footprint.pads.len());- }-- Commands::ExportIntlib { part, out } => {- let p = load_part(&part);- let mut symbol = p.symbol.clone();- seed_parameters(&mut symbol, &p);- let descr = if p.value.is_empty() { p.mpn.clone() } else { format!("{} {}", p.value, p.package) };- 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();- println!("OK: wrote {} ({} bytes) — symbol + footprint bundled", outp.display(), bytes.len());- }-- Commands::ExportKicad { part, out_dir } => {- 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 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();- println!("OK: wrote {} ({} pins) + {} ({} pads, {} graphics)",- sp.display(), p.symbol.pins.len(), fp.display(),- p.footprint.pads.len(), p.footprint.graphics.len());- }-- Commands::AddToSchlib { existing, part, out } => {- let existing_bytes = fs::read(&existing)- .unwrap_or_else(|e| { eprintln!("ERROR: read {}: {e}", existing.display()); process::exit(1); });- let json = read_or_exit(&part);- let syms: Vec<altium_codec::Symbol> =- if let Ok(p) = serde_json::from_str::<altium_codec::AdomLbrPart>(&json) {- vec![p.symbol]- } else if let Ok(v) = serde_json::from_str::<Vec<altium_codec::Symbol>>(&json) {- v- } else {- vec![serde_json::from_str::<altium_codec::Symbol>(&json)- .unwrap_or_else(|e| { eprintln!("ERROR: parse Symbol/AdomLbrPart: {e}"); process::exit(1); })]- };- 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();- println!("OK: wrote {} — added {} symbol(s) into the library", outp.display(), syms.len());- }-- Commands::ImportAltium { file, pcblib, schlib, mpn, out } => {- let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();- let read = |p: &std::path::Path| -> Vec<u8> {- fs::read(p).unwrap_or_else(|e| { eprintln!("ERROR: read {}: {e}", p.display()); process::exit(1); })- };- let part = if ext == "intlib" {- altium_codec::decode_intlib_part(&read(&file), &mpn)- } else if ext == "pcblib" {- let sch = schlib.as_ref().map(|p| read(p));- altium_codec::decode_altium(sch.as_deref(), Some(&read(&file)), &mpn)- } else {- // treat as .SchLib; auto-find the .PcbLib next to it if not given- let pcb_path = pcblib.or_else(|| {- let cand = file.with_extension("PcbLib");- cand.exists().then_some(cand)- });- let pcb = pcb_path.as_ref().map(|p| read(p));- altium_codec::decode_altium(Some(&read(&file)), pcb.as_deref(), &mpn)- }- .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();- println!("OK: wrote {} — {} pins, {} pads, {} footprint graphics",- outp.display(), part.symbol.pins.len(), part.footprint.pads.len(), part.footprint.graphics.len());- }-- 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());- }-- Commands::Health => {- println!("OK: adom-lbr v{}", VERSION.trim());- }-- Commands::Completions { shell } => {- use clap::CommandFactory;- clap_complete::generate(shell, &mut Cli::command(), "adom-lbr", &mut std::io::stdout());- }- }-}--// ── `embed`: Fusion-style 3-column interactive HTML package ──--pub(crate) fn b64(data: &[u8]) -> String {- const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";- let mut out = String::with_capacity(data.len().div_ceil(3) * 4);- for ch in data.chunks(3) {- let b = [ch[0], *ch.get(1).unwrap_or(&0), *ch.get(2).unwrap_or(&0)];- let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;- out.push(T[(n >> 18 & 63) as usize] as char);- out.push(T[(n >> 12 & 63) as usize] as char);- out.push(if ch.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });- out.push(if ch.len() > 2 { T[(n & 63) as usize] as char } else { '=' });- }- out-}--fn esc_html(s: &str) -> String {- s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)-}--/// (human label, hex color) for a ds2sf/kicad pin type token.-pub(crate) fn pin_type_style(t: &str) -> (&'static str, &'static str) {- match t.to_lowercase().replace('-', "_").as_str() {- "power_in" | "power_out" | "power" => ("Power", "#ef5350"),- "input" | "in" => ("Input", "#26c6da"),- "output" | "out" => ("Output", "#66bb6a"),- "bidirectional" | "bidi" | "io" => ("Bidir", "#ab47bc"),- "open_collector" | "open_emitter" => ("Open", "#ffa726"),- "tri_state" => ("Tri-state", "#ffa726"),- "passive" => ("Passive", "#90a4ae"),- "unspecified" | "" => ("—", "#90a4ae"),- "no_connect" | "nc" | "unconnected" => ("NC", "#5b6673"),- _ => ("Signal", "#80cbc4"),- }-}--/// Resolve the `View` layout config. A `--config page.json` with an `lbrView`-/// block fully determines layout/panes/paneOpts (overriding the flags); otherwise-/// the flags are used. Returns (layout, panes, pins.description, pins.rollup) where-/// the two Options are None when the caller should derive the layout default.-fn resolve_view_cfg(- layout: String,- panes: Option<String>,- description: Option<bool>,- rollup: Option<bool>,- config: Option<&std::path::Path>,-) -> (String, Vec<String>, Option<bool>, Option<bool>) {- if let Some(cfgpath) = config {- if let Ok(txt) = fs::read_to_string(cfgpath) {- if let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) {- if let Some(lv) = v.get("lbrView") {- let layout = lv.get("layout").and_then(|x| x.as_str()).unwrap_or(&layout).to_string();- let panes: Vec<String> = lv.get("panes").and_then(|x| x.as_array())- .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())- .unwrap_or_default();- let pins = lv.get("paneOpts").and_then(|x| x.get("pins"));- let desc = pins.and_then(|p| p.get("description")).and_then(|x| x.as_bool());- let roll = pins.and_then(|p| p.get("rollup")).and_then(|x| x.as_bool());- return (layout, panes, desc, roll);- }- }- }- eprintln!("WARN: --config {} has no usable `lbrView` block; using flags", cfgpath.display());- }- let panes_vec = panes- .map(|s| s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect())- .unwrap_or_default();- (layout, panes_vec, description, rollup)-}--fn read_first(dir: &std::path::Path, names: &[String]) -> Option<String> {- for n in names {- if let Ok(s) = fs::read_to_string(dir.join(n)) {- return Some(s);- }- }- None-}--/// Find the directory's model, transcode Draco away when present, and emit a-/// self-contained interactive 3D pane (inlined model-viewer + data-URI GLB +-/// the static iso render as the poster). Err(reason) falls back to the PNG.-fn build_3d_viewer(dir: &std::path::Path, mpn: &str) -> Result<String, String> {- // prefer <MPN>.glb (any case), else the first .glb in the directory- let mut glb_path: Option<PathBuf> = None;- if let Ok(rd) = fs::read_dir(dir) {- let mut all: Vec<PathBuf> = rd.flatten().map(|e| e.path())- .filter(|p| p.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("glb")).unwrap_or(false))- .collect();- all.sort();- glb_path = all.iter().find(|p| {- p.file_stem().and_then(|s| s.to_str()).map(|s| s.eq_ignore_ascii_case(mpn)).unwrap_or(false)- }).cloned().or_else(|| all.first().cloned());- }- let glb_path = glb_path.ok_or("no .glb in this directory")?;- let mut bytes = fs::read(&glb_path).map_err(|e| format!("read {}: {e}", glb_path.display()))?;- let orig_len = bytes.len();-- if glb_is_draco(&bytes) {- let tool = ensure_gltf_transform()?;- let out_tmp = std::env::temp_dir().join(format!("adom-lbr-{mpn}-quantized.glb"));- let st = process::Command::new(&tool)- .args(["quantize", glb_path.to_str().unwrap_or_default(), out_tmp.to_str().unwrap_or_default()])- .output()- .map_err(|e| format!("gltf-transform did not run: {e}"))?;- if !st.status.success() {- return Err(format!("gltf-transform quantize failed: {}", String::from_utf8_lossy(&st.stderr).lines().last().unwrap_or("")));- }- bytes = fs::read(&out_tmp).map_err(|e| format!("read transcode output: {e}"))?;- if glb_is_draco(&bytes) {- return Err("transcode output still requires the Draco decoder".into());- }- println!("HINT: 3D model transcoded Draco -> quantized for the sandbox: {} -> {} bytes", orig_len, bytes.len());- }-- let viewer_js = ensure_model_viewer()?;- // STEP-sourced GLBs are usually Z-up while glTF is Y-up, so a chip stands- // on its edge (barrett's PR #46 catch). Chips are flat: whichever bbox- // axis is thinnest is the height. Thinnest on Z => Z-up model => pitch it- // -90deg; thinnest on Y => already Y-up, leave it alone.- let orientation = match glb_flat_axis(&bytes) {- Some('z') => " orientation=\"0deg -90deg 0deg\"",- _ => "",- };- let poster = ["-3d-iso-named.png", "-3d-iso.png", "-3d-iso-lg.png"]- .iter().map(|s| dir.join(format!("{mpn}{s}"))).find(|p| p.exists())- .and_then(|p| fs::read(&p).ok())- .map(|b| format!(" poster=\"data:image/png;base64,{}\"", b64(&b)))- .unwrap_or_default();- Ok(format!(- "<script type=\"module\">{viewer_js}</script>\n\- <model-viewer src=\"data:model/gltf-binary;base64,{model}\"{poster}{orientation} camera-controls auto-rotate shadow-intensity=\"1\" style=\"position:absolute;inset:0;width:100%;height:100%;background:#161c27\"></model-viewer>",- model = b64(&bytes)- ))-}--/// Which axis is the model THINNEST along, from the union of the JSON chunk's-/// VEC3 float accessor min/max bounds. For chip-shaped parts the thinnest axis-/// is the height, which tells us whether the model is Y-up or Z-up.-fn glb_flat_axis(bytes: &[u8]) -> Option<char> {- if bytes.len() < 20 || &bytes[0..4] != b"glTF" {- return None;- }- let json_len = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;- let end = (20 + json_len).min(bytes.len());- let v: serde_json::Value = serde_json::from_slice(&bytes[20..end]).ok()?;- let (mut lo, mut hi) = ([f64::MAX; 3], [f64::MIN; 3]);- let mut seen = false;- for acc in v.get("accessors")?.as_array()? {- // quantized meshes store positions as ints (KHR_mesh_quantization), so- // accept ANY VEC3 accessor that carries min/max bounds- if acc.get("type").and_then(|t| t.as_str()) != Some("VEC3") {- continue;- }- let (Some(mn), Some(mx)) = (acc.get("min").and_then(|m| m.as_array()), acc.get("max").and_then(|m| m.as_array())) else { continue };- if mn.len() != 3 || mx.len() != 3 { continue; }- for i in 0..3 {- lo[i] = lo[i].min(mn[i].as_f64().unwrap_or(0.0));- hi[i] = hi[i].max(mx[i].as_f64().unwrap_or(0.0));- }- seen = true;- }- if !seen { return None; }- let ext = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];- let thin = if ext[0] <= ext[1] && ext[0] <= ext[2] { 'x' } else if ext[1] <= ext[2] { 'y' } else { 'z' };- // only meaningful when the part is clearly flat (height under half the next axis)- let mut sorted = ext; sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());- if sorted[0] > sorted[1] * 0.6 { return None; }- Some(thin)-}--/// GLB magic + JSON chunk scan for the Draco extension requirement.-fn glb_is_draco(bytes: &[u8]) -> bool {- if bytes.len() < 20 || &bytes[0..4] != b"glTF" {- return false;- }- let json_len = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;- let end = (20 + json_len).min(bytes.len());- let json = String::from_utf8_lossy(&bytes[20..end]);- json.contains("KHR_draco_mesh_compression")-}--/// The gltf-transform CLI, installed once into the adom-lbr cache.-fn ensure_gltf_transform() -> Result<PathBuf, String> {- let home = std::env::var("HOME").map_err(|_| "no HOME")?;- let prefix = std::path::Path::new(&home).join(".cache/adom-lbr/gltf");- let bin = prefix.join("node_modules/.bin/gltf-transform");- if bin.exists() {- return Ok(bin);- }- process::Command::new("node").arg("-v").output().map_err(|_| "node is not installed (needed once to transcode a Draco .glb)")?;- println!("HINT: fetching @gltf-transform/cli into {} (once)…", prefix.display());- let _ = fs::create_dir_all(&prefix);- let st = process::Command::new("npm")- .args(["install", "--prefix", prefix.to_str().unwrap_or_default(), "--no-fund", "--no-audit", "@gltf-transform/cli@3", "draco3dgltf"])- .output()- .map_err(|e| format!("npm did not run: {e}"))?;- if !st.status.success() || !bin.exists() {- return Err(format!("npm install @gltf-transform/cli@3 failed (v3 pinned: v4 needs Node 20+): {}", String::from_utf8_lossy(&st.stderr).lines().last().unwrap_or("")));- }- Ok(bin)-}--/// The model-viewer bundle, fetched once into the cache and inlined thereafter-/// (the readme sandbox may fetch nothing at view time, so it must ride along).-fn ensure_model_viewer() -> Result<String, String> {- let home = std::env::var("HOME").map_err(|_| "no HOME")?;- let path = std::path::Path::new(&home).join(".cache/adom-lbr/model-viewer-3.5.0.min.js");- if !path.exists() {- let _ = fs::create_dir_all(path.parent().unwrap());- let st = process::Command::new("curl")- .args(["-sL", "--max-time", "60", "-o", path.to_str().unwrap_or_default(),- "https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"])- .status()- .map_err(|e| format!("curl did not run: {e}"))?;- if !st.success() {- return Err("could not fetch the model-viewer bundle (offline?); the static render stands in".into());- }- }- let js = fs::read_to_string(&path).map_err(|e| format!("read model-viewer bundle: {e}"))?;- if js.len() < 100_000 {- let _ = fs::remove_file(&path);- return Err("model-viewer bundle looks truncated; refetch next run".into());- }- Ok(js)-}--fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String> {- let mpn = dir.file_name().and_then(|s| s.to_str()).ok_or("bad --dir")?.to_string();- // renders- let sym_svg = read_first(dir, &[format!("{mpn}-symbol.svg")])- .map(|s| s)- .unwrap_or_else(|| "<div class=\"ph\">no symbol render in this directory</div>".into());- let fp_svg = read_first(dir, &[format!("{mpn}-footprint.authoritative.svg"), format!("{mpn}-footprint.svg")])- .unwrap_or_else(|| "<div class=\"ph\">no footprint render in this directory</div>".into());- // The 3D column: a real interactive viewer when the directory has a .glb- // (adom-lbr#4, barrett's design carried forward). The model rides INLINE as- // a data URI and the model-viewer bundle is inlined too, because the wiki- // serves readmes with connect-src 'none': nothing may be fetched at view- // time. Draco models are transcoded to KHR_mesh_quantization at build time- // (three.js decodes that natively; the Draco decoder is a CDN fetch the- // sandbox blocks - and quantized is measured ~40% smaller anyway).- let static_3d = || {- let cands = ["-3d-iso-named.png", "-3d-iso.png", "-3d-iso-lg.png"]- .iter().map(|s| dir.join(format!("{mpn}{s}"))).find(|p| p.exists());- match cands.and_then(|p| fs::read(&p).ok()) {- Some(bytes) => format!("<img src=\"data:image/png;base64,{}\">", b64(&bytes)),- None => "<div class=\"ph\">no 3D render in this directory</div>".into(),- }- };- let threed = match build_3d_viewer(dir, &mpn) {- Ok(html) => html,- Err(reason) => {- eprintln!("HINT: 3D column falls back to the static render: {reason}");- static_3d()- }- };- // optional pin→pad connects from a .lbr- let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();- if let Some(lbr) = read_first(dir, &[format!("{mpn}.lbr")]) {- if let Ok(re) = regex_lite::Regex::new(r#"<connect gate="[^"]*" pin="([^"]+)" pad="([^"]+)"/>"#) {- for cap in re.captures_iter(&lbr) {- pin_to_pad.insert(cap[1].to_string(), cap[2].to_string());- }- }- }- // pins + descriptions from the extracted symbol JSON- let mut pins_json = String::from("[]");- let mut pad_count = fp_svg.matches("adom-pad").count().max(0);- if let Some(ext) = read_first(dir, &[format!("{mpn}-symbol.extracted.json")]) {- if let Ok(v) = serde_json::from_str::<serde_json::Value>(&ext) {- if let Some(arr) = v.get("pins").and_then(|p| p.as_array()) {- let rows: Vec<serde_json::Value> = arr.iter().map(|p| {- let num = p.get("number").and_then(|x| x.as_str()).unwrap_or("").to_string();- let raw_t = p.get("type").and_then(|x| x.as_str()).unwrap_or("");- let (label, color) = pin_type_style(raw_t);- let pad = pin_to_pad.get(p.get("name").and_then(|x| x.as_str()).unwrap_or(""))- .cloned().unwrap_or_else(|| num.clone());- serde_json::json!({- "number": num,- "name": p.get("name").and_then(|x| x.as_str()).unwrap_or(""),- "pad": pad,- "type": label,- "color": color,- "desc": p.get("description").and_then(|x| x.as_str()).unwrap_or(""),- })- }).collect();- pad_count = pad_count.max(rows.len());- pins_json = serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into());- }- }- }- let pin_count = serde_json::from_str::<serde_json::Value>(&pins_json).ok()- .and_then(|v| v.as_array().map(|a| a.len())).unwrap_or(0);-- let html = include_str!("embed.html")- .replace("__NAME__", &esc_html(&mpn))- .replace("__SYM__", &sym_svg)- .replace("__FP__", &fp_svg)- .replace("__THREED__", &threed)- .replace("__PINS__", &pins_json)- .replace("__PADN__", &pad_count.to_string())- .replace("__PINN__", &pin_count.to_string());- let out = out.unwrap_or_else(|| dir.join(format!("{mpn}-lbr-embed.html")));- fs::write(&out, &html).map_err(|e| format!("writing {}: {e}", out.display()))?;- println!("OK: 3-column interactive embed ({pin_count} pins) → {}", out.display());- Ok(())-}--#[cfg(test)]-mod lint_tests {- use super::{dedup_pad_names, lint_lbr};-- #[test]- fn four_sided_symbol_relays_into_two_columns() {- use super::{relayout_two_columns, KicadPin, ParsedSymbol};- let pin = |name: &str, number: &str, pin_type: &str, angle: u32| KicadPin {- name: name.into(), number: number.into(), pin_type: pin_type.into(),- x: 0.0, y: 0.0, angle, length: 2.54,- };- let mut sym = ParsedSymbol {- name: "U1".into(), reference: "U".into(), value: String::new(),- footprint: String::new(), description: String::new(),- pins: vec![- pin("OUT", "1", "output", 180),- pin("GND", "2", "power_in", 90), // bottom pin: the package-mirror tell- pin("IN", "3", "input", 0),- pin("VDD", "4", "power_in", 270), // top pin- ],- body: None, group_labels: vec![], graphics: vec![],- };- assert!(relayout_two_columns(&mut sym, false));- for p in &sym.pins {- assert!(p.angle == 0 || p.angle == 180, "no rotated (top/bottom) pins survive: {} at {}", p.name, p.angle);- }- let body = sym.body.as_ref().unwrap();- let by = |n: &str| sym.pins.iter().find(|p| p.name == n).unwrap();- assert!(by("IN").x < body.left && by("IN").angle == 0, "inputs on the LEFT");- assert!(by("OUT").x > body.right && by("OUT").angle == 180, "outputs on the RIGHT");- assert!(by("GND").x < body.left && by("VDD").x < body.left, "power grouped LEFT");- assert!(by("GND").y < by("IN").y, "power below the signal pins");- // a two-column source stays untouched under auto- let mut two_col = ParsedSymbol {- pins: vec![pin("IN", "1", "input", 0), pin("OUT", "2", "output", 180)],- ..sym- };- assert!(!relayout_two_columns(&mut two_col, false), "auto must not touch a left/right symbol");- }-- #[test]- fn pad_dedup_uses_the_eagle_at_suffix_convention() {- let names: Vec<String> = ["17", "18", "17", "SH", "17"].iter().map(|s| s.to_string()).collect();- let (out, groups) = dedup_pad_names(&names);- assert_eq!(out, vec!["17", "18", "17@2", "SH", "17@3"]);- assert_eq!(groups["17"], vec!["17", "17@2", "17@3"]);- assert_eq!(groups["18"], vec!["18"]);- }-- /// 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 duplicate_pad_names_fail_the_lint() {- // KiCad same-net arrays (thermal vias, shield tabs) share one pad name;- // Fusion refuses the entire library over the repeat (adom/adom-lbr#10)- let r = lint_lbr(&lbr("SH", r#"<smd name="SH" x="4" y="0"/><smd name="SH" x="5" y="0"/>"#));- assert!(!r.passed);- let e = r.errors.iter().find(|e| e.code == "duplicate-pad-name").expect("duplicate-pad-name");- assert!(e.message.contains("'SH'") && e.message.contains("3 times"), "{}", e.message);- assert_eq!(r.errors.iter().filter(|e| e.code == "duplicate-pad-name").count(), 1,- "one repeated name reports once, not once per extra pad");- }-- #[test]- fn suffixed_pad_group_with_multi_pad_connect_passes() {- // the shape generate now emits for a same-net array: NAME, NAME@2, ...- // and one connect naming the whole group, space-separated- let r = lint_lbr(&lbr("SH SH@2 SH@3",- r#"<smd name="SH@2" x="4" y="0"/><smd name="SH@3" x="5" y="0"/>"#));- assert!(r.errors.iter().all(|e| e.code != "duplicate-pad-name" && e.code != "connect-pad-unresolved"),- "@-suffixed group + multi-pad connect is the sanctioned form: {:?}", r.errors);- }-- #[test]- fn multi_pad_connect_with_a_missing_pad_still_fails() {- // each name in a space-separated pad group must resolve individually- let r = lint_lbr(&lbr("SH SH@2", ""));- let e = r.errors.iter().find(|e| e.code == "connect-pad-unresolved").expect("connect-pad-unresolved");- assert!(e.message.contains("\"SH@2\""), "names the missing group member: {}", e.message);- }-- #[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);- }-}+ /// Part metadata for the EAGLE/Fusion attributes that make a part+ /// ORDERABLE — MPN, MANUFACTURER, MOUSER_PN, DIGIKEY_PN, LCSC_PN — read+ /// from a wiki component `page.json` (or any JSON with the same+ /// `component` block). Defaults to `page.json` beside the symbol, so a+ /// component repo just works. Geometry and 3D bindings alone do not tell+ /// a BOM what to buy, and the bridge downstream cannot invent part+ /// numbers, so they have to be carried here.+ #[arg(long)]+ part_metadata: Option<PathBuf>,+ /// Schematic-symbol pin layout policy. `auto` (default): a symbol that+ /// mirrors the physical PACKAGE (pins on all four sides, rotated names)+ /// is re-laid into EE-convention two columns — inputs left, outputs and+ /// bidirectional right, power grouped bottom-left, every name+ /// horizontal, buses contiguous. `two-column` forces the re-layout even+ /// for a left/right source; `keep` preserves the .kicad_sym placement+ /// verbatim.+ #[arg(long, default_value = "auto")]+ symbol_layout: String,+ },+ /// Lint an existing .lbr file for Fusion 360 compatibility+ Lint {+ /// Path to the .lbr file+ path: PathBuf,+ },+ /// Assemble many single-part .lbr files into ONE multi-deviceset library.+ /// Packages and symbols dedupe by content, name collisions rename with a+ /// numeric suffix, lint-failing inputs are refused, and the assembled+ /// document must itself pass lint. Prints a JSON summary.+ Assemble {+ /// Output .lbr path+ #[arg(long, short = 'o')]+ out: PathBuf,+ /// Library description (default: Adom Cloud Library)+ #[arg(long)]+ name: Option<String>,+ /// Input part .lbr files+ inputs: Vec<PathBuf>,+ },+ /// Validate a .lbr file has complete deviceset (symbol + package + connects)+ Validate {+ /// Path to the .lbr file+ path: PathBuf,+ },+ /// Import an EAGLE .lbr and convert to KiCad .kicad_sym+ Import {+ /// Path to the .lbr file+ path: PathBuf,+ /// Output .kicad_sym file path (default: <name>.kicad_sym)+ #[arg(short, long)]+ output: Option<PathBuf>,+ },+ /// Verify a .kicad_sym via service-kicad (headless KiCad validation)+ Check {+ /// Path to the .kicad_sym file+ path: PathBuf,+ },+ /// Export a Fusion-style 3-column INTERACTIVE HTML package — symbol | footprint+ /// | 3D, each pan/zoomable, with a pin→pad mapping table carrying the datasheet+ /// descriptions below. Self-contained for embedding in an iframe (e.g. a wiki+ /// component page). Assembles from a chip directory's renders + extracted data.+ Embed {+ /// Chip directory (a chip-fetcher library entry) to assemble the viewer from+ #[arg(long)]+ dir: PathBuf,+ /// Output .html path (default: <mpn>-lbr-embed.html in the dir)+ #[arg(long)]+ out: Option<PathBuf>,+ },+ /// Export per-slot interactive viewers (viewers/symbol.html + footprint.html)+ /// with the cross-highlight bus + tooltips + layer toggles, for a wiki+ /// component page's native viewer slots. CAD is rendered from the wiki slug;+ /// pin descriptions come from <MPN>-symbol.extracted.json in --dir.+ ExportViewers {+ /// Wiki page slug (renders symbol/footprint from its CAD)+ #[arg(long)]+ slug: String,+ /// Local chip dir holding <MPN>-symbol.extracted.json (pin type + descriptions)+ #[arg(long)]+ dir: PathBuf,+ /// Output directory for the viewer HTML files (default: ./viewers)+ #[arg(long)]+ out: Option<PathBuf>,+ /// Omit the symbol slot's built-in pin table (use when the page carries its+ /// own pin table, e.g. a rich readme datasheet table, to avoid duplication)+ #[arg(long)]+ no_pin_table: bool,+ },+ /// Export ONE configurable, cross-linked viewer combining symbol + footprint ++ /// 3D + pin-map in a declared layout (columns | quad | single). Which panes and+ /// per-pane options (e.g. the pin-map description column) are configurable via+ /// flags or a page.json `lbrView` block — same spirit as the wiki `sections`+ /// allowlist. Renders CAD from the hub/slug; pin descriptions come from+ /// <MPN>-symbol.extracted.json in --dir.+ View {+ /// Wiki page slug (or hub <MPN>.adom-lbr.json in --dir)+ #[arg(long)]+ slug: String,+ /// Local chip dir holding the hub JSON + <MPN>-symbol.extracted.json + .glb+ #[arg(long)]+ dir: PathBuf,+ /// Output .html path (default: <MPN>-lbr-<layout>.html in --dir)+ #[arg(long)]+ out: Option<PathBuf>,+ /// Layout mode: columns | quad | single+ #[arg(long, default_value = "quad")]+ layout: String,+ /// Comma-separated panes in order (default: all available). e.g. symbol,footprint,3d,pins+ #[arg(long)]+ panes: Option<String>,+ /// Show the pin-map description column (default: on for quad/single, off for columns)+ #[arg(long)]+ description: Option<bool>,+ /// Show the pin-map type roll-up footer (default: on for quad)+ #[arg(long)]+ rollup: Option<bool>,+ /// Read layout/panes/paneOpts from a page.json `lbrView` block (overrides the flags)+ #[arg(long)]+ config: Option<PathBuf>,+ },+ /// Run the live app server (AI-drivable: /state, /load, /eval, /console)+ Serve {+ /// Port to listen on+ #[arg(long, default_value_t = 8784)]+ port: u16,+ /// Optional .lbr to load into the view on startup+ #[arg(long)]+ file: Option<PathBuf>,+ },+ /// adom-lbr Manager: browse wiki components, view symbol/footprint/3D + the+ /// footprint layer stackup (KiCad + Altium). Renders SVG on the fly per click.+ /// Foreground alias of `dashboard serve --foreground`.+ Manage {+ /// Port to listen on+ #[arg(long, default_value_t = 8785)]+ port: u16,+ },+ /// The adom-lbr Dashboard (the Manager plus live status, settings and the+ /// Hydrogen Dock Bar contract). `dashboard serve --print-url` is what the+ /// dock runs: idempotent, version-aware, self-backgrounding.+ Dashboard {+ #[command(subcommand)]+ cmd: DashboardCmd,+ },+ /// Import a KiCad symbol + footprint into the canonical adom-lbr JSON (the hub).+ ImportKicad {+ /// Path to the .kicad_sym file+ #[arg(long)]+ sym: PathBuf,+ /// Path to the .kicad_mod footprint file+ #[arg(long)]+ fp: PathBuf,+ /// Manufacturer part number / component id+ #[arg(long, default_value = "PART")]+ mpn: String,+ /// Output adom-lbr JSON path (default: <mpn>.adom-lbr.json)+ #[arg(long)]+ out: Option<PathBuf>,+ /// JSON map from SYMBOL PIN NUMBER to FOOTPRINT PAD NAME, for parts+ /// where the two disagree (a USB-C shield pin "S1" against a pad "SH").+ /// Defaults to `pinmap.json` beside the symbol when that file exists.+ #[arg(long)]+ pin_map: Option<PathBuf>,+ },+ /// Convert an adom-lbr JSON into native Altium .SchLib + .PcbLib (no Altium needed).+ ExportAltium {+ /// Path to the adom-lbr JSON+ part: PathBuf,+ /// Output directory (files named <mpn>.SchLib / <mpn>.PcbLib)+ #[arg(long, default_value = ".")]+ out_dir: PathBuf,+ },+ /// Convert an adom-lbr JSON into one self-contained Altium .IntLib.+ ExportIntlib {+ /// Path to the adom-lbr JSON+ part: PathBuf,+ /// Output .IntLib path (default: <mpn>.IntLib)+ #[arg(long)]+ out: Option<PathBuf>,+ },+ /// Convert an adom-lbr JSON into KiCad .kicad_sym + .kicad_mod.+ ExportKicad {+ /// Path to the adom-lbr JSON+ part: PathBuf,+ /// Output directory (files named <mpn>.kicad_sym / <mpn>.kicad_mod)+ #[arg(long, default_value = ".")]+ out_dir: PathBuf,+ },+ /// Add an adom-lbr JSON's symbol into an EXISTING Altium .SchLib (non-destructive merge).+ AddToSchlib {+ /// Existing .SchLib to merge into+ existing: PathBuf,+ /// Path to the adom-lbr JSON (or a bare Symbol / array of Symbols)+ part: PathBuf,+ /// Output .SchLib path (default: merged.SchLib)+ #[arg(long)]+ out: Option<PathBuf>,+ },+ /// Convert an Altium .SchLib / .PcbLib / .IntLib into the canonical adom-lbr JSON.+ /// Pass a .SchLib or .PcbLib and the other half via --pcblib/--schlib, or one .IntLib.+ ImportAltium {+ /// Path to a .SchLib, .PcbLib, or .IntLib+ file: PathBuf,+ /// The .PcbLib half (when `file` is a .SchLib)+ #[arg(long)]+ pcblib: Option<PathBuf>,+ /// The .SchLib half (when `file` is a .PcbLib)+ #[arg(long)]+ schlib: Option<PathBuf>,+ /// MPN / component id for the output part (default: the symbol's name)+ #[arg(long, default_value = "")]+ mpn: String,+ /// Output adom-lbr JSON path (default: <mpn>.adom-lbr.json)+ #[arg(long)]+ out: Option<PathBuf>,+ },+ /// Install skill + bash completions+ Install,+ /// Check health+ Health,+ /// Generate shell completions+ Completions {+ #[arg(value_enum)]+ shell: clap_complete::Shell,+ },+}++// ── KiCad .kicad_sym parser ──++#[derive(Debug, Serialize, Deserialize)]+struct KicadPin {+ name: String,+ number: String,+ pin_type: String,+ x: f64,+ y: f64,+ angle: u32,+ length: f64,+}++#[derive(Debug, Serialize, Deserialize)]+struct KicadBody {+ left: f64,+ top: f64,+ right: f64,+ bottom: f64,+}++#[derive(Debug, Serialize, Deserialize)]+struct GroupLabel {+ text: String,+ x: f64,+ y: f64,+}++#[derive(Debug, Serialize, Deserialize)]+struct ParsedSymbol {+ name: String,+ reference: String,+ value: String,+ footprint: String,+ description: String,+ pins: Vec<KicadPin>,+ body: Option<KicadBody>,+ group_labels: Vec<GroupLabel>,+ graphics: Vec<SymGraphic>,+}++pub(crate) fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedSymbol, String> {+ let name = if let Some(n) = name_override {+ n.to_string()+ } else {+ let re = regex_lite::Regex::new(r#"\(symbol "([^"]+)""#).unwrap();+ re.captures(content)+ .and_then(|c| c.get(1))+ .map(|m| m.as_str().to_string())+ .ok_or("Cannot find symbol name in .kicad_sym")?+ };++ let mut pins = Vec::new();+ // The pin angle may be an int (`180`) or a float (`180.0`) depending on the+ // KiCad version that wrote the symbol; accept both. The shape/electrical+ // tokens may also be hyphenated (e.g. `power_in`, `no_connect`).+ let pin_re = regex_lite::Regex::new(+ r#"(?s)\(pin\s+([\w-]+)\s+[\w-]+\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)\s+\(length\s+([-\d.]+)\).*?\(name\s+"([^"]*)".*?\(number\s+"([^"]*)""#+ ).unwrap();++ for cap in pin_re.captures_iter(content) {+ pins.push(KicadPin {+ pin_type: cap[1].to_string(),+ x: cap[2].parse().unwrap_or(0.0),+ y: cap[3].parse().unwrap_or(0.0),+ angle: cap[4].parse::<f64>().unwrap_or(0.0).round() as u32,+ length: cap[5].parse().unwrap_or(2.54),+ name: cap[6].to_string(),+ number: cap[7].to_string(),+ });+ }++ let body = {+ let rect_re = regex_lite::Regex::new(+ r#"\(rectangle\s+\(start\s+([-\d.]+)\s+([-\d.]+)\)\s+\(end\s+([-\d.]+)\s+([-\d.]+)\)"#+ ).unwrap();+ rect_re.captures(content).map(|c| KicadBody {+ left: c[1].parse().unwrap_or(0.0),+ top: c[2].parse().unwrap_or(0.0),+ right: c[3].parse().unwrap_or(0.0),+ bottom: c[4].parse().unwrap_or(0.0),+ })+ };++ // 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.]+)"#+ ).unwrap();+ for cap in text_re.captures_iter(content) {+ group_labels.push(GroupLabel {+ text: cap[1].to_string(),+ x: cap[2].parse().unwrap_or(0.0),+ y: cap[3].parse().unwrap_or(0.0),+ });+ }++ let prop = |key: &str| -> String {+ let re = regex_lite::Regex::new(&format!(r#"\(property\s+"{}"\s+"([^"]*)""#, key)).unwrap();+ re.captures(content)+ .and_then(|c| c.get(1))+ .map(|m| m.as_str().to_string())+ .unwrap_or_default()+ };++ Ok(ParsedSymbol {+ name,+ reference: prop("Reference"),+ value: prop("Value"),+ footprint: prop("Footprint"),+ description: prop("Description"),+ pins,+ body,+ group_labels,+ graphics,+ })+}++// ── EAGLE .lbr parser (import) ──++fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> {+ let pin_re = regex_lite::Regex::new(+ r#"<pin name="([^"]+)" x="([^"]+)" y="([^"]+)" length="([^"]+)" direction="([^"]+)"([^/]*)/>"#+ ).unwrap();++ let sym_name_re = regex_lite::Regex::new(r#"<symbol name="([^"]+)">"#).unwrap();+ let name = sym_name_re.captures(content)+ .and_then(|c| c.get(1))+ .map(|m| m.as_str().to_string())+ .ok_or("Cannot find <symbol name=\"...\"> in .lbr")?;++ let desc_re = regex_lite::Regex::new(r#"<description>([^<]*)</description>"#).unwrap();+ let description = desc_re.captures(content)+ .and_then(|c| c.get(1))+ .map(|m| m.as_str().to_string())+ .unwrap_or_default()+ .replace("&", "&").replace("<", "<").replace(">", ">")+ .replace(""", "\"").replace("'", "'");++ let prefix_re = regex_lite::Regex::new(r#"<deviceset[^>]*prefix="([^"]+)""#).unwrap();+ let reference = prefix_re.captures(content)+ .and_then(|c| c.get(1))+ .map(|m| m.as_str().to_string())+ .unwrap_or_else(|| "U".to_string());++ let rect_re = regex_lite::Regex::new(+ r#"<wire x1="([^"]+)" y1="([^"]+)" x2="([^"]+)" y2="([^"]+)" width="[^"]*" layer="94"/>"#+ ).unwrap();++ let mut min_x = f64::MAX;+ let mut min_y = f64::MAX;+ let mut max_x = f64::MIN;+ let mut max_y = f64::MIN;+ let mut has_body = false;+ for cap in rect_re.captures_iter(content) {+ has_body = true;+ let x1: f64 = cap[1].parse().unwrap_or(0.0);+ let y1: f64 = cap[2].parse().unwrap_or(0.0);+ let x2: f64 = cap[3].parse().unwrap_or(0.0);+ let y2: f64 = cap[4].parse().unwrap_or(0.0);+ min_x = min_x.min(x1).min(x2);+ min_y = min_y.min(y1).min(y2);+ max_x = max_x.max(x1).max(x2);+ max_y = max_y.max(y1).max(y2);+ }++ let body = if has_body {+ Some(KicadBody { left: min_x, top: max_y, right: max_x, bottom: min_y })+ } else {+ None+ };++ let connect_re = regex_lite::Regex::new(+ r#"<connect gate="[^"]*" pin="([^"]+)" pad="([^"]+)"/>"#+ ).unwrap();+ let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();+ for cap in connect_re.captures_iter(content) {+ pin_to_pad.insert(cap[1].to_string(), cap[2].to_string());+ }++ let length_val = |kw: &str| -> f64 {+ match kw {+ "point" => 0.0,+ "short" => 2.54,+ "middle" => 5.08,+ "long" => 7.62,+ _ => 2.54,+ }+ };++ let eagle_to_kicad_dir = |d: &str| -> &'static str {+ match d {+ "pwr" => "power_in",+ "in" => "input",+ "out" => "output",+ "io" => "bidirectional",+ "pas" => "passive",+ "nc" => "unconnected",+ "hiz" => "tri_state",+ "oc" => "open_collector",+ _ => "bidirectional",+ }+ };++ let rot_re = regex_lite::Regex::new(r#"rot="R(\d+)""#).unwrap();++ let mut pins = Vec::new();+ for cap in pin_re.captures_iter(content) {+ let pin_name_raw = cap[1].to_string();+ let pin_name = pin_name_raw.replace("@2", "").replace("@3", "")+ .replace("@4", "").replace("@5", "").replace("@6", "")+ .replace("@7", "").replace("@8", "").replace("@9", "");++ let pad_number = pin_to_pad.get(&pin_name_raw)+ .cloned()+ .unwrap_or_else(|| "0".to_string());++ let angle = rot_re.captures(&cap[6])+ .and_then(|c| c.get(1))+ .and_then(|m| m.as_str().parse::<u32>().ok())+ .unwrap_or(0);++ pins.push(KicadPin {+ name: pin_name,+ number: pad_number,+ pin_type: eagle_to_kicad_dir(&cap[5]).to_string(),+ x: cap[2].parse().unwrap_or(0.0),+ y: cap[3].parse().unwrap_or(0.0),+ angle,+ length: length_val(&cap[4]),+ });+ }++ Ok(ParsedSymbol {+ value: name.clone(),+ name,+ reference,+ footprint: String::new(),+ description,+ pins,+ body,+ group_labels: Vec::new(),+ graphics: Vec::new(),+ })+}++// ── KiCad .kicad_sym generator ──++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");+ 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");+ s.push_str(" (in_bom yes)\n (on_board yes)\n");++ s.push_str(&format!(" (property \"Reference\" \"{}\"\n (at 0 2.54 0)\n (effects (font (size 1.27 1.27)))\n )\n", sym.reference));+ s.push_str(&format!(" (property \"Value\" \"{}\"\n (at 0 -2.54 0)\n (effects (font (size 1.27 1.27)))\n )\n", sym.name));+ s.push_str(&format!(" (property \"Footprint\" \"{}\"\n (at 0 0 0)\n (effects (font (size 1.27 1.27)) (hide yes))\n )\n",+ sym.footprint.replace('"', "\\\"")+ ));+ s.push_str(" (property \"Datasheet\" \"\"\n (at 0 0 0)\n (effects (font (size 1.27 1.27)) (hide yes))\n )\n");+ if !sym.description.is_empty() {+ s.push_str(&format!(" (property \"Description\" \"{}\"\n (at 0 0 0)\n (effects (font (size 1.27 1.27)) (hide yes))\n )\n",+ sym.description.replace('"', "\\\"")+ ));+ }++ s.push_str(&format!(" (symbol \"{}_0_{}\"\n", sym.name, 1));++ if let Some(body) = &sym.body {+ s.push_str(&format!(" (rectangle (start {} {}) (end {} {})\n (stroke (width 0.254) (type default))\n (fill (type background))\n )\n",+ body.left, body.top, body.right, body.bottom+ ));+ }++ for pin in &sym.pins {+ let kicad_angle = match pin.angle {+ 0 => 0,+ 90 => 90,+ 180 => 180,+ 270 => 270,+ _ => 0,+ };+ 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('"', "\\\""),+ pin.number.replace('"', "\\\"")+ ));+ }++ s.push_str(" )\n");+ s.push_str(" )\n)\n");+ s+}++// ── EAGLE .lbr generator ──++pub(crate) fn esc_xml(s: &str) -> String {+ s.replace('&', "&")+ .replace('<', "<")+ .replace('>', ">")+ .replace('"', """)+ .replace('\'', "'")+}++fn pin_direction(kicad_type: &str) -> &'static str {+ match kicad_type {+ "power_in" => "pwr",+ "power_out" => "out",+ "input" => "in",+ "output" => "out",+ "bidirectional" => "io",+ "passive" => "pas",+ "unconnected" => "nc",+ "tri_state" => "hiz",+ "open_collector" | "open_emitter" => "oc",+ _ => "io",+ }+}++fn pin_rotation(angle: u32) -> &'static str {+ match angle {+ 180 => " rot=\"R180\"",+ 90 => " rot=\"R90\"",+ 270 => " rot=\"R270\"",+ _ => "",+ }+}++fn pin_length_keyword(len: f64) -> &'static str {+ if len <= 0.0 { "point" }+ else if len <= 2.54 { "short" }+ else if len <= 5.08 { "middle" }+ else { "long" }+}++/// EAGLE names cannot carry spaces or most punctuation; KiCad pin names can+/// ("GND (TAB)" on a real TI part). Fusion refuses the ENTIRE library over one+/// such name, so every emitted pin name passes through this. Illegal runs+/// collapse to one underscore, trimmed at the ends.+fn eagle_safe_name(name: &str) -> String {+ let mut out = String::with_capacity(name.len());+ let mut last_us = false;+ for ch in name.chars() {+ let ok = ch.is_ascii_alphanumeric() || matches!(ch, '_' | '+' | '-' | '.' | '/' | '!' | '@' | '$' | '#');+ if ok { out.push(ch); last_us = false; }+ else if !last_us { out.push('_'); last_us = true; }+ }+ let trimmed = out.trim_matches('_').to_string();+ if trimmed.is_empty() { "P".to_string() } else { trimmed }+}++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() {+ eagle_safe_name(n)+ } 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 seen: std::collections::HashMap<String, u32> = std::collections::HashMap::new();+ eff.iter().map(|name| {+ if counts[name] == 1 {+ name.clone()+ } else {+ let c = seen.entry(name.clone()).or_insert(0);+ *c += 1;+ if *c == 1 { name.clone() } else { format!("{}@{}", name, c) }+ }+ }).collect()+}++/// The first `(footprint "NAME" …)` name in a `.kicad_mod`, if any.+/// EAGLE requires unique pad names within a package, and Fusion's importer+/// refuses the ENTIRE library over a repeat. KiCad legitimately reuses one pad+/// name across a same-net array (thermal vias, ground paddles, shield tabs), so+/// those come in as correct source data. EAGLE's own convention for one logical+/// pin on several physical pads is NAME, NAME@2, NAME@3, ... with the device's+/// connect naming every pad in the group — emit exactly that, keeping the+/// electrical meaning instead of losing it. Returns the per-pad output names+/// (document order) plus original-name → group map for building the connects.+fn dedup_pad_names(names: &[String]) -> (Vec<String>, std::collections::HashMap<String, Vec<String>>) {+ let mut counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();+ let mut out = Vec::with_capacity(names.len());+ let mut groups: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();+ for n in names {+ let c = counts.entry(n.as_str()).or_insert(0);+ *c += 1;+ let out_name = if *c == 1 { n.clone() } else { format!("{}@{}", n, c) };+ groups.entry(n.clone()).or_default().push(out_name.clone());+ out.push(out_name);+ }+ (out, groups)+}++/// EE-convention symbol re-layout (adom/adom-lbr#5). A .kicad_sym that mirrors+/// the physical package puts pins on all four sides, which forces the top and+/// bottom names to render rotated and scatters logically-related pins to+/// physical corners — a schematic symbol is not a package drawing. Re-lay into+/// two columns: inputs and passives on the LEFT, outputs and bidirectional on+/// the RIGHT, power grouped at the bottom-left, NC at the bottom-right, buses+/// (GPIO0..47, DB0..15) contiguous in numeric order, a blank row between+/// groups, and a body wide enough that opposing names never meet. Every pin+/// name renders horizontal. Returns whether the re-layout was applied.+pub(crate) fn relayout_two_columns(sym: &mut ParsedSymbol, force: bool) -> bool {+ // KiCad pin angles: 0 = points right (pin on the LEFT edge), 180 = left+ // (RIGHT edge), 90 = up (BOTTOM edge), 270 = down (TOP edge). Top/bottom+ // pins are the package-mirror tell.+ let four_sided = sym.pins.iter().any(|p| p.angle == 90 || p.angle == 270);+ if sym.pins.is_empty() || !(force || four_sided) {+ return false;+ }++ // side: 0 = left, 1 = right; rank orders the sections top→bottom in a column+ fn placement(p: &KicadPin) -> (usize, u8) {+ let n = p.name.to_uppercase();+ let power = p.pin_type.starts_with("power")+ || ["VDD", "VCC", "VSS", "GND", "VBAT", "VBUS", "VSYS", "VREG"].iter().any(|k| n.contains(k));+ if power {+ return (0, 9);+ }+ match p.pin_type.as_str() {+ "input" => (0, 0),+ "output" => (1, 0),+ "bidirectional" | "tri_state" | "open_collector" | "open_emitter" => (1, 1),+ "no_connect" | "free" => (1, 9),+ _ => (0, 1), // passive / unspecified / analog+ }+ }++ // Natural sort key so DB2 sorts before DB10 and a bus stays contiguous.+ fn natural(name: &str) -> (String, u64, String) {+ let base = name.trim_end_matches(|c: char| c.is_ascii_digit());+ let num: u64 = name[base.len()..].parse().unwrap_or(0);+ (base.to_uppercase(), num, name.to_string())+ }++ let mut order: Vec<usize> = (0..sym.pins.len()).collect();+ order.sort_by(|&a, &b| {+ let (pa, pb) = (&sym.pins[a], &sym.pins[b]);+ (placement(pa), natural(&pa.name)).cmp(&(placement(pb), natural(&pb.name)))+ });++ // Bus-prefix population, to know which prefix runs are real buses.+ let mut prefix_count: std::collections::HashMap<(usize, u8, String), u32> = std::collections::HashMap::new();+ for p in &sym.pins {+ let (side, rank) = placement(p);+ *prefix_count.entry((side, rank, natural(&p.name).0)).or_insert(0) += 1;+ }++ // Build the two columns; None = blank spacer row between logical groups.+ let mut cols: [Vec<Option<usize>>; 2] = [Vec::new(), Vec::new()];+ let mut last: [Option<(u8, String)>; 2] = [None, None];+ for &i in &order {+ let p = &sym.pins[i];+ let (side, rank) = placement(p);+ let base = natural(&p.name).0;+ if let Some((prev_rank, prev_base)) = &last[side] {+ let bus_boundary = *prev_base != base+ && (prefix_count[&(side, rank, base.clone())] >= 4+ || prefix_count[&(side, *prev_rank, prev_base.clone())] >= 4);+ if *prev_rank != rank || bus_boundary {+ cols[side].push(None);+ }+ }+ last[side] = Some((rank, base));+ cols[side].push(Some(i));+ }++ // Body wide enough that the longest left + right names never approach each+ // other (~1.6 mm per char at EAGLE's default pin-name size, plus a gap).+ let widest = |col: &Vec<Option<usize>>| {+ col.iter().flatten().map(|&i| sym.pins[i].name.chars().count()).max().unwrap_or(0)+ };+ if std::env::var("ADOM_LBR_DEBUG_LAYOUT").is_ok() {+ eprintln!("DEBUG layout: widest L={} R={}", widest(&cols[0]), widest(&cols[1]));+ }+ let mut w = ((widest(&cols[0]) + widest(&cols[1])) as f64 * 1.6 + 5.08).max(15.24);+ w = (w / 2.54).ceil() * 2.54;++ let rows = cols[0].len().max(cols[1].len());+ let y_top = (((rows.saturating_sub(1)) as f64) * 2.54 / 2.0 / 2.54).ceil() * 2.54;+ for (side, col) in cols.iter().enumerate() {+ for (row, slot) in col.iter().enumerate() {+ if let Some(i) = slot {+ let p = &mut sym.pins[*i];+ p.y = y_top - row as f64 * 2.54;+ p.length = 5.08; // EAGLE "middle": numbers outside, names inside+ if side == 0 {+ p.x = -(w / 2.0) - 5.08;+ p.angle = 0;+ } else {+ p.x = w / 2.0 + 5.08;+ p.angle = 180;+ }+ }+ }+ }++ sym.body = Some(KicadBody {+ left: -(w / 2.0),+ right: w / 2.0,+ top: y_top + 2.54,+ bottom: y_top - ((rows.saturating_sub(1)) as f64) * 2.54 - 2.54,+ });+ // Source graphics and group labels were positioned for the old layout.+ sym.graphics.clear();+ sym.group_labels.clear();+ true+}++fn first_footprint_name(km: &str) -> Option<String> {+ let re = regex_lite::Regex::new(r#"\(footprint\s+"([^"]+)""#).unwrap();+ re.captures(km).and_then(|c| c.get(1)).map(|m| m.as_str().to_string())+}++/// Synthesize a placeable symbol from a `.kicad_mod`'s pads when there is no+/// `.kicad_sym`. One symbol pin per unique pad number, balanced left/right, so+/// the resulting EAGLE deviceset has the correct pin/pad names and connects.+fn synth_symbol_from_mod(km: &str, name: &str) -> ParsedSymbol {+ // Pad numbers in document order, de-duplicated (thru-hole pairs share a no.).+ // Pad ids may be quoted ("A1") or bare (1); skip mechanical pads ("").+ let pad_re = regex_lite::Regex::new(r#"\(pad\s+(?:"([^"]*)"|(\S+))"#).unwrap();+ let mut nums: Vec<String> = Vec::new();+ for c in pad_re.captures_iter(km) {+ let n = c.get(1).or_else(|| c.get(2)).map(|m| m.as_str().to_string()).unwrap_or_default();+ if !n.is_empty() && !nums.contains(&n) {+ nums.push(n);+ }+ }+ let count = nums.len().max(1);+ let half = (count + 1) / 2;+ let body_h = (half as f64) * 2.54 + 2.54;+ let pins: Vec<KicadPin> = nums.iter().enumerate().map(|(i, num)| {+ let (x, y, angle) = if i < half {+ (-12.7, (half as f64 / 2.0 - i as f64) * 2.54, 0)+ } else {+ (12.7, (half as f64 / 2.0 - (i - half) as f64) * 2.54, 180)+ };+ KicadPin {+ name: num.clone(),+ number: num.clone(),+ pin_type: "passive".into(),+ x, y, angle, length: 2.54,+ }+ }).collect();+ ParsedSymbol {+ name: name.to_string(),+ reference: "U".into(),+ value: name.to_string(),+ footprint: name.to_string(),+ description: format!("{name} (symbol synthesized from footprint pads)"),+ 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`, `fp_poly` — into EAGLE `<circle>`/`<wire>`/+/// `<polygon>` 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 xy_re = regex_lite::Regex::new(r#"\(xy\s+(-?[\d.]+)\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));+ }+ // fp_poly was silently skipped, and it is not a rare shape: KiCad draws the+ // pin-1 marker as a small FILLED polygon on F.SilkS, and the gullwing generator+ // draws the whole F.Fab body outline as one. Across 2,540 published parts, 97%+ // mark pin 1 that way — so every one of them reached Fusion with no pin-1+ // indicator at all while its geometry looked perfect (adom/adom-lbr#933).+ for b in sexpr_blocks(km, "fp_poly") {+ let Some(el) = el_of(b) else { continue };+ let verts: Vec<(f64, f64)> = xy_re+ .captures_iter(b)+ .map(|c| (c[1].parse::<f64>().unwrap_or(0.0), c[2].parse::<f64>().unwrap_or(0.0)))+ .collect();+ if verts.len() < 3 { continue }+ out.push_str(&format!(" <polygon width=\"{:.4}\" layer=\"{}\">\n", w_of(b), el));+ for (x, y) in verts {+ out.push_str(&format!(" <vertex x=\"{:.4}\" y=\"{:.4}\"/>\n", x, -y));+ }+ out.push_str(" </polygon>\n");+ }+ 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),+ }+}++/// The attributes a BOM and the fusion-bridge linter read off a part.+///+/// Accepts a wiki component `page.json` (fields under `component`) or a bare JSON+/// object with the same keys. Distributor part numbers may be written either flat+/// (`"mouser_pn": "584-..."`) or under `distributor_links` as a string or as an+/// object carrying `pn` / `part_number`, because both shapes are already in use.+/// An absent or unreadable file is not an error - a part with no metadata still+/// generates, it just generates without attributes, exactly as before.+fn part_attributes(path: &std::path::Path) -> Vec<(String, String)> {+ let Ok(raw) = fs::read_to_string(path) else { return Vec::new() };+ let Ok(doc) = serde_json::from_str::<serde_json::Value>(&raw) else { return Vec::new() };+ let c = doc.get("component").unwrap_or(&doc);+ let clean = |v: Option<&serde_json::Value>| -> Option<String> {+ v.and_then(|x| x.as_str()).map(|x| x.trim().to_string()).filter(|x| !x.is_empty())+ };+ let mut out = Vec::new();+ if let Some(v) = clean(c.get("mpn")) { out.push(("MPN".to_string(), v)); }+ if let Some(v) = clean(c.get("manufacturer")) { out.push(("MANUFACTURER".to_string(), v)); }+ let links = c.get("distributor_links");+ for (key, attr) in [("mouser", "MOUSER_PN"), ("digikey", "DIGIKEY_PN"), ("lcsc", "LCSC_PN")] {+ let flat = clean(c.get(&format!("{key}_pn")));+ let nested = links.and_then(|d| d.get(key)).and_then(|e| match e {+ serde_json::Value::String(t) => Some(serde_json::Value::String(t.clone())),+ other => other.get("pn").or_else(|| other.get("part_number")).cloned(),+ });+ if let Some(v) = flat.or_else(|| clean(nested.as_ref())) {+ out.push((attr.to_string(), v));+ }+ }+ out+}++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 {+ let mut xml = String::with_capacity(8192);++ xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");+ xml.push_str("<!DOCTYPE eagle SYSTEM \"eagle.dtd\">\n");+ xml.push_str("<eagle version=\"9.6.2\">\n<drawing>\n");+ xml.push_str("<settings><setting alwaysvectorfont=\"no\"/></settings>\n");+ xml.push_str("<grid distance=\"0.1\" unitdist=\"inch\" unit=\"inch\" style=\"lines\" multiple=\"1\" display=\"no\" altdistance=\"0.01\" altunitdist=\"inch\" altunit=\"inch\"/>\n");++ // Layers+ xml.push_str(EAGLE_LAYERS);++ xml.push_str("<library>\n");+ xml.push_str(&format!("<description>{}</description>\n", esc_xml(&sym.description)));++ // 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();+ // Original pad/pin-number name → the @-suffixed pad group it became, for the connects.+ let pad_groups: std::collections::HashMap<String, Vec<String>>;+ if !mod_pads.is_empty() {+ let km = fp_content.unwrap();+ xml.push_str(&format!(" <description>{}</description>\n", esc_xml(&sym.name)));+ let pad_names: Vec<String> = mod_pads.iter().map(|p| p.name.clone()).collect();+ let (out_names, groups) = dedup_pad_names(&pad_names);+ pad_groups = groups;+ for (i, p) in mod_pads.iter().enumerate() {+ if p.smd {+ xml.push_str(&format!(+ " <smd name=\"{}\" x=\"{:.4}\" y=\"{:.4}\" dx=\"{:.4}\" dy=\"{:.4}\" layer=\"1\"{}/>\n",+ esc_xml(&out_names[i]), 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(&out_names[i]), 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+ let pad_size = 0.5;+ let pitch = 0.5;+ let pin_count = sym.pins.len();+ let half = (pin_count + 1) / 2;++ let placeholder_names: Vec<String> = sym.pins.iter().map(|p| p.number.clone()).collect();+ let (placeholder_out, groups) = dedup_pad_names(&placeholder_names);+ pad_groups = groups;+ for i in 0..pin_count {+ let (px, py) = if i < half {+ // Left column+ (-(pin_count as f64 * pitch / 4.0), (half as f64 / 2.0 - i as f64) * pitch)+ } else {+ // Right column+ (pin_count as f64 * pitch / 4.0, (half as f64 / 2.0 - (i - half) as f64) * pitch)+ };+ xml.push_str(&format!(+ " <smd name=\"{}\" x=\"{:.3}\" y=\"{:.3}\" dx=\"{:.2}\" dy=\"{:.2}\" layer=\"1\"/>\n",+ esc_xml(&placeholder_out[i]), px, py, pad_size, pad_size+ ));+ }++ // Package outline+ let body_w = pin_count as f64 * pitch / 2.0 + 1.0;+ let body_h = half as f64 * pitch + 1.0;+ xml.push_str(&format!(+ " <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+ -body_w / 2.0, body_h / 2.0, body_w / 2.0, body_h / 2.0+ ));+ xml.push_str(&format!(+ " <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+ body_w / 2.0, body_h / 2.0, body_w / 2.0, -body_h / 2.0+ ));+ xml.push_str(&format!(+ " <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+ body_w / 2.0, -body_h / 2.0, -body_w / 2.0, -body_h / 2.0+ ));+ xml.push_str(&format!(+ " <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+ -body_w / 2.0, -body_h / 2.0, -body_w / 2.0, body_h / 2.0+ ));++ 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");++ // 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\">>NAME</text>\n", name_y));+ xml.push_str(&format!(" <text x=\"0\" y=\"{}\" size=\"1.27\" layer=\"96\" align=\"top-center\">>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");+ xml.push_str("</symbols>\n");++ // Deviceset with COMPLETE device (package + connects)+ let ref_prefix = if sym.reference.is_empty() { "U" } else { &sym.reference };+ xml.push_str("<devicesets>\n");+ 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(" </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() {+ // 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);+ // A same-net pad array (KiCad repeats the name; we emitted NAME, NAME@2,+ // ...) connects this one pin to EVERY pad in the group, space-separated —+ // standard EAGLE multi-pad connect form.+ let pad_ref = match pad_groups.get(pad) {+ Some(group) if group.len() > 1 => group.join(" "),+ Some(group) => group[0].clone(),+ None => pad.clone(),+ };+ xml.push_str(&format!(+ " <connect gate=\"G$1\" pin=\"{}\" pad=\"{}\"/>\n",+ esc_xml(&deduped_names[i]), esc_xml(&pad_ref)+ ));+ }++ xml.push_str(" </connects>\n");+ // Attributes ride on <technology>, which is where Fusion's BOM export and the+ // fusion-bridge partMetadata linter look for them. An empty technology - what+ // this always emitted - produces a part with perfect geometry that nothing can+ // order.+ if part_attrs.is_empty() {+ xml.push_str(" <technologies><technology name=\"\"/></technologies>\n");+ } else {+ xml.push_str(" <technologies>\n <technology name=\"\">\n");+ for (k, v) in part_attrs {+ xml.push_str(&format!(+ " <attribute name=\"{}\" value=\"{}\" constant=\"no\"/>\n",+ esc_xml(k), esc_xml(v)));+ }+ xml.push_str(" </technology>\n </technologies>\n");+ }+ xml.push_str(" </device>\n");+ xml.push_str(" </devices>\n");+ xml.push_str("</deviceset>\n");+ xml.push_str("</devicesets>\n");++ xml.push_str("</library>\n</drawing>\n</eagle>");+ xml+}++// ── Linter ──++#[derive(Debug, Serialize)]+pub(crate) struct LintResult {+ pub passed: bool,+ pub errors: Vec<LintError>,+ pub warnings: Vec<LintError>,+}++#[derive(Debug, Serialize)]+pub(crate) struct LintError {+ pub code: String,+ pub message: String,+}++pub(crate) fn lint_lbr(content: &str) -> LintResult {+ let mut errors = Vec::new();+ let mut warnings = Vec::new();++ if !content.contains("<packages>") || !content.contains("</packages>") {+ errors.push(LintError { code: "missing-packages".into(), message: "No <packages> section found".into() });+ }+ if !content.contains("<symbols>") || !content.contains("</symbols>") {+ errors.push(LintError { code: "missing-symbols".into(), message: "No <symbols> section found".into() });+ }+ if !content.contains("<devicesets>") || !content.contains("</devicesets>") {+ errors.push(LintError { code: "missing-devicesets".into(), message: "No <devicesets> section found".into() });+ }++ // 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() });+ }++ // Check for empty connects+ if content.contains("<connects/>") || content.contains("<connects></connects>") {+ errors.push(LintError { code: "empty-connects".into(), message: "Device has no pin-to-pad connect mappings — Fusion cannot use this component".into() });+ }++ // Check device has a package attribute+ if content.contains("<device name=\"\">") && !content.contains("package=") {+ 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;+ }+ if pname.chars().any(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '+' | '-' | '.' | '/' | '!' | '@' | '$' | '#'))) {+ errors.push(LintError {+ code: "illegal-pin-name".into(),+ message: format!("Symbol '{}' pin '{}' contains characters an EAGLE name cannot carry (space or punctuation outside _+-./!@$#) — Fusion refuses the whole library over it", sname, pname),+ });+ }+ *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);+ }++ // 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),+ });+ }+ }+ }+ }+ }++ // 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();++ // Duplicate pad names within a package (adom/adom-lbr#10). KiCad allows a+ // same-net pad array to share one name (thermal vias, ground paddles,+ // shield tabs); EAGLE does not, and Fusion's importer refuses the WHOLE+ // library over it. generate resolves these to NAME, NAME@2, ... — so a+ // file that still carries repeats either predates that or came from+ // somewhere else, and it will not import.+ let mut pad_counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();+ for p in &pads { *pad_counts.entry(p.as_str()).or_insert(0) += 1; }+ let mut dup_pads: Vec<(&str, u32)> = pad_counts.iter().filter(|(_, c)| **c > 1).map(|(n, c)| (*n, *c)).collect();+ dup_pads.sort();+ for (name, count) in dup_pads {+ errors.push(LintError {+ code: "duplicate-pad-name".into(),+ message: format!(+ "Pad name '{}' appears {} times in package '{}' — Fusion's importer refuses the \+entire library over duplicate pad names. Use the EAGLE @-suffix convention ({name}, {name}@2, ...) \+with a multi-pad <connect> (adom-lbr generate now emits this).",+ name, count, &pc[1]+ ),+ });+ }++ 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 = c[1].to_string();+ // pad= may be a space-separated group ("17 17@2 17@3"): the standard+ // EAGLE multi-pad connect for one pin on a same-net pad array. Each+ // named pad must resolve.+ for pad in c[2].split_whitespace() {+ let pad = pad.to_string();+ if pad_set.contains(&pad) { used.insert(pad); } else { dangling.push((pin.clone(), 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() });+ }++ // Fusion-specific checks+ if !content.contains("layer=\"94\"") {+ warnings.push(LintError { code: "no-symbol-layer".into(), message: "No elements on layer 94 (Symbols) — the symbol body may be invisible".into() });+ }++ LintResult {+ passed: errors.is_empty(),+ errors,+ warnings,+ }+}++// ── Layers constant ──++const EAGLE_LAYERS: &str = r#"<layers>+<layer number="1" name="Top" color="4" fill="1" visible="yes" active="yes"/>+<layer number="16" name="Bottom" color="1" fill="1" visible="yes" active="yes"/>+<layer number="17" name="Pads" color="2" fill="1" visible="yes" active="yes"/>+<layer number="18" name="Vias" color="2" fill="1" visible="yes" active="yes"/>+<layer number="19" name="Unrouted" color="6" fill="1" visible="yes" active="yes"/>+<layer number="20" name="Dimension" color="15" fill="1" visible="yes" active="yes"/>+<layer number="21" name="tPlace" color="7" fill="1" visible="yes" active="yes"/>+<layer number="22" name="bPlace" color="7" fill="1" visible="yes" active="yes"/>+<layer number="23" name="tOrigins" color="15" fill="1" visible="yes" active="yes"/>+<layer number="24" name="bOrigins" color="15" fill="1" visible="yes" active="yes"/>+<layer number="25" name="tNames" color="7" fill="1" visible="yes" active="yes"/>+<layer number="26" name="bNames" color="7" fill="1" visible="yes" active="yes"/>+<layer number="27" name="tValues" color="7" fill="1" visible="yes" active="yes"/>+<layer number="28" name="bValues" color="7" fill="1" visible="yes" active="yes"/>+<layer number="29" name="tStop" color="7" fill="3" visible="no" active="yes"/>+<layer number="30" name="bStop" color="7" fill="6" visible="no" active="yes"/>+<layer number="31" name="tCream" color="7" fill="4" visible="no" active="yes"/>+<layer number="32" name="bCream" color="7" fill="5" visible="no" active="yes"/>+<layer number="33" name="tFinish" color="6" fill="3" visible="no" active="yes"/>+<layer number="34" name="bFinish" color="6" fill="6" visible="no" active="yes"/>+<layer number="35" name="tGlue" color="7" fill="4" visible="no" active="yes"/>+<layer number="36" name="bGlue" color="7" fill="5" visible="no" active="yes"/>+<layer number="37" name="tTest" color="7" fill="1" visible="no" active="yes"/>+<layer number="38" name="bTest" color="7" fill="1" visible="no" active="yes"/>+<layer number="39" name="tKeepout" color="4" fill="11" visible="no" active="yes"/>+<layer number="40" name="bKeepout" color="1" fill="11" visible="no" active="yes"/>+<layer number="41" name="tRestrict" color="4" fill="10" visible="no" active="yes"/>+<layer number="42" name="bRestrict" color="1" fill="10" visible="no" active="yes"/>+<layer number="43" name="vRestrict" color="2" fill="10" visible="no" active="yes"/>+<layer number="44" name="Drills" color="7" fill="1" visible="no" active="yes"/>+<layer number="45" name="Holes" color="7" fill="1" visible="no" active="yes"/>+<layer number="46" name="Milling" color="3" fill="1" visible="no" active="yes"/>+<layer number="47" name="Measures" color="7" fill="1" visible="no" active="yes"/>+<layer number="48" name="Document" color="7" fill="1" visible="yes" active="yes"/>+<layer number="49" name="Reference" color="7" fill="1" visible="yes" active="yes"/>+<layer number="91" name="Nets" color="2" fill="1" visible="yes" active="yes"/>+<layer number="92" name="Busses" color="1" fill="1" visible="yes" active="yes"/>+<layer number="93" name="Pins" color="2" fill="1" visible="no" active="yes"/>+<layer number="94" name="Symbols" color="4" fill="1" visible="yes" active="yes"/>+<layer number="95" name="Names" color="7" fill="1" visible="yes" active="yes"/>+<layer number="96" name="Values" color="7" fill="1" visible="yes" active="yes"/>+<layer number="97" name="Info" color="7" fill="1" visible="yes" active="yes"/>+<layer number="98" name="Guide" color="6" fill="1" visible="yes" active="yes"/>+</layers>+"#;++// ── Main ──++/// Read a text file or exit with a clear error.+fn read_or_exit(path: &std::path::Path) -> String {+ fs::read_to_string(path).unwrap_or_else(|e| {+ eprintln!("ERROR: read {}: {e}", path.display());+ process::exit(1);+ })+}++/// Load an adom-lbr JSON into the canonical part.+pub(crate) fn load_part(path: &std::path::Path) -> altium_codec::AdomLbrPart {+ let json = read_or_exit(path);+ serde_json::from_str(&json).unwrap_or_else(|e| {+ eprintln!("ERROR: parse adom-lbr JSON {}: {e}", path.display());+ process::exit(1);+ })+}++/// Seed the symbol's Altium property fields from the part identity (Value drawn;+/// Manufacturer/MPN/Package hidden), skipping empties + names already present.+fn seed_parameters(symbol: &mut altium_codec::Symbol, part: &altium_codec::AdomLbrPart) {+ use altium_codec::Parameter;+ let mut push = |name: &str, value: &str, visible: bool| {+ if value.is_empty()+ || symbol.parameters.iter().any(|p| p.name.eq_ignore_ascii_case(name))+ {+ return;+ }+ symbol.parameters.push(Parameter { name: name.into(), value: value.into(), visible });+ };+ push("Value", &part.value, true);+ push("Manufacturer", &part.manufacturer, false);+ push("Manufacturer Part Number", &part.mpn, false);+ push("Package", &part.package, false);+}++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 } => {+ 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);+ process::exit(1);+ })+ });++ let mut parsed = match (&sym, &fp_content) {+ (Some(sym_path), _) => {+ let sym_content = fs::read_to_string(sym_path).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot read {}: {}", sym_path.display(), e);+ eprintln!("Hint: Check the .kicad_sym path exists.");+ process::exit(1);+ });+ parse_kicad_sym(&sym_content, name.as_deref()).unwrap_or_else(|e| {+ eprintln!("ERROR: {}", e);+ process::exit(1);+ })+ }+ (None, Some(fp)) => {+ // Footprint-only: synthesize a placeable symbol from the pads.+ let syn_name = name.clone()+ .or_else(|| first_footprint_name(fp))+ .unwrap_or_else(|| "FOOTPRINT".to_string());+ synth_symbol_from_mod(fp, &syn_name)+ }+ (None, None) => {+ eprintln!("ERROR: Generate needs at least --sym or --fp.");+ eprintln!("Hint: `adom-lbr generate --fp part.kicad_mod` synthesizes a symbol from the pads.");+ process::exit(1);+ }+ };++ 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,+ };+ // 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());+ }++ // Part metadata: an explicit --part-metadata wins, else page.json beside+ // the symbol, mirroring how pinmap.json is picked up.+ let pm_path = part_metadata.clone().or_else(|| {+ sym.as_ref().and_then(|p| p.parent()).map(|d| d.join("page.json")).filter(|p| p.exists())+ });+ let part_attrs = pm_path.as_ref().map(|p| part_attributes(p)).unwrap_or_default();+ if part_attrs.is_empty() {+ println!("HINT: no part attributes (MPN/MANUFACTURER/MOUSER_PN/DIGIKEY_PN/LCSC_PN). \+The geometry is fine but a BOM cannot order this part - pass --part-metadata or put a page.json beside the symbol.");+ } else {+ println!("HINT: part attributes: {}", part_attrs.iter()+ .map(|(k, _)| k.as_str()).collect::<Vec<_>>().join(", "));+ }++ match symbol_layout.as_str() {+ "keep" => {}+ "auto" | "two-column" => {+ if art.is_some() {+ // Symbol art is placed in the source symbol's own+ // coordinate space; re-laying the pins would orphan it.+ if symbol_layout == "two-column" {+ eprintln!("WARN: --symbol-layout two-column ignored: --symbol-art is placed in the source symbol's coordinates");+ }+ } else if relayout_two_columns(&mut parsed, symbol_layout == "two-column") {+ println!("HINT: symbol re-laid into two columns (source mirrored the package: pins on top/bottom). --symbol-layout keep preserves the original.");+ }+ }+ other => {+ eprintln!("ERROR: --symbol-layout must be auto, two-column, or keep (got '{}')", other);+ process::exit(1);+ }+ }++ let lbr = generate_lbr(&parsed, fp_content.as_deref(), art.as_ref(), symbol_art_scale, &pin_map, &part_attrs);++ // Lint before writing+ let lint = lint_lbr(&lbr);+ for err in &lint.errors {+ eprintln!("LINT ERROR: [{}] {}", err.code, err.message);+ }+ for warn in &lint.warnings {+ eprintln!("LINT WARN: [{}] {}", warn.code, warn.message);+ }++ let out_path = output.unwrap_or_else(|| {+ out_default.with_extension("lbr")+ });++ fs::write(&out_path, &lbr).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot write {}: {}", out_path.display(), e);+ process::exit(1);+ });++ println!("OK: Generated {} ({} pins, {} bytes)", out_path.display(), parsed.pins.len(), lbr.len());+ println!("HINT: Lint {} with {} error(s), {} warning(s)", if lint.passed { "PASSED" } else { "FAILED" }, lint.errors.len(), lint.warnings.len());++ if !lint.passed {+ process::exit(2);+ }+ }++ Commands::Lint { path } => {+ let content = fs::read_to_string(&path).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot read {}: {}", path.display(), e);+ eprintln!("Hint: Check the path exists. Use `ls` to verify.");+ process::exit(1);+ });++ let result = lint_lbr(&content);+ if result.passed {+ println!("OK: Lint passed with 0 error(s), {} warning(s)", result.warnings.len());+ } else {+ eprintln!("ERROR: Lint failed with {} error(s)", result.errors.len());+ for err in &result.errors {+ eprintln!(" [{}] {}", err.code, err.message);+ }+ eprintln!("Hint: Fix the errors above, then re-run `adom-lbr lint {}`", path.display());+ }+ for warn in &result.warnings {+ eprintln!("WARN: [{}] {}", warn.code, warn.message);+ }+ // Also output JSON for programmatic consumers+ let json = serde_json::to_string_pretty(&result).unwrap();+ println!("{}", json);++ if !result.passed {+ process::exit(2);+ }+ }++ Commands::Assemble { out, name, inputs } => {+ assemble::run(&out, name.as_deref(), &inputs);+ }+ Commands::Validate { path } => {+ let content = fs::read_to_string(&path).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot read {}: {}", path.display(), e);+ process::exit(1);+ });++ let result = lint_lbr(&content);+ if result.passed {+ println!("OK: {} is a valid EAGLE .lbr with complete deviceset", path.display());+ } else {+ eprintln!("FAIL: {} has {} error(s)", path.display(), result.errors.len());+ for err in &result.errors {+ eprintln!(" [{}] {}", err.code, err.message);+ }+ process::exit(2);+ }+ }++ Commands::Import { path, output } => {+ let content = fs::read_to_string(&path).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot read {}: {}", path.display(), e);+ process::exit(1);+ });++ let parsed = parse_eagle_lbr(&content).unwrap_or_else(|e| {+ eprintln!("ERROR: {}", e);+ process::exit(1);+ });++ let kicad_sym = generate_kicad_sym(&parsed);++ let out_path = output.unwrap_or_else(|| {+ path.with_extension("kicad_sym")+ });++ fs::write(&out_path, &kicad_sym).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot write {}: {}", out_path.display(), e);+ process::exit(1);+ });++ println!("OK: Imported {} → {} ({} pins)", path.display(), out_path.display(), parsed.pins.len());+ println!("HINT: Run 'adom-lbr check {}' to validate via service-kicad", out_path.display());+ }++ Commands::Check { path } => {+ let content = fs::read_to_string(&path).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot read {}: {}", path.display(), e);+ process::exit(1);+ });++ let mut errors: Vec<String> = Vec::new();++ if !content.contains("(kicad_symbol_lib") {+ errors.push("Missing (kicad_symbol_lib) header — not a valid .kicad_sym file".into());+ }+ if !content.contains("(symbol \"") {+ errors.push("No (symbol) definitions found".into());+ }++ let pin_count = content.matches("(pin ").count();+ if pin_count == 0 {+ errors.push("No pins found — symbol has no electrical connections".into());+ }++ if !content.contains("(property \"Reference\"") {+ errors.push("Missing Reference property".into());+ }+ if !content.contains("(property \"Value\"") {+ errors.push("Missing Value property".into());+ }++ // Check for duplicate pin names within each symbol+ let sym_re = regex_lite::Regex::new(r#"\(pin \w+ \w+ \(at [^)]+\) \(length [^)]+\) \(name "([^"]+)""#).unwrap();+ let mut pin_names: std::collections::HashMap<String, usize> = std::collections::HashMap::new();+ for cap in sym_re.captures_iter(&content) {+ *pin_names.entry(cap[1].to_string()).or_insert(0) += 1;+ }+ let dups: Vec<_> = pin_names.iter().filter(|(_, &c)| c > 1).collect();+ if !dups.is_empty() && !dups.iter().all(|(n, _)| n.contains('@') || n.as_str() == "~") {+ let dup_list: Vec<_> = dups.iter().filter(|(n, _)| !n.contains('@') && n.as_str() != "~").map(|(n, c)| format!("{} (x{})", n, c)).collect();+ if !dup_list.is_empty() {+ // Duplicate pin names are valid in KiCad for power pins (DVDD, IOVDD, GND).+ // Warn but don't error — EAGLE import will need @N suffixes.+ eprintln!("WARN: Duplicate pin names (will need @N suffix for EAGLE): {}", dup_list.join(", "));+ }+ }++ if errors.is_empty() {+ println!("OK: {} validated — {} pins, {} unique pin names", path.display(), pin_count, pin_names.len());+ } else {+ eprintln!("ERROR: {} failed validation with {} error(s)", path.display(), errors.len());+ for e in &errors {+ eprintln!(" {}", e);+ }+ eprintln!("Hint: Fix the errors above, then re-run `adom-lbr check {}`", path.display());+ process::exit(2);+ }+ }++ Commands::ExportViewers { slug, dir, out, no_pin_table } => {+ let out_dir = out.unwrap_or_else(|| PathBuf::from("viewers"));+ if let Err(e) = manage::export_viewers(&slug, &dir, &out_dir, no_pin_table) {+ eprintln!("ERROR: {e}"); std::process::exit(1);+ }+ }+ Commands::Embed { dir, out } => {+ if let Err(e) = cmd_embed(&dir, out) {+ eprintln!("ERROR: {e:#}");+ process::exit(1);+ }+ }+ Commands::View { slug, dir, out, layout, panes, description, rollup, config } => {+ let (layout, panes_vec, description, rollup) =+ resolve_view_cfg(layout, panes, description, rollup, config.as_deref());+ if let Err(e) = manage::export_composed(&slug, &dir, out, &layout, &panes_vec, description, rollup) {+ eprintln!("ERROR: {e}");+ process::exit(1);+ }+ }+ Commands::Manage { port } => {+ if let Err(e) = manage::run(port) {+ eprintln!("ERROR: {e}");+ process::exit(1);+ }+ }+ Commands::Dashboard { cmd } => match cmd {+ DashboardCmd::Serve { port, print_url, foreground } => {+ // A foreign service on the default port moves us over rather+ // than killing the launch (John's box, 2026-08-25).+ let port = match dock::resolve_port(port) {+ Ok(p) => p,+ Err(e) => { eprintln!("ERROR: {e}"); process::exit(2); }+ };+ match dock::serve_entry(port, print_url, foreground) {+ Ok(true) => {}+ Ok(false) => {+ if let Err(e) = manage::run(port) {+ // Lost a bind race? If it is us after all, this launch still succeeded.+ if dock::running_version(port).is_some() {+ if print_url { println!("{}", dock::direct_url(port)); }+ return;+ }+ eprintln!("ERROR: adom-lbr dashboard cannot serve on port {port}: {e}. Something else owns it; pick another with --port.");+ process::exit(2);+ }+ }+ Err(e) => {+ eprintln!("ERROR: {e}");+ process::exit(2);+ }+ }+ }+ DashboardCmd::Status { port } => {+ let out = process::Command::new("curl").args(["-s", "--max-time", "3", &format!("http://127.0.0.1:{port}/api/status")]).output();+ match out {+ Ok(o) if o.status.success() && !o.stdout.is_empty() => println!("{}", String::from_utf8_lossy(&o.stdout)),+ _ => { eprintln!("ERROR: no adom-lbr dashboard answering on port {port}"); eprintln!("Hint: adom-lbr dashboard serve --print-url"); process::exit(2); }+ }+ }+ DashboardCmd::Say => {+ for p in dock::say_phrases() { println!("{p}"); }+ }+ },+ Commands::Serve { port, file } => {+ if let Some(f) = file {+ match fs::read_to_string(&f) {+ Ok(content) => server::set_state(server::analyze_lbr(&content)),+ Err(e) => {+ eprintln!("ERROR: Cannot read {}: {}", f.display(), e);+ process::exit(1);+ }+ }+ }+ if let Err(e) = server::run(port) {+ eprintln!("ERROR: {e:#}");+ process::exit(1);+ }+ }++ Commands::ImportKicad { sym, fp, mpn, out, pin_map } => {+ let sym_txt = read_or_exit(&sym);+ let fp_txt = read_or_exit(&fp);+ // Same pin-map convention as generate: explicit --pin-map wins, else+ // a pinmap.json sidecar beside the symbol just works.+ let pin_map_path = pin_map.or_else(|| {+ sym.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) => serde_json::from_str(&read_or_exit(path)).unwrap_or_else(|e| {+ eprintln!("ERROR: {} is not a JSON object of \"pin number\": \"pad name\": {}", path.display(), e);+ process::exit(1);+ }),+ None => Default::default(),+ };+ 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 (part, dropped) = altium_codec::import_kicad_with_pin_map(&sym_txt, &fp_txt, &mpn, &pin_map)+ .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });+ if !dropped.is_empty() {+ // A pin that matches no pad is a broken netlist in EVERY export+ // target — say so loudly instead of letting it vanish (adom-lbr#9).+ let referenced: std::collections::HashSet<&str> =+ part.connect.iter().map(|c| c.pad.as_str()).collect();+ let mut unref: Vec<&str> = part.footprint.pads.iter()+ .map(|p| p.number.as_str())+ .filter(|n| !n.is_empty() && !referenced.contains(n))+ .collect();+ unref.sort();+ unref.dedup();+ eprintln!("WARN: {} symbol pin(s) match no footprint pad and were left out of connect[]: {}",+ dropped.len(), dropped.join(", "));+ if !unref.is_empty() {+ eprintln!(" unreferenced pads in footprint: {} — if one of these is the intended target, supply --pin-map (or a pinmap.json sidecar)", unref.join(", "));+ }+ }+ let outp = out.unwrap_or_else(|| PathBuf::from(format!("{mpn}.adom-lbr.json")));+ fs::write(&outp, serde_json::to_string_pretty(&part).unwrap()).ok();+ 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());+ }++ Commands::ExportAltium { part, out_dir } => {+ let p = load_part(&part);+ let mut symbol = p.symbol.clone();+ seed_parameters(&mut symbol, &p);+ let sym_bytes = altium_codec::encode_schlib(&[symbol])+ .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });+ let fp_bytes = altium_codec::encode_pcblib(&p.footprint)+ .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();+ println!("OK: wrote {} ({} pins) + {} ({} pads)",+ sp.display(), p.symbol.pins.len(), pp.display(), p.footprint.pads.len());+ }++ Commands::ExportIntlib { part, out } => {+ let p = load_part(&part);+ let mut symbol = p.symbol.clone();+ seed_parameters(&mut symbol, &p);+ let descr = if p.value.is_empty() { p.mpn.clone() } else { format!("{} {}", p.value, p.package) };+ 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();+ println!("OK: wrote {} ({} bytes) — symbol + footprint bundled", outp.display(), bytes.len());+ }++ Commands::ExportKicad { part, out_dir } => {+ 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 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();+ println!("OK: wrote {} ({} pins) + {} ({} pads, {} graphics)",+ sp.display(), p.symbol.pins.len(), fp.display(),+ p.footprint.pads.len(), p.footprint.graphics.len());+ }++ Commands::AddToSchlib { existing, part, out } => {+ let existing_bytes = fs::read(&existing)+ .unwrap_or_else(|e| { eprintln!("ERROR: read {}: {e}", existing.display()); process::exit(1); });+ let json = read_or_exit(&part);+ let syms: Vec<altium_codec::Symbol> =+ if let Ok(p) = serde_json::from_str::<altium_codec::AdomLbrPart>(&json) {+ vec![p.symbol]+ } else if let Ok(v) = serde_json::from_str::<Vec<altium_codec::Symbol>>(&json) {+ v+ } else {+ vec![serde_json::from_str::<altium_codec::Symbol>(&json)+ .unwrap_or_else(|e| { eprintln!("ERROR: parse Symbol/AdomLbrPart: {e}"); process::exit(1); })]+ };+ 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();+ println!("OK: wrote {} — added {} symbol(s) into the library", outp.display(), syms.len());+ }++ Commands::ImportAltium { file, pcblib, schlib, mpn, out } => {+ let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();+ let read = |p: &std::path::Path| -> Vec<u8> {+ fs::read(p).unwrap_or_else(|e| { eprintln!("ERROR: read {}: {e}", p.display()); process::exit(1); })+ };+ let part = if ext == "intlib" {+ altium_codec::decode_intlib_part(&read(&file), &mpn)+ } else if ext == "pcblib" {+ let sch = schlib.as_ref().map(|p| read(p));+ altium_codec::decode_altium(sch.as_deref(), Some(&read(&file)), &mpn)+ } else {+ // treat as .SchLib; auto-find the .PcbLib next to it if not given+ let pcb_path = pcblib.or_else(|| {+ let cand = file.with_extension("PcbLib");+ cand.exists().then_some(cand)+ });+ let pcb = pcb_path.as_ref().map(|p| read(p));+ altium_codec::decode_altium(Some(&read(&file)), pcb.as_deref(), &mpn)+ }+ .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();+ println!("OK: wrote {} — {} pins, {} pads, {} footprint graphics",+ outp.display(), part.symbol.pins.len(), part.footprint.pads.len(), part.footprint.graphics.len());+ }++ 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());+ }++ Commands::Health => {+ println!("OK: adom-lbr v{}", VERSION.trim());+ }++ Commands::Completions { shell } => {+ use clap::CommandFactory;+ clap_complete::generate(shell, &mut Cli::command(), "adom-lbr", &mut std::io::stdout());+ }+ }+}++// ── `embed`: Fusion-style 3-column interactive HTML package ──++pub(crate) fn b64(data: &[u8]) -> String {+ const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+ let mut out = String::with_capacity(data.len().div_ceil(3) * 4);+ for ch in data.chunks(3) {+ let b = [ch[0], *ch.get(1).unwrap_or(&0), *ch.get(2).unwrap_or(&0)];+ let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;+ out.push(T[(n >> 18 & 63) as usize] as char);+ out.push(T[(n >> 12 & 63) as usize] as char);+ out.push(if ch.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });+ out.push(if ch.len() > 2 { T[(n & 63) as usize] as char } else { '=' });+ }+ out+}++fn esc_html(s: &str) -> String {+ s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)+}++/// (human label, hex color) for a ds2sf/kicad pin type token.+pub(crate) fn pin_type_style(t: &str) -> (&'static str, &'static str) {+ match t.to_lowercase().replace('-', "_").as_str() {+ "power_in" | "power_out" | "power" => ("Power", "#ef5350"),+ "input" | "in" => ("Input", "#26c6da"),+ "output" | "out" => ("Output", "#66bb6a"),+ "bidirectional" | "bidi" | "io" => ("Bidir", "#ab47bc"),+ "open_collector" | "open_emitter" => ("Open", "#ffa726"),+ "tri_state" => ("Tri-state", "#ffa726"),+ "passive" => ("Passive", "#90a4ae"),+ "unspecified" | "" => ("—", "#90a4ae"),+ "no_connect" | "nc" | "unconnected" => ("NC", "#5b6673"),+ _ => ("Signal", "#80cbc4"),+ }+}++/// Resolve the `View` layout config. A `--config page.json` with an `lbrView`+/// block fully determines layout/panes/paneOpts (overriding the flags); otherwise+/// the flags are used. Returns (layout, panes, pins.description, pins.rollup) where+/// the two Options are None when the caller should derive the layout default.+fn resolve_view_cfg(+ layout: String,+ panes: Option<String>,+ description: Option<bool>,+ rollup: Option<bool>,+ config: Option<&std::path::Path>,+) -> (String, Vec<String>, Option<bool>, Option<bool>) {+ if let Some(cfgpath) = config {+ if let Ok(txt) = fs::read_to_string(cfgpath) {+ if let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) {+ if let Some(lv) = v.get("lbrView") {+ let layout = lv.get("layout").and_then(|x| x.as_str()).unwrap_or(&layout).to_string();+ let panes: Vec<String> = lv.get("panes").and_then(|x| x.as_array())+ .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())+ .unwrap_or_default();+ let pins = lv.get("paneOpts").and_then(|x| x.get("pins"));+ let desc = pins.and_then(|p| p.get("description")).and_then(|x| x.as_bool());+ let roll = pins.and_then(|p| p.get("rollup")).and_then(|x| x.as_bool());+ return (layout, panes, desc, roll);+ }+ }+ }+ eprintln!("WARN: --config {} has no usable `lbrView` block; using flags", cfgpath.display());+ }+ let panes_vec = panes+ .map(|s| s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect())+ .unwrap_or_default();+ (layout, panes_vec, description, rollup)+}++fn read_first(dir: &std::path::Path, names: &[String]) -> Option<String> {+ for n in names {+ if let Ok(s) = fs::read_to_string(dir.join(n)) {+ return Some(s);+ }+ }+ None+}++/// Find the directory's model, transcode Draco away when present, and emit a+/// self-contained interactive 3D pane (inlined model-viewer + data-URI GLB ++/// the static iso render as the poster). Err(reason) falls back to the PNG.+fn build_3d_viewer(dir: &std::path::Path, mpn: &str) -> Result<String, String> {+ // prefer <MPN>.glb (any case), else the first .glb in the directory+ let mut glb_path: Option<PathBuf> = None;+ if let Ok(rd) = fs::read_dir(dir) {+ let mut all: Vec<PathBuf> = rd.flatten().map(|e| e.path())+ .filter(|p| p.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("glb")).unwrap_or(false))+ .collect();+ all.sort();+ glb_path = all.iter().find(|p| {+ p.file_stem().and_then(|s| s.to_str()).map(|s| s.eq_ignore_ascii_case(mpn)).unwrap_or(false)+ }).cloned().or_else(|| all.first().cloned());+ }+ let glb_path = glb_path.ok_or("no .glb in this directory")?;+ let mut bytes = fs::read(&glb_path).map_err(|e| format!("read {}: {e}", glb_path.display()))?;+ let orig_len = bytes.len();++ if glb_is_draco(&bytes) {+ let tool = ensure_gltf_transform()?;+ let out_tmp = std::env::temp_dir().join(format!("adom-lbr-{mpn}-quantized.glb"));+ let st = process::Command::new(&tool)+ .args(["quantize", glb_path.to_str().unwrap_or_default(), out_tmp.to_str().unwrap_or_default()])+ .output()+ .map_err(|e| format!("gltf-transform did not run: {e}"))?;+ if !st.status.success() {+ return Err(format!("gltf-transform quantize failed: {}", String::from_utf8_lossy(&st.stderr).lines().last().unwrap_or("")));+ }+ bytes = fs::read(&out_tmp).map_err(|e| format!("read transcode output: {e}"))?;+ if glb_is_draco(&bytes) {+ return Err("transcode output still requires the Draco decoder".into());+ }+ println!("HINT: 3D model transcoded Draco -> quantized for the sandbox: {} -> {} bytes", orig_len, bytes.len());+ }++ let viewer_js = ensure_model_viewer()?;+ // STEP-sourced GLBs are usually Z-up while glTF is Y-up, so a chip stands+ // on its edge (barrett's PR #46 catch). Chips are flat: whichever bbox+ // axis is thinnest is the height. Thinnest on Z => Z-up model => pitch it+ // -90deg; thinnest on Y => already Y-up, leave it alone.+ let orientation = match glb_flat_axis(&bytes) {+ Some('z') => " orientation=\"0deg -90deg 0deg\"",+ _ => "",+ };+ let poster = ["-3d-iso-named.png", "-3d-iso.png", "-3d-iso-lg.png"]+ .iter().map(|s| dir.join(format!("{mpn}{s}"))).find(|p| p.exists())+ .and_then(|p| fs::read(&p).ok())+ .map(|b| format!(" poster=\"data:image/png;base64,{}\"", b64(&b)))+ .unwrap_or_default();+ Ok(format!(+ "<script type=\"module\">{viewer_js}</script>\n\+ <model-viewer src=\"data:model/gltf-binary;base64,{model}\"{poster}{orientation} camera-controls auto-rotate shadow-intensity=\"1\" style=\"position:absolute;inset:0;width:100%;height:100%;background:#161c27\"></model-viewer>",+ model = b64(&bytes)+ ))+}++/// Which axis is the model THINNEST along, from the union of the JSON chunk's+/// VEC3 float accessor min/max bounds. For chip-shaped parts the thinnest axis+/// is the height, which tells us whether the model is Y-up or Z-up.+fn glb_flat_axis(bytes: &[u8]) -> Option<char> {+ if bytes.len() < 20 || &bytes[0..4] != b"glTF" {+ return None;+ }+ let json_len = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;+ let end = (20 + json_len).min(bytes.len());+ let v: serde_json::Value = serde_json::from_slice(&bytes[20..end]).ok()?;+ let (mut lo, mut hi) = ([f64::MAX; 3], [f64::MIN; 3]);+ let mut seen = false;+ for acc in v.get("accessors")?.as_array()? {+ // quantized meshes store positions as ints (KHR_mesh_quantization), so+ // accept ANY VEC3 accessor that carries min/max bounds+ if acc.get("type").and_then(|t| t.as_str()) != Some("VEC3") {+ continue;+ }+ let (Some(mn), Some(mx)) = (acc.get("min").and_then(|m| m.as_array()), acc.get("max").and_then(|m| m.as_array())) else { continue };+ if mn.len() != 3 || mx.len() != 3 { continue; }+ for i in 0..3 {+ lo[i] = lo[i].min(mn[i].as_f64().unwrap_or(0.0));+ hi[i] = hi[i].max(mx[i].as_f64().unwrap_or(0.0));+ }+ seen = true;+ }+ if !seen { return None; }+ let ext = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];+ let thin = if ext[0] <= ext[1] && ext[0] <= ext[2] { 'x' } else if ext[1] <= ext[2] { 'y' } else { 'z' };+ // only meaningful when the part is clearly flat (height under half the next axis)+ let mut sorted = ext; sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());+ if sorted[0] > sorted[1] * 0.6 { return None; }+ Some(thin)+}++/// GLB magic + JSON chunk scan for the Draco extension requirement.+fn glb_is_draco(bytes: &[u8]) -> bool {+ if bytes.len() < 20 || &bytes[0..4] != b"glTF" {+ return false;+ }+ let json_len = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;+ let end = (20 + json_len).min(bytes.len());+ let json = String::from_utf8_lossy(&bytes[20..end]);+ json.contains("KHR_draco_mesh_compression")+}++/// The gltf-transform CLI, installed once into the adom-lbr cache.+fn ensure_gltf_transform() -> Result<PathBuf, String> {+ let home = std::env::var("HOME").map_err(|_| "no HOME")?;+ let prefix = std::path::Path::new(&home).join(".cache/adom-lbr/gltf");+ let bin = prefix.join("node_modules/.bin/gltf-transform");+ if bin.exists() {+ return Ok(bin);+ }+ process::Command::new("node").arg("-v").output().map_err(|_| "node is not installed (needed once to transcode a Draco .glb)")?;+ println!("HINT: fetching @gltf-transform/cli into {} (once)…", prefix.display());+ let _ = fs::create_dir_all(&prefix);+ let st = process::Command::new("npm")+ .args(["install", "--prefix", prefix.to_str().unwrap_or_default(), "--no-fund", "--no-audit", "@gltf-transform/cli@3", "draco3dgltf"])+ .output()+ .map_err(|e| format!("npm did not run: {e}"))?;+ if !st.status.success() || !bin.exists() {+ return Err(format!("npm install @gltf-transform/cli@3 failed (v3 pinned: v4 needs Node 20+): {}", String::from_utf8_lossy(&st.stderr).lines().last().unwrap_or("")));+ }+ Ok(bin)+}++/// The model-viewer bundle, fetched once into the cache and inlined thereafter+/// (the readme sandbox may fetch nothing at view time, so it must ride along).+fn ensure_model_viewer() -> Result<String, String> {+ let home = std::env::var("HOME").map_err(|_| "no HOME")?;+ let path = std::path::Path::new(&home).join(".cache/adom-lbr/model-viewer-3.5.0.min.js");+ if !path.exists() {+ let _ = fs::create_dir_all(path.parent().unwrap());+ let st = process::Command::new("curl")+ .args(["-sL", "--max-time", "60", "-o", path.to_str().unwrap_or_default(),+ "https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"])+ .status()+ .map_err(|e| format!("curl did not run: {e}"))?;+ if !st.success() {+ return Err("could not fetch the model-viewer bundle (offline?); the static render stands in".into());+ }+ }+ let js = fs::read_to_string(&path).map_err(|e| format!("read model-viewer bundle: {e}"))?;+ if js.len() < 100_000 {+ let _ = fs::remove_file(&path);+ return Err("model-viewer bundle looks truncated; refetch next run".into());+ }+ Ok(js)+}++fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String> {+ let mpn = dir.file_name().and_then(|s| s.to_str()).ok_or("bad --dir")?.to_string();+ // renders+ let sym_svg = read_first(dir, &[format!("{mpn}-symbol.svg")])+ .map(|s| s)+ .unwrap_or_else(|| "<div class=\"ph\">no symbol render in this directory</div>".into());+ let fp_svg = read_first(dir, &[format!("{mpn}-footprint.authoritative.svg"), format!("{mpn}-footprint.svg")])+ .unwrap_or_else(|| "<div class=\"ph\">no footprint render in this directory</div>".into());+ // The 3D column: a real interactive viewer when the directory has a .glb+ // (adom-lbr#4, barrett's design carried forward). The model rides INLINE as+ // a data URI and the model-viewer bundle is inlined too, because the wiki+ // serves readmes with connect-src 'none': nothing may be fetched at view+ // time. Draco models are transcoded to KHR_mesh_quantization at build time+ // (three.js decodes that natively; the Draco decoder is a CDN fetch the+ // sandbox blocks - and quantized is measured ~40% smaller anyway).+ let static_3d = || {+ let cands = ["-3d-iso-named.png", "-3d-iso.png", "-3d-iso-lg.png"]+ .iter().map(|s| dir.join(format!("{mpn}{s}"))).find(|p| p.exists());+ match cands.and_then(|p| fs::read(&p).ok()) {+ Some(bytes) => format!("<img src=\"data:image/png;base64,{}\">", b64(&bytes)),+ None => "<div class=\"ph\">no 3D render in this directory</div>".into(),+ }+ };+ let threed = match build_3d_viewer(dir, &mpn) {+ Ok(html) => html,+ Err(reason) => {+ eprintln!("HINT: 3D column falls back to the static render: {reason}");+ static_3d()+ }+ };+ // optional pin→pad connects from a .lbr+ let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();+ if let Some(lbr) = read_first(dir, &[format!("{mpn}.lbr")]) {+ if let Ok(re) = regex_lite::Regex::new(r#"<connect gate="[^"]*" pin="([^"]+)" pad="([^"]+)"/>"#) {+ for cap in re.captures_iter(&lbr) {+ pin_to_pad.insert(cap[1].to_string(), cap[2].to_string());+ }+ }+ }+ // pins + descriptions from the extracted symbol JSON+ let mut pins_json = String::from("[]");+ let mut pad_count = fp_svg.matches("adom-pad").count().max(0);+ if let Some(ext) = read_first(dir, &[format!("{mpn}-symbol.extracted.json")]) {+ if let Ok(v) = serde_json::from_str::<serde_json::Value>(&ext) {+ if let Some(arr) = v.get("pins").and_then(|p| p.as_array()) {+ let rows: Vec<serde_json::Value> = arr.iter().map(|p| {+ let num = p.get("number").and_then(|x| x.as_str()).unwrap_or("").to_string();+ let raw_t = p.get("type").and_then(|x| x.as_str()).unwrap_or("");+ let (label, color) = pin_type_style(raw_t);+ let pad = pin_to_pad.get(p.get("name").and_then(|x| x.as_str()).unwrap_or(""))+ .cloned().unwrap_or_else(|| num.clone());+ serde_json::json!({+ "number": num,+ "name": p.get("name").and_then(|x| x.as_str()).unwrap_or(""),+ "pad": pad,+ "type": label,+ "color": color,+ "desc": p.get("description").and_then(|x| x.as_str()).unwrap_or(""),+ })+ }).collect();+ pad_count = pad_count.max(rows.len());+ pins_json = serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into());+ }+ }+ }+ let pin_count = serde_json::from_str::<serde_json::Value>(&pins_json).ok()+ .and_then(|v| v.as_array().map(|a| a.len())).unwrap_or(0);++ let html = include_str!("embed.html")+ .replace("__NAME__", &esc_html(&mpn))+ .replace("__SYM__", &sym_svg)+ .replace("__FP__", &fp_svg)+ .replace("__THREED__", &threed)+ .replace("__PINS__", &pins_json)+ .replace("__PADN__", &pad_count.to_string())+ .replace("__PINN__", &pin_count.to_string());+ let out = out.unwrap_or_else(|| dir.join(format!("{mpn}-lbr-embed.html")));+ fs::write(&out, &html).map_err(|e| format!("writing {}: {e}", out.display()))?;+ println!("OK: 3-column interactive embed ({pin_count} pins) → {}", out.display());+ Ok(())+}++#[cfg(test)]+mod lint_tests {+ use super::{dedup_pad_names, lint_lbr};++ #[test]+ fn four_sided_symbol_relays_into_two_columns() {+ use super::{relayout_two_columns, KicadPin, ParsedSymbol};+ let pin = |name: &str, number: &str, pin_type: &str, angle: u32| KicadPin {+ name: name.into(), number: number.into(), pin_type: pin_type.into(),+ x: 0.0, y: 0.0, angle, length: 2.54,+ };+ let mut sym = ParsedSymbol {+ name: "U1".into(), reference: "U".into(), value: String::new(),+ footprint: String::new(), description: String::new(),+ pins: vec![+ pin("OUT", "1", "output", 180),+ pin("GND", "2", "power_in", 90), // bottom pin: the package-mirror tell+ pin("IN", "3", "input", 0),+ pin("VDD", "4", "power_in", 270), // top pin+ ],+ body: None, group_labels: vec![], graphics: vec![],+ };+ assert!(relayout_two_columns(&mut sym, false));+ for p in &sym.pins {+ assert!(p.angle == 0 || p.angle == 180, "no rotated (top/bottom) pins survive: {} at {}", p.name, p.angle);+ }+ let body = sym.body.as_ref().unwrap();+ let by = |n: &str| sym.pins.iter().find(|p| p.name == n).unwrap();+ assert!(by("IN").x < body.left && by("IN").angle == 0, "inputs on the LEFT");+ assert!(by("OUT").x > body.right && by("OUT").angle == 180, "outputs on the RIGHT");+ assert!(by("GND").x < body.left && by("VDD").x < body.left, "power grouped LEFT");+ assert!(by("GND").y < by("IN").y, "power below the signal pins");+ // a two-column source stays untouched under auto+ let mut two_col = ParsedSymbol {+ pins: vec![pin("IN", "1", "input", 0), pin("OUT", "2", "output", 180)],+ ..sym+ };+ assert!(!relayout_two_columns(&mut two_col, false), "auto must not touch a left/right symbol");+ }++ #[test]+ fn pad_dedup_uses_the_eagle_at_suffix_convention() {+ let names: Vec<String> = ["17", "18", "17", "SH", "17"].iter().map(|s| s.to_string()).collect();+ let (out, groups) = dedup_pad_names(&names);+ assert_eq!(out, vec!["17", "18", "17@2", "SH", "17@3"]);+ assert_eq!(groups["17"], vec!["17", "17@2", "17@3"]);+ assert_eq!(groups["18"], vec!["18"]);+ }++ /// 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 duplicate_pad_names_fail_the_lint() {+ // KiCad same-net arrays (thermal vias, shield tabs) share one pad name;+ // Fusion refuses the entire library over the repeat (adom/adom-lbr#10)+ let r = lint_lbr(&lbr("SH", r#"<smd name="SH" x="4" y="0"/><smd name="SH" x="5" y="0"/>"#));+ assert!(!r.passed);+ let e = r.errors.iter().find(|e| e.code == "duplicate-pad-name").expect("duplicate-pad-name");+ assert!(e.message.contains("'SH'") && e.message.contains("3 times"), "{}", e.message);+ assert_eq!(r.errors.iter().filter(|e| e.code == "duplicate-pad-name").count(), 1,+ "one repeated name reports once, not once per extra pad");+ }++ #[test]+ fn suffixed_pad_group_with_multi_pad_connect_passes() {+ // the shape generate now emits for a same-net array: NAME, NAME@2, ...+ // and one connect naming the whole group, space-separated+ let r = lint_lbr(&lbr("SH SH@2 SH@3",+ r#"<smd name="SH@2" x="4" y="0"/><smd name="SH@3" x="5" y="0"/>"#));+ assert!(r.errors.iter().all(|e| e.code != "duplicate-pad-name" && e.code != "connect-pad-unresolved"),+ "@-suffixed group + multi-pad connect is the sanctioned form: {:?}", r.errors);+ }++ #[test]+ fn multi_pad_connect_with_a_missing_pad_still_fails() {+ // each name in a space-separated pad group must resolve individually+ let r = lint_lbr(&lbr("SH SH@2", ""));+ let e = r.errors.iter().find(|e| e.code == "connect-pad-unresolved").expect("connect-pad-unresolved");+ assert!(e.message.contains("\"SH@2\""), "names the missing group member: {}", e.message);+ }++ #[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);+ }+}+@@ -1,1596 +1,1597 @@⋯ 1243 unchanged lines ⋯ let lbr = match crate::parse_kicad_sym(&sym_txt, Some(&mpn)) { Ok(mut parsed) => { crate::relayout_two_columns(&mut parsed, false);- Some(crate::generate_lbr(&parsed, Some(&fp_txt), None, 0.38, &pin_map))+ Some(crate::generate_lbr(&parsed, Some(&fp_txt), None, 0.38, &pin_map, &[])) } Err(_) => None, };⋯ 343 unchanged lines ⋯ panes.join("+"), rows.len(), has3d, out.display()); Ok(()) }+@@ -1,1 +1,2 @@-2.17.1+2.18.0+@@ -1,28 +1,29 @@ [package] name = "adom-lbr"-version = "2.16.0"+version = "2.18.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." ⋯ 19 unchanged lines ⋯ opt-level = "z" lto = true strip = true+@@ -1,85 +1,86 @@ { "slug": "adom-lbr",- "version": "2.16.0",+ "version": "2.18.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": {},⋯ 76 unchanged lines ⋯ } ] }+
Comments
Log in to comment.