app
Adom Library
Public Made by Adomby adom
adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.
← All Pull Requests
2.46.3: TO-92 pad clearance (copper, not boxes) and rect thru pads on the short side (fixes #27) #7
Merged opened by Ray 2026-09-23
Fixes #27: every TO-92 part failed pad-clearance and dropped out of the component factory.
lint_lbrpad clearance. A round pad against a rectangular one is measured as copper. When the circle sits off the rectangle's corner, the gap is the circle-to-corner distance; beside an edge or inside, the box gap is already exact and is kept. KiCad's staggered TO-92 has 0.227 mm of real clearance and no longer reads as "OVERLAP by 0.030 mm".tht_pad_geometry("rect", ...). A rect thru pad is never drawn square on its LONG side, which madeTO-92_Inlinepad 1 (1.05 × 1.5 mm at 1.27 mm pitch) a 1.5 mm square that overlapped pad 2. Elongated rect pads becomelongon the short side along their length, with a warning that the square pin-1 corner is lost. Squarish ones become square on the short side. That's the ruleroundrectalready follows (wiki #19).- Version 2.46.3:
VERSION,Cargo.toml,Cargo.lockandpackage.json, plus the CHANGELOG entry.
Verified
cargo test --release: 28/28. The new lint test fails when the bounding-box code is restored.- KiCad
Package_TO_SOT_THT:TO-92*: 8/11 failvalidateon 2.46.2, 0/11 on 2.46.3. - 90 random THT footprints with rect pads: 89 unchanged, 1 newly passing, 0 newly failing.
GitHub adom-inc/adom-lbr is at 2.42.0 (last push 09-15), four minor versions behind this page. I haven't touched it; reconciling it is Colby's call.
Diff Skip to comments
@@ -1,4815 +1,4886 @@⋯ 1262 unchanged lines ⋯ let rot = if along_y { 90.0 } else { 0.0 }; match kicad_shape { "circle" => ("round".into(), dx, 0.0, None),- "rect" => ("square".into(), long, 0.0, None),- "oval" => {- // EAGLE's 2:1 is fixed, so the drawn length is 2 * short whatever the source- // said. Worth stating when that overshoots by a visible margin rather than- // letting the pad silently grow.- let drawn = 2.0 * short;- let warn = if (drawn - long).abs() > 0.3 {- Some(format!("EAGLE draws a long pad at a fixed 2:1, so this {short:.2} x {long:.2} mm pad is drawn {short:.2} x {drawn:.2} mm"))- } else { None };- ("long".into(), short, rot, warn)- }- "roundrect" | "custom" => {- // No roundness exists on a thru pad. An elongated one is closer to `long`; a- // squarish one becomes round on its SHORT side, and says that it lost the rest,- // because rounding on the LONG side is what grew a pad into its neighbour.- if aspect > 1.2 {- let drawn = 2.0 * short;- let warn = if (drawn - long).abs() > 0.3 {- Some(format!("roundrect thru pad {short:.2} x {long:.2} mm has no EAGLE equivalent; drawn as a long pad {short:.2} x {drawn:.2} mm"))- } else { None };- ("long".into(), short, rot, warn)- } else {- ("round".into(), short, 0.0,- Some(format!("roundrect thru pad {short:.2} x {long:.2} mm drawn round at {short:.2} mm; the long side has no EAGLE equivalent on a thru pad")))- }- }- _ => ("round".into(), short, 0.0, None),- }-}--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()-}--/// One extra PACKAGE of the same part: the same silicon in a different footprint, or the-/// same footprint with extra geometry (heatsink vias as through pads, solder-ball outlines).-/// EAGLE models this as another `<device>` inside the SAME deviceset, which is why Fusion's-/// CHANGE PACKAGE swaps between them without re-wiring the schematic.-#[derive(Clone)]-pub(crate) struct PackageVariant {- /// Device-name suffix, e.g. "-HV". The baseline device keeps the empty name.- pub suffix: String,- pub km: String,- pub description: Option<String>,-}--/// Emit one `<package>` from a `.kicad_mod`, returning its pad groups for the connects.-/// This mirrors the baseline package branch rather than refactoring it: the baseline path-/// is exercised by every part we ship and is not worth destabilising for this.-fn emit_variant_package(xml: &mut String, km: &str, name: &str, description: &str)- -> std::collections::HashMap<String, Vec<String>> {- let pads = parse_mod_pads(km);- xml.push_str(&format!("<package name=\"{}\">\n", esc_xml(name)));- xml.push_str(&format!(" <description>{}</description>\n", esc_xml(description)));- let pad_names: Vec<String> = pads.iter().map(|p| p.name.clone()).collect();- let (out_names, groups) = dedup_pad_names(&pad_names);- for (i, p) in 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),- p.roundness.map(|r| format!(" roundness=\"{r}\"")).unwrap_or_default()));- } else {- let drill = p.drill.unwrap_or_else(|| p.dx.min(p.dy) * 0.6);- let (tht_shape, diameter, extra_rot, warn) = tht_pad_geometry(&p.kicad_shape, p.dx, p.dy);- if let Some(w) = warn { eprintln!("WARN: pad {}: {}", p.name, w); }- 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, tht_shape, smd_rotation(p.angle + extra_rot)));- }- }- xml.push_str(&package_graphics(km));- xml.push_str("</package>\n");- groups-}--/// Which BASELINE pad an extra variant pad belongs to, by geometry: a heatsink via sits-/// inside the thermal pad it vents, so the pad that CONTAINS its centre is the one whose-/// net it joins. Naming cannot decide this ("V1" says nothing about which pin it serves)-/// and a footprint on its own carries no nets, so position is the only honest signal.-fn owning_pad<'a>(extra: &ModPad, baseline: &'a [ModPad]) -> Option<&'a ModPad> {- baseline.iter().find(|b| {- (extra.x - b.x).abs() <= b.dx / 2.0 && (extra.y - b.y).abs() <= b.dy / 2.0- })-}--/// 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),- // USER LAYERS. KiCad's User.N and the named Adom.* layers carry real geometry that- // was being dropped on the floor: the LAN7800 footprint lost 296 solder-ball circles- // that way, silently, because an unmapped layer returned None. EAGLE reserves 1..99- // and leaves 100..255 for user layers, so they are mapped there and DECLARED in the- // <layers> block, since Fusion ignores geometry on a layer the file never declares.- "Adom.SolderBalls" | "User.9" => Some(101),- "Adom.HeatsinkVias" => Some(102),- other => other.strip_prefix("User.")- .and_then(|n| n.parse::<u32>().ok())- .filter(|n| *n <= 145)- .map(|n| 110 + n),- }-}--/// Which user layers this footprint actually draws on.-fn used_user_layers(km: &str) -> std::collections::BTreeSet<u32> {- let re = regex_lite::Regex::new(r#"\(layer "?([A-Za-z0-9_.]+)"?\)"#).unwrap();- re.captures_iter(km)- .filter_map(|c| kicad_layer_to_eagle(&c[1]))- .filter(|n| *n >= 100)- .collect()-}--/// The EAGLE `<layer>` declarations for whatever user layers a package actually draws on.-/// Only layers in use are declared: a file full of empty layer declarations is noise, and-/// Fusion lists every declared layer in its layer dialog whether or not anything is on it.-fn user_layer_decls(used: &std::collections::BTreeSet<u32>) -> String {- let mut out = String::new();- for n in used.iter().filter(|n| **n >= 100) {- let name = match n {- 101 => "SolderBalls".to_string(),- 102 => "HeatsinkVias".to_string(),- other => format!("User{}", other - 110),- };- // colour 13 is EAGLE's light-grey user default; fill 1 is solid, matching how the- // silk layers are declared above.- out.push_str(&format!(- "<layer number=\"{}\" name=\"{}\" color=\"13\" fill=\"1\" visible=\"yes\" active=\"yes\"/>\n",- n, name));- }- out-}--/// 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.-/// What adom-symbol states about a symbol's interior, all in kicad-symbol-local mm, Y-up,-/// the same space as the pins. adom-symbol is AUTHORITATIVE for these: it is the tool that-/// fits the icon and writes the KiCad Reference/Value properties, so taking its numbers is-/// what makes the KiCad render and the Fusion render agree pin for pin and label for label.-/// Two tools computing the same rectangle from different font models will always disagree-/// slightly, and the disagreement is invisible until someone measures it.-#[derive(Debug, Clone, Default)]-struct ContractLayout {- /// Where the icon was fitted, already clear of the inside designators. When present it- /// is honoured as given: re-centring it here would undo that fitting.- icon_rect: Option<(f64, f64, f64, f64)>,- /// Stroke width adom-symbol suggests for the icon, in mm. None means it did not say.- stroke_mm: Option<f64>,- /// Text CENTRES for >NAME and >VALUE. None means adom-symbol hid that designator.- reference: Option<(f64, f64)>,- value: Option<(f64, f64)>,- inside_body: bool,-}--enum SymbolArt {- Contract { mm_svg: String, dx: f64, svg_cy: f64, e_cy: f64, layout: ContractLayout },- 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"),- ) {- // adom-symbol 1.1.12 and later also state the interior it laid out.- let arr4 = |k: &str| -> Option<(f64, f64, f64, f64)> {- let a = v.get(k)?.as_array()?;- if a.len() < 4 { return None; }- Some((a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?, a[3].as_f64()?))- };- let pt = |k: &str| -> Option<(f64, f64)> {- let a = v.get("designators")?.get(k)?.as_array()?;- if a.len() < 2 { return None; }- Some((a[0].as_f64()?, a[1].as_f64()?))- };- let layout = ContractLayout {- icon_rect: arr4("icon_rect"),- stroke_mm: v.get("stroke_mm").and_then(|n| n.as_f64()),- reference: pt("reference"),- value: pt("value"),- inside_body: v.get("designators").and_then(|d| d.get("inside_body"))- .and_then(|b| b.as_bool()).unwrap_or(false),- };- return Ok(SymbolArt::Contract { mm_svg: content, dx: svg_cx - e_cx, svg_cy, e_cy, layout });- }- }- }- 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)`.-/// CENTRED ON THE BODY. The contract places art in exact symbol mm-space, which is right-/// when the symbol is drawn around the origin and wrong the moment it is not: measured on-/// the CH340G variant set, the body centres sit at y = 0, -4.61, -7.79, -20.05 and -27.86-/// depending on the layout, while the art stayed at the origin every time. The thumbnail-/// ended up jammed against the top edge of the taller symbols, overlapping the group-/// labels, with the middle of the symbol left empty. So the art keeps its SIZE and shape-/// from the contract and is translated as a whole onto the body's centre.-fn contract_wires(mm_svg: &str, dx: f64, svg_cy: f64, e_cy: f64, body: Option<&KicadBody>, stroke_mm: Option<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(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); } }- if pts.len() >= 2 { polys.push(pts); }- }-- // Translate the whole drawing so its bounding box sits on the body's centre. Shape and- // size are untouched; only where it sits changes.- let (mut ox, mut oy) = (0.0f64, 0.0f64);- if let Some(b) = body {- let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);- for p in &polys { for &(x, y) in p { x0 = x0.min(x); y0 = y0.min(y); x1 = x1.max(x); y1 = y1.max(y); } }- if x0 <= x1 {- ox = (b.left + b.right) / 2.0 - (x0 + x1) / 2.0;- oy = (b.top + b.bottom) / 2.0 - (y0 + y1) / 2.0;- }- }-- // STROKE SCALES WITH THE ICON. A fixed 0.1016 mm is fine on a large icon and turns a- // small one into a solid blob: measured on CH340G-2C-CHIP, the fitted icon is 3.7 x 2.4- // mm with 343 segments whose median length is 0.090 mm, so 89% of them are SHORTER than- // the line is wide and the drawing fills in. adom-symbol's suggested stroke_mm wins when- // the contract carries one; otherwise the same rule of thumb from the icon's own width.- // EAGLE treats 0 as a hairline, so that is the floor.- let icon_w = {- let (mut x0, mut x1) = (f64::MAX, f64::MIN);- for p in &polys { for &(x, _) in p { x0 = x0.min(x); x1 = x1.max(x); } }- if x0 <= x1 { x1 - x0 } else { 0.0 }- };- let stroke = stroke_mm.unwrap_or_else(|| (icon_w / 120.0).max(0.0508)).max(0.0);- let mut out = String::new();- let mut segs = 0usize;- for pts in &polys {- for w in pts.windows(2) {- out.push_str(&format!(" <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"{:.4}\" layer=\"94\"/>\n",- w[0].0 + ox, w[0].1 + oy, w[1].0 + ox, w[1].1 + oy, stroke));- 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);- // Stroke scales with the drawn size, same rule as the contract path: a fixed width on a- // small icon draws segments wider than they are long and the whole thing fills in.- let stroke = ((sw * scale) / 120.0).max(0.0508);- 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=\"{:.4}\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1, stroke));- 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, layout } => {- // A contract that states where the icon goes has already fitted it clear of the- // inside designators. Re-centring it on the body would undo exactly that.- let recentre = if layout.icon_rect.is_some() { None } else { body };- contract_wires(mm_svg, *dx, *svg_cy, *e_cy, recentre, layout.stroke_mm)- }- SymbolArt::Legacy(svg) => symbol_art_wires(svg, body, scale_frac),- }-}--/// KiCad's stock symbol libraries name a symbol after its CLASS ("R", "C", "L", "D",-/// "LED", "Crystal_GND24", "Conn_01x02"), because they describe a kind of part rather than-/// a specific one. That name is right for a symbol and wrong for a DEVICESET: a user who-/// opens a library and sees "L" cannot tell which inductor it is.-fn is_generic_part_name(name: &str) -> bool {- let n = name.trim();- const EXACT: [&str; 14] = ["R", "C", "L", "D", "Q", "Y", "U", "J", "K", "LED", "FB",- "Fuse", "Crystal", "Jumper"];- if EXACT.iter().any(|e| e.eq_ignore_ascii_case(n)) { return true; }- const PREFIX: [&str; 12] = ["Conn_", "Crystal", "R_", "C_", "L_", "D_", "LED_", "Q_",- "Fuse_", "Ferrite", "Jumper_", "TestPoint"];- PREFIX.iter().any(|p| n.len() > p.len() && n.to_lowercase().starts_with(&p.to_lowercase()))-}--/// A description that names a CLASS of part rather than the part. KiCad's stock libraries-/// ship these, so a converted library shows "Inductor" or "Unpolarized capacitor" where the-/// wiki page's brief says which inductor and which capacitor. When both exist, the brief is-/// the one worth carrying into Fusion.-fn is_generic_description(text: &str) -> bool {- let t = text.trim().to_lowercase();- if t.is_empty() { return true; }- if t.contains("script generated") || t.contains("kicad-library-utils") { return true; }- const CLASSES: [&str; 22] = [- "resistor", "capacitor", "unpolarized capacitor", "polarized capacitor",- "inductor", "ferrite bead", "diode", "light emitting diode", "led",- "zener diode", "schottky diode", "crystal", "oscillator", "transistor",- "npn transistor", "pnp transistor", "mosfet", "jfet", "fuse", "jumper",- "test point", "connector",- ];- if CLASSES.iter().any(|c| t == *c) { return true; }- // "Four pin crystal, GND on pins 2 and 4" and "Generic connector, single row, 01x02"- t.starts_with("generic connector")- || t.starts_with("generic ")- || (t.contains("crystal") && t.contains("pin") && t.len() < 60)-}--/// Catalogue boilerplate that belongs on a wiki PAGE, not in a part's description. A page-/// brief is written for a reader browsing the library; the same sentence repeated in every-/// Fusion Description cell is noise that pushes the real text out of a narrow column.-fn strip_catalog_boilerplate(text: &str) -> String {- // LONGEST FIRST: "Datasheet-verified pinout" has to go before "Datasheet-verified",- // or the short form eats its prefix and strands the word "pinout" on its own.- const NOISE: [&str; 7] = [- "Datasheet-verified pinout",- "KiCad symbol/footprint/3D",- "Symbol, footprint and 3D model",- "Generated by adom",- "Verified pinout",- "Datasheet-verified",- "Datasheet verified",- ];- // Remove the boilerplate PHRASES in place. An earlier version split the text on every- // '.' and rejoined it, which turned "2.5V precision reference" into "2. 5V precision- // reference": a part number or a voltage is full of periods that are not sentence ends.- let mut out = text.to_string();- for n in NOISE {- while let Some(i) = out.find(n) {- // take the phrase plus any trailing separator it brought with it- let mut j = i + n.len();- let bytes = out.as_bytes();- while j < bytes.len() && matches!(bytes[j], b'.' | b',' | b';' | b' ' | b'\n') { j += 1; }- out.replace_range(i..j, "");- }- }- let out = out.trim().trim_end_matches(&['.', ',', ';', ' '][..]).trim().to_string();- if out.is_empty() { text.trim().to_string() } else { out }-}--/// A human line for the PACKAGE, GEOMETRY FIRST.-///-/// Fusion's Content Manager shows about 20 characters of this column before it truncates-/// with an ellipsis (the full text is on hover), measured by the fusion-bridge thread. So-/// the first words have to be the ones that identify the package. A manufacturer-first-/// `(descr …)` truncates to "Walsin Technology C…", which identifies nothing, while the-/// geometry derived from the footprint NAME truncates to "SOIC-16, 3.9 x 9.9 m…", which-/// still tells you the family, the pin count and the size.-///-/// The `.kicad_mod`'s own `(descr …)` is appended after it when it adds something. It is-/// skipped when it merely restates the geometry: "SOIC, 8 Pin" beside-/// "SOIC-8, 3.9 x 4.9 mm body, 1.27 mm pitch" is noise, not detail.-fn package_description(fp_content: Option<&str>, package_name: &str) -> String {- let descr = fp_content.and_then(|c| {- let re = regex_lite::Regex::new(r#"\(descr\s+"([^"]*)""#).ok()?;- let raw = re.captures(c)?.get(1)?.as_str();- let mut t = raw.split("http").next().unwrap_or(raw).trim().to_string();- // Cutting a URL can leave the bracket that introduced it, e.g. "(JEDEC MS-012AA, ".- if t.matches('(').count() > t.matches(')').count() {- if let Some(i) = t.rfind('(') { t.truncate(i); }- }- let t = t.trim().trim_end_matches(&[',', ';', '\u{b7}', '-'][..]).trim().to_string();- if t.is_empty() { None } else { Some(t) }- });-- let mut family = String::new();- let mut body = String::new();- let mut pitch = String::new();- let dims = regex_lite::Regex::new(r"^([\d.]+)x([\d.]+)mm$").unwrap();- let pit = regex_lite::Regex::new(r"^P([\d.]+)mm$").unwrap();- for (i, tok) in package_name.split('_').enumerate() {- if i == 0 { family = tok.to_string(); continue; }- if let Some(c) = dims.captures(tok) { body = format!("{} x {} mm", &c[1], &c[2]); continue; }- if let Some(c) = pit.captures(tok) { pitch = format!("{} mm pitch", &c[1]); continue; }- }- // Geometry only counts when the NAME actually encodes it. "0603N101J500CT" is a part- // number, not a package description. Rather than fall back to the manufacturer-first- // descr (which truncates to "Walsin Technology Co...", identifying nothing) or to the- // package name (which just repeats the Name column beside it, the thing John objected- // to), MEASURE the footprint: the pad count and the outline are always available and- // they are what actually distinguishes one package from another.- let geometry = if body.is_empty() && pitch.is_empty() {- match fp_content {- Some(km) => {- let pads = parse_mod_pads(km);- if pads.is_empty() {- String::new()- } else {- // Measure the COPPER, not the silkscreen. package_outline() prefers the- // F.SilkS box, which on a two-terminal chip part is a pair of short- // ticks beside the pads: it measured an 0603 as "0.6 x 0.0 mm", a- // number that is confidently wrong. The pad extent is always defined.- let (mut x1, mut y1, mut x2, mut y2) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);- for pd in &pads {- x1 = x1.min(pd.x - pd.dx / 2.0); y1 = y1.min(pd.y - pd.dy / 2.0);- x2 = x2.max(pd.x + pd.dx / 2.0); y2 = y2.max(pd.y + pd.dy / 2.0);- }- let (w, h) = ((x2 - x1).abs(), (y2 - y1).abs());- // Refuse to state a measurement that cannot be right rather than print- // a tidy-looking zero.- if w < 0.2 || h < 0.2 {- format!("{} pads", pads.len())- } else {- format!("{} pads, {:.1} x {:.1} mm", pads.len(), w, h)- }- }- }- None => String::new(),- }- } else {- let mut parts = vec![family];- if !body.is_empty() { parts.push(body); }- if !pitch.is_empty() { parts.push(pitch); }- parts.join(", ")- };-- match (geometry.is_empty(), descr) {- (true, Some(d)) => d,- (true, None) => package_name.to_string(),- (false, None) => geometry,- (false, Some(d)) => {- // Skip a descr that just restates the geometry: compare on alphanumeric tokens- // rather than on the exact string, so "SOIC, 8 Pin" is recognised as a restating- // of "SOIC-8, ...".- let norm = |t: &str| -> Vec<String> {- t.to_lowercase()- .split(|c: char| !c.is_ascii_alphanumeric() && c != '.')- .filter(|w| !w.is_empty() && *w != "pin" && *w != "pins" && *w != "mm")- .map(|w| w.to_string())- .collect()- };- let g = norm(&geometry);- let dt = norm(&d);- let covered = dt.iter().filter(|w| g.iter().any(|x| x == *w)).count();- let redundant = !dt.is_empty() && covered * 100 / dt.len() >= 60;- if redundant { geometry } else { format!("{}. {}", geometry, d) }- }- }-}--/// The wiki page's one-line `brief`, used when the `.kicad_sym` carries no Description.-/// Several published sources have none at all (adom/s10b-ph-sm4-tb, ray/stm32l431rct6tr,-/// ray/xl7026e1), so their devicesets reached Fusion with an empty Description column and-/// nothing to search on. The page brief is the right text and it is already sitting beside-/// the symbol, so the library carries it rather than the importer having to guess.-fn page_brief(path: &std::path::Path) -> Option<String> {- let raw = fs::read_to_string(path).ok()?;- let doc = serde_json::from_str::<serde_json::Value>(&raw).ok()?;- for probe in [doc.get("brief"), doc.get("component").and_then(|c| c.get("brief")),- doc.get("description"), doc.get("component").and_then(|c| c.get("description"))] {- if let Some(t) = probe.and_then(|v| v.as_str()) {- let t = t.trim();- if !t.is_empty() { return Some(t.to_string()); }- }- }- None-}--/// 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` / `part` / `sku` (the wiki component pages-/// write `part`), because all of those shapes are 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.-/// Every EE attaches the orderable part numbers to a library part before it reaches a-/// design: the manufacturer part number and the Mouser, DigiKey and LCSC/JLCPCB numbers-/// are what the BOM and CPL carry to the fab with the gerbers (John, 2026-09-19). A-/// KiCad symbol carries them as properties (MPN, Manufacturer, Mouser, DigiKey, LCSC;-/// adom-symbol writes them from `mpn` + `distributors` in its create JSON); import-kicad-/// turns them into Altium parameters (plus Supplier N / Supplier Part Number N pairs)-/// and generate writes them as EAGLE attributes. Say so when any are missing, and say-/// where to get them: `adom-parts-search search <MPN>` looks all three up at once.-/// Heatsink (thermal) vias travel INSIDE the footprint as plated through-hole pads that-/// carry the exposed pad's own number (0.3 mm drill, 0.8 mm pitch, 0.15 mm ring, tented-/// is the wiki's standard rule; adom-footprint create `heatsinkVias` writes them). Every-/// lane keeps them: KiCad as the pads themselves, Altium as multi-layer pads with holes,-/// EAGLE/Fusion as <pad drill=...> - so a user never redraws them per tool (John,-/// 2026-09-19). Say when a large SMD pad has none.-fn heatsink_hint(fp_txt: &str, mpn: &str) {- let pad_re = regex_lite::Regex::new(r#"\(pad\s+"([^"]+)"\s+(smd|thru_hole|np_thru_hole)\b[^\n]*?\(size\s+([-\d.]+)\s+([-\d.]+)\)"#).unwrap();- let mut smd_big: Vec<(String, f64)> = Vec::new();- let mut th: std::collections::HashMap<String, usize> = std::collections::HashMap::new();- for c in pad_re.captures_iter(fp_txt) {- let (name, kind) = (c[1].to_string(), c[2].to_string());- let area = c[3].parse::<f64>().unwrap_or(0.0) * c[4].parse::<f64>().unwrap_or(0.0);- if kind == "smd" { smd_big.push((name, area)); } else if kind == "thru_hole" { *th.entry(name).or_insert(0) += 1; }- }- let Some(max_area) = smd_big.iter().map(|(_, a)| *a).fold(None, |m: Option<f64>, a| Some(m.map_or(a, |x| x.max(a)))) else { return };- let median = { let mut v: Vec<f64> = smd_big.iter().map(|(_, a)| *a).collect(); v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v[v.len() / 2] };- for (name, area) in &smd_big {- if *area >= 1.0 && *area >= 3.0 * median && *area == max_area {- match th.get(name) {- Some(n) => println!("HINT: {mpn} exposed pad {name} carries {n} heatsink via(s) as plated holes with its own number; they reach Altium (multi-layer pads) and Fusion (<pad drill>) unchanged."),- None => println!("HINT: {mpn} exposed pad {name} ({area:.1} mm2) has NO heatsink vias in the footprint. A thermal pad without vias into the ground plane is a part that overheats; add them once in the KiCad footprint as thru_hole pads numbered {name} (adom-footprint create `heatsinkVias`, or the wiki page's artwork/<MPN>-heatsink-via.json rule: 0.3 mm drill, 0.8 mm pitch, 0.15 mm ring, tented) and every lane carries them."),- }- }- }-}--fn sourcing_hint(sym_txt: &str, mpn: &str) {- let has = |n: &str| sym_txt.contains(&format!("(property \"{n}\""));- let missing: Vec<&str> = ["MPN", "Manufacturer", "Datasheet", "Description", "Mouser", "DigiKey", "LCSC", "Adom Wiki"].into_iter().filter(|n| !has(n)).collect();- if missing.is_empty() {- println!("HINT: identity complete (MPN, Manufacturer, Datasheet, Description, Mouser, DigiKey, LCSC, Adom Wiki) - they become Altium parameters + Supplier links and EAGLE attributes.");- } else {- println!("HINT: {mpn} symbol has no {} property. A part without its MPN and Mouser/DigiKey/LCSC numbers cannot be ordered from the BOM/CPL the fab gets with the gerbers. Look them up with `adom-parts-search search {mpn}` and put them in the symbol (adom-symbol create: `mpn` + `distributors` in the create JSON) or the wiki page.json (component.distributor_links) before converting; LCSC may honestly be absent (say so on the page). The Adom Wiki property is the link to the part's component page (https://wiki.adom.inc/<owner>/<slug>), the one place an EE goes for the reviewed symbol, footprint, 3D, datasheet and provenance.", missing.join(" / "));- }-}--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)); }- // The datasheet and the Adom wiki page ride along as attributes too: the wiki page is- // the one place that holds the reviewed symbol/footprint/3D/datasheet/provenance for- // the part, so every tool carries the link back to it (John, 2026-09-19).- if let Some(v) = clean(c.get("datasheet")).or_else(|| clean(c.get("datasheet_url"))).or_else(|| clean(doc.get("datasheet"))) {- out.push(("DATASHEET".to_string(), v));- }- let wiki = clean(c.get("wiki_url")).or_else(|| clean(doc.get("wiki_url"))).or_else(|| clean(doc.get("url"))).or_else(|| {- let slug = clean(doc.get("slug"))?;- let owner = clean(doc.get("owner")).or_else(|| clean(doc.get("org"))).unwrap_or_else(|| "adom".to_string());- Some(format!("https://wiki.adom.inc/{owner}/{slug}"))- });- if let Some(v) = wiki { out.push(("ADOM_WIKI".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")).or_else(|| other.get("part")).or_else(|| other.get("sku")).cloned(),- });- if let Some(v) = flat.or_else(|| clean(nested.as_ref())) {- out.push((attr.to_string(), v));- }- }- out-}--/// One extra gate of the deviceset: an alternate symbol for the same part.-pub(crate) struct AltGate {- pub sym: ParsedSymbol,- pub gate: String,- pub art: Option<SymbolArt>,-}--/// The `<symbol>` block of ONE symbol: body, chip drawing, pins, group labels.-/// Returns the pin names as emitted (deduplicated); the connects must reuse them.-fn emit_symbol_block(xml: &mut String, sym: &ParsedSymbol, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64) -> Vec<String> {- xml.push_str(&format!("<symbol name=\"{}\">\n", esc_xml(&sym.name)));- // Fusion shows a Description column for every row in the tree, and the SYMBOL rows were- // blank for every part we ship. The deviceset text is the right text for the symbol too:- // it is what a person is looking for when they are reading the list.- if !sym.description.trim().is_empty() {- xml.push_str(&format!(" <description>{}</description>\n", esc_xml(&sym.description)));- }-- 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, src_w) => {- let ww = eagle_symbol_wire_width(*src_w);- for w in pts.windows(2) {- xml.push_str(&format!(" <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"{:.4}\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1, ww));- }- }- 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));- }- }- SymGraphic::Rect { x1, y1, x2, y2 } => {- xml.push_str(&format!(" <rectangle x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" layer=\"94\"/>\n", x1, y1, x2, y2));- }- SymGraphic::Circle { cx, cy, r } => {- xml.push_str(&format!(" <circle x=\"{:.4}\" y=\"{:.4}\" radius=\"{:.4}\" width=\"0.1524\" layer=\"94\"/>\n", cx, cy, r));- }- }- }- }-- // >NAME and >VALUE GO WHERE A WIRE CANNOT. They used to sit 2.54 mm above the body top- // and below the body bottom, which is correct for a two-column symbol and wrong for a- // four-sided one: that is exactly the lane the top and bottom pin stubs occupy, so every- // net a user draws to those pins runs straight through the label (John, on the CH340G-SQ- // frame). When the symbol has pins on the top or bottom edge, both labels move INSIDE- // the body, into the interior rectangle left free by the pin names.- // ADOM-SYMBOL IS AUTHORITATIVE when it states where the designators go. Its points are- // the text centres it wrote into the .kicad_sym Reference/Value properties, so emitting- // >NAME and >VALUE there with align="center" makes the KiCad render and the Fusion- // render agree 1:1, which is the whole point of having a contract. A null means it- // deliberately hid that designator, and hidden is honoured rather than second-guessed.- // Note the two tools measure text differently (adom-symbol 1.5 mm per character at the- // 1.27 mm font, adom-lbr 0.6 x height), so the rects they compute differ; that is- // precisely why one of them has to own the number rather than both approximating it.- const GLYPH_ADVANCE: f64 = 1.18;-- // SECOND AUTHORITY: the source's own Reference/Value positions, used when there is no- // contract, so a symbol and its -CHIP twin do not give two different answers side by- // side in the tree (John, comparing CH340G-SQ with CH340G-SQ-CHIP).- //- // VALIDATED, NOT OBEYED. A position is honoured only if a wire cannot run through it.- // Older adom-symbol builds and KiCad's stock libraries put Reference above the body and- // Value below it, which on a four-sided symbol is exactly the lane the top and bottom- // nets occupy: CH340G-SQ's own source puts Reference at y = 16.64, past the top pin ends- // at 10.54, so obeying it blindly would put the label back where this was just fixed.- let in_a_wire_lane = |pt: (f64, f64), label_w: f64| -> bool {- let half = label_w / 2.0;- sym.pins.iter().any(|p| match p.angle {- 270 => (pt.0 - p.x).abs() < half + 0.5 && pt.1 >= p.y,- 90 => (pt.0 - p.x).abs() < half + 0.5 && pt.1 <= p.y,- 0 => (pt.1 - p.y).abs() < 1.27 && pt.0 <= p.x,- _ => (pt.1 - p.y).abs() < 1.27 && pt.0 >= p.x,- })- };- let source_designators = match (sym.ref_pos, sym.val_pos) {- (Some(r), Some(v))- if !in_a_wire_lane(r, 5.0 * GLYPH_ADVANCE * 1.27)- && !in_a_wire_lane(v, 6.0 * GLYPH_ADVANCE * 1.27) => Some((r, v)),- _ => None,- };-- let contract_designators = match symbol_art {- Some(SymbolArt::Contract { layout, .. }) if layout.inside_body- && (layout.reference.is_some() || layout.value.is_some()) => Some(layout),- _ => None,- };- if let Some(layout) = contract_designators {- if let Some((x, y)) = layout.reference {- xml.push_str(&format!(" <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"95\" align=\"center\">>NAME</text>\n", x, y));- }- if let Some((x, y)) = layout.value {- xml.push_str(&format!(" <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"96\" align=\"center\">>VALUE</text>\n", x, y));- }- println!("HINT: >NAME/>VALUE placed from the outline contract (adom-symbol's own designator points), so the KiCad and Fusion renders match.");- } else if let Some((r, v)) = source_designators {- xml.push_str(&format!(" <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"95\" align=\"center\">>NAME</text>\n", r.0, r.1));- xml.push_str(&format!(" <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"96\" align=\"center\">>VALUE</text>\n", v.0, v.1));- println!("HINT: >NAME/>VALUE placed from the source .kicad_sym's own Reference/Value positions, so this symbol matches its chip-outline twin.");- } else {- let has_vertical_pins = sym.pins.iter().any(|p| p.angle == 90 || p.angle == 270);- // GLYPH ADVANCE, taken from adom-symbol rather than from my own estimate. I used- // 0.6 x height, which is the usual approximation for EAGLE's vector font and is roughly- // HALF what Fusion actually draws: on the CH340G-SQ frame that made >NAME land against- // the bottom of "R232" while my arithmetic claimed 2.69 mm of clearance. adom-symbol- // measures 1.5 mm per character at the 1.27 mm font, so about 1.18 x height, and its- // margins read correctly in Fusion. One model, and it is the one that has been checked- // against the renderer. Over-reserving is the safe direction: it costs interior space,- // never a collision.- let pin_name_w = |p: &KicadPin| dedup_pin_names(std::slice::from_ref(p))[0].chars().count() as f64 * GLYPH_ADVANCE * 1.778;- match (&sym.body, has_vertical_pins) {- (Some(b), true) => {- // Interior free rect: pull each side in by that side's longest pin name plus- // KiCad's 1.016 mm name offset. This is the same rectangle adom-symbol computes- // to fit the chip icon.- let inset = |sel: &dyn Fn(&KicadPin) -> bool| -> f64 {- sym.pins.iter().filter(|p| sel(p)).map(pin_name_w).fold(0.0, f64::max) + 1.016- };- let l = b.left + inset(&|p: &KicadPin| p.angle == 0);- let r = b.right - inset(&|p: &KicadPin| p.angle == 180);- let mut t = b.top - inset(&|p: &KicadPin| p.angle == 270);- let mut bo = b.bottom + inset(&|p: &KicadPin| p.angle == 90);- let cx = (l + r) / 2.0;-- // THE NEAREST INK IS NOT ALWAYS A PIN NAME. On the grouped layouts a group- // caption sits above or below the pin names, so it, not the name, is what a- // label would crowd. Pull the rect in past any caption that shares the labels'- // horizontal span. A caption's own extent depends on how it is anchored: an- // unrotated one is centred on its y, a rotated top one hangs DOWN from its y,- // a rotated bottom one runs UP from it.- let cap_h = 0.762;- let label_half_w = 6.0 * GLYPH_ADVANCE * 1.27 / 2.0; // ">VALUE" is the wider of the two- for g in &sym.group_labels {- let len = g.text.chars().count() as f64 * GLYPH_ADVANCE * cap_h;- let (gy0, gy1) = if g.angle == 90 || g.angle == 270 {- if g.justify_right { (g.y - len, g.y) } else { (g.y, g.y + len) }- } else {- (g.y - cap_h / 2.0, g.y + cap_h / 2.0)- };- let (gx0, gx1) = if g.angle == 90 || g.angle == 270 {- (g.x - cap_h / 2.0, g.x + cap_h / 2.0)- } else if g.justify_right {- (g.x - len, g.x)- } else {- (g.x, g.x + len)- };- if gx1 < cx - label_half_w || gx0 > cx + label_half_w { continue; }- let mid = (b.top + b.bottom) / 2.0;- if gy0 > mid { t = t.min(gy0); } else { bo = bo.max(gy1); }- }-- // BREATHING ROOM, ON THE GRID. Sitting exactly on the rect edge puts the label- // flush against the ink below it, which reads as crowded even though nothing- // overlaps (John, on the SQG frame). One full text height of gap, snapped to- // EAGLE's 2.54 mm grid so the labels land where a user's own text would.- const MARGIN: f64 = 2.54;- let (ny, vy) = (t - MARGIN, bo + MARGIN);- // The two labels must clear EACH OTHER as well as the ink around them: a rect- // that fits them only by letting them touch is the squeeze the corner fallback- // exists to avoid. Two text heights plus a gap of one.- if ny - vy >= 3.0 * 1.27 && r - l >= 4.0 {- xml.push_str(&format!(" <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"95\" align=\"top-center\">>NAME</text>\n", cx, ny));- xml.push_str(&format!(" <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"96\" align=\"bottom-center\">>VALUE</text>\n", cx, vy));- } else {- // The interior is too small for 1.27 mm text (a tiny square symbol). Put the- // labels in the corners BEYOND the pin extents, where a wire still cannot go.- let far = sym.pins.iter().map(|p| p.x.abs() + p.length).fold(0.0, f64::max) + 1.27;- xml.push_str(&format!(" <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"95\" align=\"bottom-left\">>NAME</text>\n", far, b.top));- xml.push_str(&format!(" <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"96\" align=\"top-left\">>VALUE</text>\n", far, b.bottom));- }- }- (body, _) => {- // No pins on the top or bottom edge: above and below the body is free space, and- // it is where an engineer expects to read the designator.- let name_y = body.as_ref().map(|b| b.top + 2.54).unwrap_or(10.0);- let value_y = 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);- // A GLYPH SYMBOL HIDES ITS PIN NAMES. EAGLE draws a pin's name at its INNER end, which- // is inside the body for an IC and inside the DRAWING for a two-terminal part that has- // no body box: measured on the published LED, both names land on the triangle, and on- // the crystal all four land on the plates ("G@2" drawn straight through the box). No- // engineer labels a diode's pins on a schematic anyway, so show the pad NUMBER and hide- // the name, which is what EAGLE's own passive libraries do. Tightly scoped: only a- // symbol with NO body box, its own artwork, and at most four pins, so no IC is touched.- // NOT keyed on "has no body box": a crystal's first rectangle IS its can, so it parses- // as a body while being part of the drawing, and the four pin names printed straight- // through it. The test that holds for both is artwork plus a small pin count: an IC- // with a body and no artwork of its own keeps its names.- let glyph_only = !sym.graphics.is_empty() && sym.pins.len() <= 4;- if glyph_only {- println!("HINT: glyph symbol ({} pins, no body box): pin NAMES hidden and pad numbers shown, so the names do not print on top of the drawing.", sym.pins.len());- }- 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),- if glyph_only { " visible=\"pad\"" } else { "" }- ));- }-- 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 {- // FAITHFUL MAPPING of adom-symbol's four caption forms, rather than re-deriving the- // side from x. EAGLE's align vocabulary is the nine {bottom|center|top}-{left|- // center|right} values; a bare "right" is not one of them, so Fusion logged a- // warning per caption, ignored it, and fell back to LEFT anchoring, which made every- // right-hand caption run outward across the body line (John saw it on 2CG and SQG).- // KiCad's vertical default is centre, so center-* is the faithful vertical anchor.- // left (at x y 0) justify left -> R0, center-left- // right (at x y 0) justify right -> R0, center-right- // top (at x y 90) justify right -> R90, center-right- // bottom (at x y 90) justify left -> R90, center-left- let align = if label.justify_right { "center-right" } else { "center-left" };- let rot = if label.angle == 90 || label.angle == 270 { " rot=\"R90\"" } else { "" };- // The source already places captions 1.02 mm inside the edge. Use its coordinates- // instead of recomputing them from the body, which is how the old code lost the- // top and bottom captions entirely.- xml.push_str(&format!(- " <text x=\"{:.4}\" y=\"{:.4}\" size=\"0.762\" layer=\"94\" font=\"vector\" ratio=\"15\"{} align=\"{}\">{}</text>\n",- label.x, label.y, rot, align, esc_xml(&label.text)- ));- }- let _ = (body_center_x, body_left, body_right);-- xml.push_str("</symbol>\n");- deduped_names-}--pub(crate) fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64, pin_map: &std::collections::HashMap<String, String>, part_attrs: &[(String, String)]) -> String {- generate_lbr_gates(sym, fp_content, symbol_art, symbol_art_scale, pin_map, part_attrs, &[], &[])-}--pub(crate) fn generate_lbr_gates(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64, pin_map: &std::collections::HashMap<String, String>, part_attrs: &[(String, String)], alts: &[AltGate], variants: &[PackageVariant]) -> 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. User layers are declared only when a footprint draws on one: Fusion IGNORES- // geometry on a layer the file never declares, and lists every declared layer in its- // dialog whether or not anything sits on it.- let mut used: std::collections::BTreeSet<u32> = std::collections::BTreeSet::new();- if let Some(km) = fp_content { used.extend(used_user_layers(km)); }- for v in variants { used.extend(used_user_layers(&v.km)); }- if used.is_empty() {- xml.push_str(EAGLE_LAYERS);- } else {- let decls = user_layer_decls(&used);- xml.push_str(&EAGLE_LAYERS.replace("</layers>", &format!("{}</layers>", decls)));- println!("HINT: declared {} user layer(s) ({}) so their geometry survives into Fusion.",- used.len(), used.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(", "));- }-- 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.package_name.as_deref().unwrap_or(&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();- // The PACKAGE's description names the package, not the symbol. It used to carry- // sym.name, so two symbol variants of one chip produced packages that were- // byte-identical except for this line, and assemble's content dedup could not see- // that they were the same footprint: the merged library gained a second package- // (and the bridge a second 3D model) per variant.- xml.push_str(&format!(" <description>{}</description>\n", esc_xml(sym.package_description.as_deref().unwrap_or(sym.package_name.as_deref().unwrap_or(&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),- p.roundness.map(|r| format!(" roundness=\"{r}\"")).unwrap_or_default()));- } 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 (tht_shape, diameter, extra_rot, warn) = tht_pad_geometry(&p.kicad_shape, p.dx, p.dy);- if let Some(w) = warn { eprintln!("WARN: pad {}: {}", p.name, w); }- 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, tht_shape, smd_rotation(p.angle + extra_rot)));- }- }- // 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");- // One package per VARIANT. Same part, different footprint, so they belong in the same- // deviceset as sibling devices rather than as separate parts.- let base_pkg = sym.package_name.as_deref().unwrap_or(&sym.name).to_string();- let mut variant_groups: Vec<(String, std::collections::HashMap<String, Vec<String>>)> = Vec::new();- for v in variants {- let pkg = format!("{}{}", base_pkg, v.suffix);- let desc = match &v.description {- Some(d) => d.clone(),- None => format!("{}{}", sym.package_description.as_deref().unwrap_or(&base_pkg),- if v.suffix.is_empty() { String::new() } else { format!(", variant {}", v.suffix.trim_start_matches('-')) }),- };- let g = emit_variant_package(&mut xml, &v.km, &pkg, &desc);- variant_groups.push((pkg, g));- }- xml.push_str("</packages>\n");-- // Symbols section- xml.push_str("<symbols>\n");- let deduped_names = emit_symbol_block(&mut xml, sym, symbol_art, symbol_art_scale);- let alt_names: Vec<Vec<String>> = alts.iter()- .map(|a| emit_symbol_block(&mut xml, &a.sym, a.art.as_ref(), symbol_art_scale))- .collect();- xml.push_str("</symbols>\n");-- // Deviceset with COMPLETE device (package + connects)- 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.deviceset_name.as_deref().unwrap_or(&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),- if alts.is_empty() { "" } else { " addlevel=\"next\"" }));- // Alternate gates sit SIDE BY SIDE in the deviceset (Fusion's component preview draws- // every gate at its offset; ten gates at 0,0 were an unreadable pile on the first run).- let width = |p: &ParsedSymbol| p.body.as_ref().map(|b| b.right - b.left).unwrap_or(20.0) + 2.0 * 7.62;- let mut gx = width(sym) / 2.0;- for a in alts {- let w = width(&a.sym);- gx += w / 2.0 + 5.08;- let x = (gx / 2.54).round() * 2.54; // keep gate origins on the 0.1in grid- xml.push_str(&format!(" <gate name=\"{}\" symbol=\"{}\" x=\"{:.2}\" y=\"0\" addlevel=\"can\"/>\n", esc_xml(&a.gate), esc_xml(&a.sym.name), x));- gx += w / 2.0;- }- xml.push_str(" </gates>\n");- xml.push_str(" <devices>\n");- xml.push_str(&format!(" <device name=\"\" package=\"{}\">\n", esc_xml(sym.package_name.as_deref().unwrap_or(&sym.name))));- xml.push_str(" <connects>\n");-- // Generate connect mappings: gate pin name → pad number, for the primary gate and- // then for every alternate gate (same pads: they are the same physical pins).- let mut gate_pins: Vec<(&str, &ParsedSymbol, &Vec<String>)> = vec![("G$1", sym, &deduped_names)];- for (a, names) in alts.iter().zip(alt_names.iter()) {- gate_pins.push((a.gate.as_str(), &a.sym, names));- }- let gate_pins2 = gate_pins.clone();- for (gate_name, gsym, gnames) in gate_pins {- for (i, pin) in gsym.pins.iter().enumerate() {- // The pad reference is the symbol pin's NUMBER, which is the KiCad- // convention and right for almost every part. A pin map is the escape- // hatch for the ones where the footprint names that pad something else.- 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=\"{}\" pin=\"{}\" pad=\"{}\"/>\n",- esc_xml(gate_name), esc_xml(&gnames[i]), esc_xml(&pad_ref)- ));- }- }-- xml.push_str(" </connects>\n");- // Attributes ride on <technology>, which is where Fusion's BOM export and the- // 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");-- // ONE DEVICE PER VARIANT, sharing the gates and the connects. An extra pad that sits- // inside a baseline pad joins that pad's connect as a space-separated multi-pad ref,- // which is EAGLE's own form for one pin on several pads: sixteen heatsink vias in a- // thermal pad become <connect pin="EP" pad="EP V1 V2 ...">. Attributes are repeated- // verbatim, because it is the same orderable part whichever package you fit.- let base_pads = fp_content.map(parse_mod_pads).unwrap_or_default();- for (v, (pkg, vgroups)) in variants.iter().zip(variant_groups.iter()) {- xml.push_str(&format!(" <device name=\"{}\" package=\"{}\">\n", esc_xml(&v.suffix), esc_xml(pkg)));- xml.push_str(" <connects>\n");- let vpads = parse_mod_pads(&v.km);- // extra pad name -> the baseline pad whose area contains it- let mut extra_for: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();- let mut orphans: Vec<String> = Vec::new();- for ep in &vpads {- if base_pads.iter().any(|b| b.name == ep.name) { continue; }- match owning_pad(ep, &base_pads) {- Some(owner) => extra_for.entry(owner.name.clone()).or_default().push(ep.name.clone()),- None => orphans.push(ep.name.clone()),- }- }- if !orphans.is_empty() {- eprintln!("WARN: variant {} has pad(s) {} that sit inside no baseline pad, so nothing connects them. \-Place them within the pad they serve, or they will import as unconnected copper.",- v.suffix, orphans.join(", "));- }- for (gate_name, gsym, gnames) in &gate_pins2 {- for (i, pin) in gsym.pins.iter().enumerate() {- let pad = pin_map.get(&pin.number).unwrap_or(&pin.number);- let mut refs: Vec<String> = match vgroups.get(pad) {- Some(group) => group.clone(),- None => vec![pad.clone()],- };- if let Some(extra) = extra_for.get(pad) { refs.extend(extra.clone()); }- xml.push_str(&format!(- " <connect gate=\"{}\" pin=\"{}\" pad=\"{}\"/>\n",- gate_name, esc_xml(&gnames[i]), esc_xml(&refs.join(" "))));- }- }- xml.push_str(" </connects>\n");- if part_attrs.is_empty() {- xml.push_str(" <technologies><technology name=\"\"/></technologies>\n");- } else {- xml.push_str(" <technologies>\n <technology name=\"\">\n");- for (k, val) in part_attrs {- xml.push_str(&format!(- " <attribute name=\"{}\" value=\"{}\" constant=\"no\"/>\n",- esc_xml(k), esc_xml(val)));- }- 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() });- }-- // PAD CLEARANCE. A mapping that grows a pad into its neighbour is a SHORT, and the file- // is still perfectly valid EAGLE, so nothing else here would catch it: adom/b2b-ph-k-s- // shipped with pin 2's copper 0.35 mm inside pin 1 (wiki #19). Copper-to-copper under- // 0.2 mm is an error rather than a warning, because a converter must refuse before a- // board house does.- {- #[derive(Clone)]- struct P { name: String, x: f64, y: f64, hx: f64, hy: f64, round: bool }- let num = |s: &str| s.parse::<f64>().unwrap_or(0.0);- let rot90 = |r: Option<&str>| matches!(r, Some("R90") | Some("R270"));- let mut overlaps: Vec<String> = Vec::new();- let mut close: Vec<String> = Vec::new();- let pkg_re = regex_lite::Regex::new(r#"(?s)<package name="([^"]*)">(.*?)</package>"#).unwrap();- // Match the whole element, then read each attribute from it. An optional (?:rot=...)?- // group followed by a greedy [^>]* silently matches EMPTY and lets the tail eat the- // rotation, so every pad reads as unrotated: that made this lint fail the file it- // had just been used to fix.- let smd_re = regex_lite::Regex::new(r#"<smd [^>]*/>"#).unwrap();- let pad_re = regex_lite::Regex::new(r#"<pad [^>]*/>"#).unwrap();- let attr = |el: &str, k: &str| -> Option<String> {- regex_lite::Regex::new(&format!(r#"{}="([^"]*)""#, k)).ok()?- .captures(el).map(|c| c[1].to_string())- };- for pk in pkg_re.captures_iter(content) {- let (pkg, body) = (pk[1].to_string(), pk[2].to_string());- let mut pads: Vec<P> = Vec::new();- for m in smd_re.find_iter(&body) {- let el = m.as_str();- let g = |k: &str| attr(el, k).unwrap_or_default();- let (mut hx, mut hy) = (num(&g("dx")) / 2.0, num(&g("dy")) / 2.0);- if rot90(attr(el, "rot").as_deref()) { std::mem::swap(&mut hx, &mut hy); }- pads.push(P { name: g("name"), x: num(&g("x")), y: num(&g("y")), hx, hy, round: false });- }- for m in pad_re.find_iter(&body) {- let el = m.as_str();- let g = |k: &str| attr(el, k).unwrap_or_default();- let shape = g("shape");- let d = num(&g("diameter"));- let (mut hx, mut hy) = match shape.as_str() {- // EAGLE's `long` is a 2:1 stadium elongated along the pad's ROTATION- // axis, which is X when unrotated: 2d wide by d tall, so the half- // extents are (d, d/2). Writing them the other way round is what made- // this lint pass the very file it was written for.- "long" => (d, d / 2.0),- _ => (d / 2.0, d / 2.0),- };- if rot90(attr(el, "rot").as_deref()) { std::mem::swap(&mut hx, &mut hy); }- pads.push(P { name: g("name"), x: num(&g("x")), y: num(&g("y")), hx, hy, round: shape == "round" });- }- for i in 0..pads.len() {- for j in i + 1..pads.len() {- let (a, b) = (&pads[i], &pads[j]);- // Pads of the SAME net legitimately touch or nest: heatsink vias sit- // inside the thermal pad they vent, and a same-net array is emitted as- // NAME, NAME@2, NAME@3. Comparing those would fail every such footprint.- let base = |n: &str| n.split('@').next().unwrap_or(n).to_string();- if base(&a.name) == base(&b.name) { continue; }- let gx = (a.x - b.x).abs() - (a.hx + b.hx);- let gy = (a.y - b.y).abs() - (a.hy + b.hy);- let gap = if a.round && b.round {- ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt() - (a.hx + b.hx)- } else {- gx.max(gy)- };- // True copper OVERLAP is a short and stays a hard error. A small- // POSITIVE gap is what fine-pitch land patterns look like by- // construction (a 0.5 mm-pitch SOT-563 has 0.150 mm between pads in- // KiCad's own library), so it warns instead of failing: a flat 0.2 mm- // ERROR rejected 559 of 1314 stock KiCad footprints (wiki #20), each- // one a part silently dropped from the factory worklist. The epsilon- // keeps a gap of exactly the threshold clean: 1.2 - 1.0 is- // 0.19999999999999996 in floats, and a message that says "closer than- // 0.2 mm" must not reject a pair that is "0.200 mm apart".- const EPS: f64 = 1e-4;- if gap < -EPS {- overlaps.push(format!("{} pads {} and {} OVERLAP by {:.3} mm", pkg, a.name, b.name, -gap));- } else if gap < 0.2 - EPS {- close.push(format!("{} pads {} and {} are {:.3} mm apart", pkg, a.name, b.name, gap));- }- }- }- }- if !overlaps.is_empty() {- errors.push(LintError {- code: "pad-clearance".into(),- message: format!("copper OVERLAPS: {}. A pad mapping that grows into its neighbour is a short.",- overlaps.join("; ")),- });- }- if !close.is_empty() {- warnings.push(LintError {- code: "pad-clearance".into(),- message: format!("copper closer than 0.2 mm: {}. Fine-pitch land patterns sit here by construction; \-check the source footprint intends it rather than a mapping having grown a pad.",- close.join("; ")),- });- }- }-- // EAGLE's align vocabulary is exactly nine values. Anything else makes Fusion log a- // warning per element on open and fall back to bottom-left, so text anchored to an- // edge runs OUTWARD across the body line instead of inward. adom-lbr shipped- // align="right" on right-side group captions until 2.26.0; this stops any align value- // reaching a library without a name EAGLE knows.- {- const VALID: [&str; 9] = ["bottom-left", "bottom-center", "bottom-right",- "center-left", "center", "center-right",- "top-left", "top-center", "top-right"];- let re = regex_lite::Regex::new(r#"align="([^"]*)""#).unwrap();- let mut bad: Vec<String> = Vec::new();- for c in re.captures_iter(content) {- let v = c[1].to_string();- if !VALID.contains(&v.as_str()) && !bad.contains(&v) { bad.push(v); }- }- if !bad.is_empty() {- errors.push(LintError {- code: "invalid-align".into(),- message: format!("align={:?} is not an EAGLE alignment. Valid: {}. Fusion warns per element and falls back to bottom-left, so edge-anchored text runs across the body line.",- bad.join(", "), VALID.join(", ")),- });- }- }-- // 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.-/// Write `bytes` to `path` (creating the directory), read the file back and-/// require it to match, returning the sha256. Any failure is a coded error-/// and exit 2: export-kicad once printed "OK: wrote" for two files in a-/// directory that did not exist, because every write went through-/// `fs::write(..).ok()` and nothing created the directory (wiki #26).-fn write_verified(path: &std::path::Path, bytes: &[u8]) -> String {- use sha2::{Digest, Sha256};- if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {- if let Err(e) = fs::create_dir_all(dir) {- eprintln!("ERROR: [OUTPUT_NOT_WRITTEN] cannot create {}: {e}", dir.display());- process::exit(2);- }- }- if let Err(e) = fs::write(path, bytes) {- eprintln!("ERROR: [OUTPUT_NOT_WRITTEN] cannot write {}: {e}", path.display());- process::exit(2);- }- match fs::read(path) {- Ok(back) if back == bytes => format!("{:x}", Sha256::digest(bytes)),- Ok(back) => {- eprintln!("ERROR: [OUTPUT_NOT_WRITTEN] {} read back {} bytes, expected {}", path.display(), back.len(), bytes.len());- process::exit(2);- }- Err(e) => {- eprintln!("ERROR: [OUTPUT_NOT_WRITTEN] {} was written but cannot be read back: {e}", path.display());- process::exit(2);- }- }-}--/// The per-file receipt after a verified write.-fn written_hint(path: &std::path::Path, bytes: usize, sha: &str) {- println!("Hint: verified {} ({bytes} bytes, sha256 {sha}, read back identical)", path.display());-}--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.-/// Pads whose per-pad solder-mask/paste margins the Altium encoder cannot-/// carry yet (its pad-record expansion bytes are uncalibrated): named so the-/// loss is explicit, never a silent success (wiki #25).-/// A source feature a target lane cannot carry. One shape for every lane-/// (Colby's policy call on wiki #25): `code` is the machine key, `pads` the-/// pads it applies to in source order, `effect` what the target does instead.-#[derive(Debug, Clone, PartialEq)]-struct LostFeature {- code: &'static str,- part: String,- feature: String,- pads: Vec<String>,- effect: String,-}--impl LostFeature {- fn json(&self) -> serde_json::Value {- serde_json::json!({"code": self.code, "part": self.part, "feature": self.feature,- "pads": self.pads, "effect": self.effect})- }- fn pad_list(&self) -> String { self.pads.join(",") }-}--/// The three outputs every lane emits for a loss, in the default warn-and-ship-/// policy: the human WARN on stderr (the shape people already read), and the-/// structured LOST: json line on stdout for the wiki export workers.-fn report_lost(l: &LostFeature) {- eprintln!("WARN: [{}] pads {} carry {}; {}.", l.code, l.pad_list(), l.feature, l.effect);- println!("LOST: {}", l.json());-}--/// The --strict decision for a single-part lane: refuse only when something-/// is actually lost. Pure on `strict` (not the global) so tests cannot race.-fn strict_refusal(strict: bool, lane: &str, lost: &[LostFeature]) -> Option<String> {- if !strict || lost.is_empty() { return None; }- let what: Vec<String> = lost.iter().map(|l| format!("{} on pads {}", l.feature, l.pad_list())).collect();- Some(format!("nothing written for the {lane} lane: refused under --strict: {}", what.join("; ")))-}--/// Pads whose KiCad per-pad solder-mask/paste margins the EAGLE lane drops.-/// parse_mod_pads runs several times per part (baseline, variants, connects),-/// so it only COLLECTS here, deduped in source order; the generate handler-/// drains the collector once, before it lints or writes, and reports with the-/// part name (wiki #25).-static EAGLE_MARGIN_PADS: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());--fn eagle_margin_collect(pad: &str) {- if let Ok(mut v) = EAGLE_MARGIN_PADS.lock() {- if !v.iter().any(|p| p == pad) { v.push(pad.to_string()); }- }-}--fn eagle_margin_drain() -> Vec<String> {- EAGLE_MARGIN_PADS.lock().map(|mut v| std::mem::take(&mut *v)).unwrap_or_default()-}--fn eagle_lost_feature(part: &str, pads: Vec<String>) -> Option<LostFeature> {- if pads.is_empty() { return None; }- Some(LostFeature {- code: "lost-pad-margins",- part: part.to_string(),- feature: "per-pad solder-mask/paste margins".into(),- pads,- effect: "EAGLE has no per-pad aperture margin, so Fusion applies the library DRC stop/cream rules instead; the copper is exact, review the mask/paste apertures in Fusion before fab".into(),- })-}--/// The Altium lane's loss: signed mm expansions ride the pad record since-/// 2.41.0; a KiCad paste RATIO has no radial equivalent.-fn altium_lost_feature(part: &str, fp: &altium_codec::Footprint) -> Option<LostFeature> {- let pads = lost_margin_pads(fp);- if pads.is_empty() { return None; }- Some(LostFeature {- code: "lost-pad-margins",- part: part.to_string(),- feature: "per-pad solder-paste ratio".into(),- pads,- effect: "Altium carries the mm mask/paste expansions; a paste ratio has no radial equivalent and is dropped, review the paste apertures before fab".into(),- })-}--fn lost_margin_pads(fp: &altium_codec::Footprint) -> Vec<String> {- // Signed mm expansions now ride the Altium pad record (manual mode); a- // KiCad paste RATIO has no radial equivalent, so it is the one that is- // still named as lost.- fp.pads.iter()- .filter(|p| p.paste_margin_ratio.is_some())- .map(|p| p.number.clone())- .collect()-}--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, package_name, variants, description, symbol_art, symbol_art_scale, pin_map, part_metadata, symbol_layout, alt_sym } => {- let fp_content = fp.as_ref().map(|p| {- fs::read_to_string(p).unwrap_or_else(|e| {- eprintln!("ERROR: Cannot read footprint {}: {}", p.display(), e);- 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);- }- };-- // The PACKAGE takes the footprint's own name, not the symbol's. --name renames- // the symbol and deviceset only; it used to rename the package too, so eight- // symbol variants of one chip produced eight differently-named copies of the- // same footprint and `assemble` could not share a single package between them.- parsed.package_name = package_name.clone().or_else(|| {- fp_content.as_deref().and_then(kicad_mod_footprint_name)- });- if let Some(pkg) = &parsed.package_name {- if pkg != &parsed.name {- println!("HINT: package named \"{}\" (from the footprint); the symbol and deviceset are \"{}\". --package-name overrides.", pkg, parsed.name);- }- }-- let pkg_variants: Vec<PackageVariant> = variants.iter().filter_map(|spec| {- let mut it = spec.splitn(3, '=');- let suffix = it.next().unwrap_or_default().to_string();- let path = it.next().unwrap_or_default();- let desc = it.next().filter(|d| !d.is_empty()).map(|d| d.to_string());- if suffix.is_empty() || path.is_empty() {- eprintln!("ERROR: --variant wants SUFFIX=<file.kicad_mod>[=description], got '{spec}'");- process::exit(1);- }- match fs::read_to_string(path) {- Ok(km) => Some(PackageVariant {- suffix: if suffix.starts_with('-') { suffix } else { format!("-{suffix}") },- km, description: desc,- }),- Err(e) => { eprintln!("ERROR: cannot read variant {path}: {e}"); process::exit(1); }- }- }).collect();- if !pkg_variants.is_empty() {- println!("HINT: {} package variant(s) in ONE deviceset: {}. Fusion's CHANGE PACKAGE swaps between them without re-wiring.",- pkg_variants.len(),- pkg_variants.iter().map(|v| v.suffix.trim_start_matches('-')).collect::<Vec<_>>().join(", "));- // The bridge pairs a 3D model to a package by NAME first, so the STEP for a- // variant has to be named after the variant package rather than after the- // part. Said here because the person generating the STEPs is usually not the- // person reading the .lbr, and a mismatch shows up as a variant that imports- // with no 3D at all.- println!("HINT: name each variant's STEP after its PACKAGE so the bridge pairs it: {}.",- pkg_variants.iter()- .map(|v| format!("<package>{}.step", v.suffix))- .collect::<Vec<_>>().join(", "));- }-- 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();- // Description precedence: an explicit --description, then the .kicad_sym's own- // Description property, then the wiki page's brief. Whatever wins has catalogue- // boilerplate stripped: a page brief is written for someone browsing the wiki,- // and repeating "Datasheet-verified pinout, KiCad symbol/footprint/3D" in every- // Fusion Description cell crowds out the words that identify the part.- if let Some(d) = description.clone() {- parsed.description = d;- } else if is_generic_description(&parsed.description) {- // The source Description is empty, or it names a CLASS rather than a part- // ("Inductor", "Unpolarized capacitor", "Generic connector, single row").- // The page brief says WHICH inductor, so it wins. An explicit --description- // still beats both.- if let Some(brief) = pm_path.as_ref().and_then(|p| page_brief(p)) {- if parsed.description.trim().is_empty() {- println!("HINT: symbol had no Description; using the page brief so the deviceset is not blank in Fusion.");- } else {- println!("HINT: the source Description is a class word (\"{}\"); using the page brief instead, which names the actual part.", parsed.description.trim());- }- parsed.description = brief;- }- }- parsed.description = strip_catalog_boilerplate(&parsed.description);- if parsed.description.trim().is_empty() {- println!("HINT: this part has NO description: the deviceset, the symbol and the ADD dialog will all read blank in Fusion. Pass --description, or give the source a Description property.");- }- parsed.package_description = Some(package_description(fp_content.as_deref(),- parsed.package_name.as_deref().unwrap_or(&parsed.name)));-- // A DEVICESET IS THE ORDERABLE PART, so when the symbol carries a KiCad class- // name ("L", "Crystal_GND24", "Conn_01x02") and the page knows the MPN, the- // deviceset takes the MPN. The SYMBOL keeps its class name: it is the drawing,- // it is shared between every part of that class, and assemble dedupes symbols- // by content anyway. Without this a user opening the library sees a deviceset- // called "L" and cannot tell which inductor it is.- if name.is_none() && is_generic_part_name(&parsed.name) {- if let Some(mpn) = part_attrs.iter().find(|(k, _)| k == "MPN").map(|(_, v)| v.clone()) {- let mpn = eagle_safe_name(&mpn);- if mpn != parsed.name {- println!("HINT: deviceset named \"{}\" from the part's MPN; the symbol keeps the class name \"{}\". --name overrides.", mpn, parsed.name);- parsed.deviceset_name = Some(mpn);- }- }- }-- // A SEMANTIC PART WITH NO ARTWORK IS AN UNFINISHED SYMBOL, and it is worth- // saying so at generate time. A diode, LED, crystal or inductor that reaches a- // schematic as two bare pin stubs reads as a placeholder to any engineer (wiki- // issue #5). Almost always the source .kicad_sym does carry the glyph and it- // was being dropped in translation, which is fixed; what is left is a source- // that genuinely draws nothing, and that has to be fixed in the source rather- // than invented here, because guessing a part's class from its designator and- // drawing a triangle would be wrong the moment the guess is.- if parsed.graphics.is_empty() && parsed.body.is_none() {- let cls = match parsed.reference.trim_end_matches(|c: char| c.is_ascii_digit() || c == '?') {- "D" => Some("a diode or LED"),- "Y" | "X" => Some("a crystal or oscillator"),- "L" => Some("an inductor"),- "Q" => Some("a transistor"),- _ => None,- };- if let Some(what) = cls {- println!("HINT: this symbol has NO drawing of its own, and its reference \"{}\" says it is {}. \-It will render as bare pin stubs, which reads as an unfinished placeholder. Add the glyph to the source .kicad_sym \-(or pass --symbol-art), because the shape is what an engineer reads first.", parsed.reference, what);- }- }- 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);- }- }-- // Alternate gates: `path[=GATE[=art]]`, each one more symbol of THIS part.- let mut alts: Vec<AltGate> = Vec::new();- for spec in &alt_sym {- let mut it = spec.splitn(3, '=');- let path = PathBuf::from(it.next().unwrap_or_default());- let gate_opt = it.next().filter(|g| !g.is_empty()).map(|g| g.to_string());- let art_opt = it.next().filter(|a| !a.is_empty() && *a != "-").map(PathBuf::from);- let content = fs::read_to_string(&path).unwrap_or_else(|e| {- eprintln!("ERROR: Cannot read --alt-sym {}: {}", path.display(), e);- process::exit(1);- });- let mut alt = parse_kicad_sym(&content, None).unwrap_or_else(|e| {- eprintln!("ERROR: --alt-sym {}: {}", path.display(), e);- process::exit(1);- });- let gate = gate_opt.unwrap_or_else(|| {- let pfx = format!("{}-", parsed.name);- alt.name.strip_prefix(&pfx).map(|s| s.to_string()).unwrap_or_else(|| alt.name.clone())- });- if alt.name == parsed.name || alts.iter().any(|a| a.sym.name == alt.name) {- alt.name = format!("{}-{}", parsed.name, gate);- }- if alt.pins.len() != parsed.pins.len() {- eprintln!("ERROR: --alt-sym {} has {} pins, the primary symbol has {}: an alternate symbol must be the SAME part (same pin numbers)", path.display(), alt.pins.len(), parsed.pins.len());- process::exit(1);- }- let art = match art_opt.as_ref().map(|p| resolve_symbol_art(p)) {- Some(Ok(a)) => Some(a),- Some(Err(e)) => { eprintln!("ERROR: --alt-sym art: {e}"); process::exit(1); }- None => None,- };- if alts.iter().any(|a| a.gate == gate) || gate == "G$1" {- eprintln!("ERROR: duplicate gate name {:?} in --alt-sym; give it path=GATE", gate);- process::exit(1);- }- alts.push(AltGate { sym: alt, gate, art });- }- if !alts.is_empty() {- println!("HINT: {} alternate symbol(s) as extra gates of {}: {}", alts.len(), parsed.name,- alts.iter().map(|a| format!("{} ({})", a.gate, a.sym.name)).collect::<Vec<_>>().join(", "));- }- let lbr = generate_lbr_gates(&parsed, fp_content.as_deref(), art.as_ref(), symbol_art_scale, &pin_map, &part_attrs, &alts, &pkg_variants);-- // Losses are decided here, once, before anything is linted or- // written: the parser only collected the margin pads.- let lost: Vec<LostFeature> = eagle_lost_feature(&parsed.name, eagle_margin_drain()).into_iter().collect();- for l in &lost { report_lost(l); }- if let Some(why) = strict_refusal(cli.strict, "EAGLE/Fusion", &lost) {- eprintln!("ERROR: [LOST_FEATURES] {why}");- process::exit(2);- }-- // 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);- sourcing_hint(&sym_txt, &mpn);- heatsink_hint(&fp_txt, &mpn);- // 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")));- let json_out = serde_json::to_string_pretty(&part).unwrap();- let sha = write_verified(&outp, json_out.as_bytes());- println!("OK: wrote {} (adom-lbr JSON — the canonical hub)", outp.display());- written_hint(&outp, json_out.len(), &sha);- 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 lost: Vec<LostFeature> = altium_lost_feature(&p.mpn, &p.footprint).into_iter().collect();- for l in &lost { report_lost(l); }- if let Some(why) = strict_refusal(cli.strict, "Altium", &lost) {- eprintln!("ERROR: [LOST_FEATURES] {why}");- process::exit(2);- }- 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));- let sha_s = write_verified(&sp, &sym_bytes);- let sha_p = write_verified(&pp, &fp_bytes);- println!("OK: wrote {} ({} pins) + {} ({} pads)",- sp.display(), p.symbol.pins.len(), pp.display(), p.footprint.pads.len());- written_hint(&sp, sym_bytes.len(), &sha_s);- written_hint(&pp, fp_bytes.len(), &sha_p);- }-- Commands::AssembleAltium { out, parts, no_dedupe } => {- use serde_json::{json, Value};- use std::collections::{BTreeMap, BTreeSet};- let mut symbols: Vec<altium_codec::Symbol> = Vec::new();- let mut fps: Vec<altium_codec::Footprint> = Vec::new();- // Content key (footprint minus its name) -> the shipped footprint- // name, so identical land patterns collapse to ONE footprint (and- // ONE 3D binding downstream), mirroring the EAGLE assemble and the- // 2.21.0 package-sharing rule.- let mut models: Vec<Option<altium_codec::EmbeddedModel>> = Vec::new();- let mut fp_index: BTreeMap<String, usize> = BTreeMap::new();- let mut model_missing: Vec<Value> = Vec::new();- let mut lost_features: Vec<Value> = Vec::new();- let mut refused: Vec<Value> = Vec::new();- let mut fp_by_content: BTreeMap<String, String> = BTreeMap::new();- let mut fp_shared: BTreeMap<String, Vec<String>> = BTreeMap::new();- let mut fp_names: BTreeSet<String> = BTreeSet::new();- let mut comp_names: BTreeSet<String> = BTreeSet::new();- let mut renamed: Vec<Value> = Vec::new();- let mut skipped: Vec<Value> = Vec::new();- let unique = |taken: &BTreeSet<String>, want: &str| -> String {- if !taken.contains(want) { return want.to_string(); }- (2..).map(|i| format!("{want}@{i}")).find(|c| !taken.contains(c)).unwrap()- };- for path in &parts {- let raw = match fs::read_to_string(path) {- Ok(r) => r,- Err(e) => { skipped.push(json!({"part": path.display().to_string(), "reason": format!("read: {e}")})); continue; }- };- let p: altium_codec::AdomLbrPart = match serde_json::from_str(&raw) {- Ok(p) => p,- Err(e) => { skipped.push(json!({"part": path.display().to_string(), "reason": format!("parse: {e}")})); continue; }- };- let mut sym = p.symbol.clone();- seed_parameters(&mut sym, &p);- if let Some(l) = altium_lost_feature(&p.mpn, &p.footprint) {- report_lost(&l);- if let Some(why) = strict_refusal(cli.strict, "Altium", std::slice::from_ref(&l)) {- // Under --strict the part is refused, the rest still- // assemble; the loss stays a NAMED entry, never silent.- skipped.push(json!({"part": path.display().to_string(), "reason": why}));- refused.push(l.json());- continue;- }- lost_features.push(l.json());- }- // The component a user picks in Altium is the ORDERABLE part,- // so it takes the MPN (the deviceset=MPN doctrine); the source- // symbol name is often a shared class name like Conn_01x02.- let want = if p.mpn.is_empty() { sym.name.clone() } else { p.mpn.clone() };- let comp_name = unique(&comp_names, &want);- if comp_name != want {- renamed.push(json!({"kind": "component", "from": want, "to": comp_name, "part": p.mpn}));- }- comp_names.insert(comp_name.clone());- sym.name = comp_name;- // Footprint content-dedup: same geometry = one footprint.- let mut content_probe = p.footprint.clone();- content_probe.name = if no_dedupe { p.footprint.name.clone() } else { String::new() };- let key = serde_json::to_string(&content_probe).unwrap_or_default();- // The part's STEP body (wiki adom-lbr#22): model_3d as a path- // (relative to the JSON), else <mpn>.step beside it. A missing- // model is a NAMED report entry, never silent.- // Path::parent() on a BARE file name yields Some("") rather- // than None, and read_dir("") finds nothing, so a part passed- // as a plain relative name silently lost its sibling STEP- // (wiki #23). Empty means the current directory.- let json_dir = match path.parent() {- Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),- _ => PathBuf::from("."),- };- let step_path = p.model_3d.as_deref().filter(|m| !m.is_empty())- .map(|m| { let mp = PathBuf::from(m); if mp.is_absolute() { mp } else { json_dir.join(mp) } })- .filter(|mp| mp.is_file())- .or_else(|| {- // wiki pages store the STEP under the lowercase slug- // stem, so match the <mpn>.step candidate by- // case-insensitive file name.- let want: Vec<String> = ["step", "stp"].iter()- .map(|e| format!("{}.{e}", p.mpn).to_lowercase()).collect();- fs::read_dir(&json_dir).ok().and_then(|rd| {- rd.flatten()- .map(|e| e.path())- .find(|c| c.file_name()- .map(|n| want.contains(&n.to_string_lossy().to_lowercase()))- .unwrap_or(false))- })- });- let part_model = step_path.and_then(|sp| match fs::read(&sp) {- Ok(step) => Some(altium_codec::EmbeddedModel {- name: format!("{}.step", p.mpn), step,- rot_x: 0.0, rot_y: 0.0, rot_z: 0.0, dz_mil: 0.0, overall_height_mil: 0.0,- }),- Err(e) => { model_missing.push(json!({"part": p.mpn, "reason": format!("read {}: {e}", sp.display())})); None }- });- if part_model.is_none() && model_missing.iter().all(|m| m["part"] != json!(p.mpn.clone())) {- model_missing.push(json!({"part": p.mpn,- "reason": "no 3D model: model_3d is empty and no <mpn>.step sits beside the JSON"}));- }- let fp_name = if let Some(existing) = fp_by_content.get(&key) {- fp_shared.entry(existing.clone()).or_default().push(p.mpn.clone());- // A later part can supply the model its shared footprint lacks.- if let (Some(i), Some(m)) = (fp_index.get(existing), part_model) {- if models[*i].is_none() { models[*i] = Some(m); }- }- existing.clone()- } else {- let want_fp = if p.footprint.name.is_empty() { format!("{}-fp", p.mpn) } else { p.footprint.name.clone() };- let n = unique(&fp_names, &want_fp);- if n != want_fp {- renamed.push(json!({"kind": "footprint", "from": want_fp, "to": n, "part": p.mpn}));- }- fp_names.insert(n.clone());- fp_by_content.insert(key, n.clone());- fp_shared.insert(n.clone(), vec![p.mpn.clone()]);- let mut f = p.footprint.clone();- f.name = n.clone();- fp_index.insert(n.clone(), fps.len());- fps.push(f);- models.push(part_model);- n- };- sym.footprint = Some(fp_name);- symbols.push(sym);- }- if symbols.is_empty() {- println!("{}", json!({"status": "error", "error": "no parts assembled", "skipped": skipped}));- process::exit(2);- }- let sch_bytes = altium_codec::encode_schlib(&symbols)- .unwrap_or_else(|e| { println!("{}", json!({"status":"error","error": format!("encode SchLib: {e:#}"), "skipped": skipped})); process::exit(2); });- let pcb_bytes = altium_codec::encode_pcblib_many_with_models(&fps, &models)- .unwrap_or_else(|e| { println!("{}", json!({"status":"error","error": format!("encode PcbLib: {e:#}"), "skipped": skipped})); process::exit(2); });- let base = out.with_extension("");- let sp = base.with_extension("SchLib");- let pp = base.with_extension("PcbLib");- if let Err(e) = fs::write(&sp, &sch_bytes) { println!("{}", json!({"status":"error","error": format!("write {}: {e}", sp.display())})); process::exit(2); }- if let Err(e) = fs::write(&pp, &pcb_bytes) { println!("{}", json!({"status":"error","error": format!("write {}: {e}", pp.display())})); process::exit(2); }- let deduped: Vec<Value> = fp_shared.iter().filter(|(_, v)| v.len() > 1)- .map(|(k, v)| json!({"footprint": k, "sharedBy": v})).collect();- let partial = !refused.is_empty();- println!("{}", serde_json::to_string_pretty(&json!({- "status": if partial { "partial" } else { "ok" },- "schlib": sp.display().to_string(),- "pcblib": pp.display().to_string(),- "components": symbols.len(),- "footprints": fps.len(),- "deduped": deduped,- "renamed": renamed,- "skipped": skipped,- "models": {- "embedded": models.iter().filter(|m| m.is_some()).count(),- "missing": model_missing,- },- "lostFeatures": lost_features,- "refused": refused,- "_hint": "components are named by MPN; identical land patterns share one footprint. skipped[] is the complete drop list, never silent. lostFeatures[] names what the Altium lane could not carry (warn and ship); under --strict those parts move to refused[] and skipped[], the rest still assemble, status is partial and the exit code 2 while the files written stay valid."- })).unwrap());- if partial {- process::exit(2);- }- }-- Commands::ExportIntlib { part, out } => {- let p = load_part(&part);- let lost: Vec<LostFeature> = altium_lost_feature(&p.mpn, &p.footprint).into_iter().collect();- for l in &lost { report_lost(l); }- if let Some(why) = strict_refusal(cli.strict, "Altium", &lost) {- eprintln!("ERROR: [LOST_FEATURES] {why}");- process::exit(2);- }- 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)));- let sha = write_verified(&outp, &bytes);- println!("OK: wrote {} ({} bytes) — symbol + footprint bundled", outp.display(), bytes.len());- written_hint(&outp, bytes.len(), &sha);- }-- 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));- let sha_s = write_verified(&sp, sym_txt.as_bytes());- let sha_f = write_verified(&fp, fp_txt.as_bytes());- println!("OK: wrote {} ({} pins) + {} ({} pads, {} graphics)",- sp.display(), p.symbol.pins.len(), fp.display(),- p.footprint.pads.len(), p.footprint.graphics.len());- written_hint(&sp, sym_txt.len(), &sha_s);- written_hint(&fp, fp_txt.len(), &sha_f);- }-- 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"));- let sha = write_verified(&outp, &merged);- println!("OK: wrote {} — added {} symbol(s) into the library", outp.display(), syms.len());- written_hint(&outp, merged.len(), &sha);- }-- 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")));- let json_out = serde_json::to_string_pretty(&part).unwrap();- let sha = write_verified(&outp, json_out.as_bytes());- println!("OK: wrote {} — {} pins, {} pads, {} footprint graphics",- outp.display(), part.symbol.pins.len(), part.footprint.pads.len(), part.footprint.graphics.len());- written_hint(&outp, json_out.len(), &sha);- }-- 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");- write_verified(&skill_dir.join("SKILL.md"), SKILL.as_bytes());- 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 lost_feature_tests {- use super::*;-- #[test]- fn eagle_collector_dedupes_across_parses_and_drains() {- // the parser runs several times per part; the collector keeps one- // entry per pad in source order and empties on drain- let km = r#"(footprint "M"- (pad "1" smd rect (at 0 0) (size 1 1) (solder_mask_margin -0.05))- (pad "2" smd rect (at 1 0) (size 1 1) (solder_paste_margin -0.05))- (pad "3" smd rect (at 2 0) (size 1 1)))"#;- let _ = eagle_margin_drain();- let _ = parse_mod_pads(km);- let _ = parse_mod_pads(km);- let _ = parse_mod_pads(km);- let pads = eagle_margin_drain();- assert_eq!(pads, vec!["1".to_string(), "2".to_string()], "one entry per pad, source order");- assert!(eagle_margin_drain().is_empty(), "drain empties the collector");- let lost = eagle_lost_feature("M", pads).expect("a loss");- assert_eq!(lost.code, "lost-pad-margins");- assert_eq!(lost.json()["pads"], serde_json::json!(["1", "2"]));- assert!(eagle_lost_feature("M", vec![]).is_none(), "no margin pads, no loss");- }-- #[test]- fn strict_refuses_only_when_something_is_lost() {- let l = LostFeature { code: "lost-pad-margins", part: "P".into(),- feature: "per-pad solder-mask/paste margins".into(), pads: vec!["1".into(), "2".into()],- effect: "x".into() };- assert!(strict_refusal(false, "EAGLE/Fusion", std::slice::from_ref(&l)).is_none(), "default ships");- assert!(strict_refusal(true, "EAGLE/Fusion", &[]).is_none(), "nothing lost, nothing refused");- let why = strict_refusal(true, "EAGLE/Fusion", std::slice::from_ref(&l)).expect("refused");- assert_eq!(why, "nothing written for the EAGLE/Fusion lane: refused under --strict: per-pad solder-mask/paste margins on pads 1,2");- }-- #[test]- fn altium_lane_loses_only_the_paste_ratio() {- let sym = r#"(kicad_symbol_lib (version 20231120) (generator "t")- (symbol "P" (property "Reference" "U" (at 0 0 0))- (symbol "P_1_1"- (pin passive line (at -5.08 0 0) (length 2.54) (name "A" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))- (pin passive line (at -5.08 -2.54 0) (length 2.54) (name "B" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))))"#;- let km = r#"(footprint "P" (layer "F.Cu")- (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu" "F.Paste" "F.Mask") (solder_mask_margin -0.05) (solder_paste_margin -0.05))- (pad "2" smd rect (at 2 0) (size 1 1) (layers "F.Cu" "F.Paste" "F.Mask") (solder_paste_margin_ratio -0.1)))"#;- let part = altium_codec::import_kicad(sym, km, "P").expect("import");- let lost = altium_lost_feature(&part.mpn, &part.footprint).expect("the ratio is a loss");- assert_eq!(lost.pads, vec!["2".to_string()], "mm margins on pad 1 are carried; only the ratio pad is lost");- assert_eq!(lost.feature, "per-pad solder-paste ratio");- let mut carried = part.footprint.clone();- carried.pads.retain(|p| p.number == "1");- assert!(altium_lost_feature("P", &carried).is_none(), "mm margins alone are not a loss on the Altium lane");- }-}--#[cfg(test)]-mod lint_tests {- #[test]- fn write_verified_creates_the_directory_and_returns_the_hash() {- use sha2::{Digest, Sha256};- let dir = std::env::temp_dir().join(format!("adom-lbr-wv-{}-{}", std::process::id(),- std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()));- let path = dir.join("nested").join("out.txt");- assert!(!dir.exists(), "fresh temp dir must not pre-exist");- let sha = super::write_verified(&path, b"hello export");- assert_eq!(std::fs::read(&path).unwrap(), b"hello export", "the file must exist with the bytes");- assert_eq!(sha, format!("{:x}", Sha256::digest(b"hello export")));- let _ = std::fs::remove_dir_all(&dir);- }-- use super::{dedup_pad_names, lint_lbr};-- // ---- 2.21.0 / 2.22.x conversion fidelity -------------------------------------- // Every one of these guards a bug that reached a user's Fusion install, so each test- // names the symptom rather than the function.-- #[test]- fn kicad_pin_length_maps_to_the_nearest_eagle_stop_not_the_next_one_up() {- use super::pin_length_keyword;- // 2.794 is the standard 0.11 in pin on the wiki's passives. The old ceiling rule- // sent it to "middle" (5.08), so a two-pin part drew as one line with its pins- // overlapping by 2.54 mm.- assert_eq!(pin_length_keyword(2.794), "short");- assert_eq!(pin_length_keyword(0.0), "point");- assert_eq!(pin_length_keyword(2.54), "short");- assert_eq!(pin_length_keyword(5.08), "middle");- assert_eq!(pin_length_keyword(7.62), "long");- assert_eq!(pin_length_keyword(20.0), "long");- // a value nearer the upper stop still rounds up- assert_eq!(pin_length_keyword(4.9), "middle");- }-- #[test]- fn unnamed_pins_take_the_pad_number_and_a_pin_really_called_p_survives() {- use super::{dedup_pin_names, KicadPin};- let pin = |name: &str, number: &str| KicadPin {- name: name.to_string(), number: number.to_string(),- x: 0.0, y: 0.0, angle: 0, length: 2.54, pin_type: "passive".to_string(),- };- // "~" is KiCad's unnamed marker; it used to sanitise to the literal "P", so an- // unnamed two-pin part shipped pins called "P" and "P@2".- let got = dedup_pin_names(&[pin("~", "1"), pin("~", "2")]);- assert_eq!(got, vec!["1".to_string(), "2".to_string()]);- // an empty name behaves the same way- assert_eq!(dedup_pin_names(&[pin("", "7")]), vec!["7".to_string()]);- // ...but a pin genuinely named P keeps its name- assert_eq!(dedup_pin_names(&[pin("P", "3")]), vec!["P".to_string()]);- // and real names are untouched, including the @-suffix for a repeat- assert_eq!(dedup_pin_names(&[pin("GND", "4"), pin("GND", "5")]),- vec!["GND".to_string(), "GND@2".to_string()]);- }-- #[test]- fn catalog_boilerplate_is_stripped_without_mangling_decimals() {- use super::strip_catalog_boilerplate;- assert_eq!(- strip_catalog_boilerplate("WCH USB Converters, SOP-16. Datasheet-verified pinout, KiCad symbol/footprint/3D."),- "WCH USB Converters, SOP-16");- // the first version split on every '.' and rejoined, turning a voltage into "2. 5V"- assert_eq!(strip_catalog_boilerplate("2.5V precision reference"), "2.5V precision reference");- assert_eq!(strip_catalog_boilerplate("STM32F103. 72 MHz Cortex-M3"), "STM32F103. 72 MHz Cortex-M3");- // nothing but boilerplate: keep the original rather than emit an empty description- assert!(!strip_catalog_boilerplate("Datasheet-verified pinout").is_empty());- // the real ray/srn6045-100m brief ends with a BARE "Datasheet-verified.", which the- // longer phrase did not match, so it survived on the end of the description- assert_eq!(- strip_catalog_boilerplate("Bourns 10 uH semi-shielded SMD power inductor, 6.0x6.0 mm. 3.0 A Isat, 58.6 mOhm DCR. Datasheet-verified."),- "Bourns 10 uH semi-shielded SMD power inductor, 6.0x6.0 mm. 3.0 A Isat, 58.6 mOhm DCR");- // and the longer form must not strand the word "pinout"- assert!(!strip_catalog_boilerplate("ADR4525 reference. Datasheet-verified pinout.").contains("pinout"));- }-- #[test]- fn package_description_leads_with_geometry_and_never_states_a_bad_measurement() {- use super::package_description;- // Fusion's Description column shows ~20 characters, so the identifying words go first.- assert_eq!(package_description(None, "SOIC-8_3.9x4.9mm_P1.27mm"),- "SOIC-8, 3.9 x 4.9 mm, 1.27 mm pitch");- // a descr that merely restates the geometry is dropped rather than appended- let restating = r#"(footprint "SOIC-8_3.9x4.9mm_P1.27mm" (descr "SOIC, 8 Pin"))"#;- assert_eq!(package_description(Some(restating), "SOIC-8_3.9x4.9mm_P1.27mm"),- "SOIC-8, 3.9 x 4.9 mm, 1.27 mm pitch");- // a descr that adds something is appended AFTER the geometry- let adds = r#"(footprint "SOIC-8_3.9x4.9mm_P1.27mm" (descr "Analog Devices precision reference"))"#;- assert!(package_description(Some(adds), "SOIC-8_3.9x4.9mm_P1.27mm")- .starts_with("SOIC-8, 3.9 x 4.9 mm, 1.27 mm pitch. "));- // a URL in the descr does not leave the bracket that introduced it behind- let urly = r#"(footprint "X" (descr "SOIC, 8 Pin (JEDEC MS-012AA, https://example.com/x.pdf)"))"#;- let out = package_description(Some(urly), "X");- assert!(!out.contains("http"), "{out}");- assert!(!out.ends_with('('), "{out}");- assert!(out.matches('(').count() == out.matches(')').count(), "unbalanced: {out}");- }-- #[test]- fn a_footprint_named_by_mpn_is_measured_rather_than_repeating_its_own_name() {- use super::package_description;- // Two 1.0 x 0.5 mm pads 1.0 mm apart: a plain chip land pattern.- let km = r#"(footprint "0603N101J500CT"- (pad "1" smd rect (at -0.75 0) (size 1.0 0.5) (layers "F.Cu"))- (pad "2" smd rect (at 0.75 0) (size 1.0 0.5) (layers "F.Cu")))"#;- let out = package_description(Some(km), "0603N101J500CT");- assert!(out.starts_with("2 pads,"), "{out}");- assert!(!out.contains("0603N101J500CT"), "must not repeat the Name column: {out}");- // The first version measured the SILKSCREEN, which on a chip part is two ticks, and- // reported "0.6 x 0.0 mm". A confidently wrong measurement is worse than none.- assert!(!out.contains("0.0 mm"), "{out}");- assert!(out.contains("2.5 x 0.5 mm"), "expected the copper extent: {out}");- }-- #[test]- fn group_captions_map_to_eagle_alignment_not_a_bare_right() {- use super::parse_kicad_sym;- // adom-symbol's four caption forms, verbatim in shape: left and right unrotated,- // top and bottom rotated, with the angle written in TENTHS of a degree (900 = 90).- let src = r#"(kicad_symbol_lib (version 20211014) (generator adom)- (symbol "T" (property "Reference" "U" (id 0) (at 0 0 0))- (symbol "T_1_1"- (text "Handshake" (at -11.68 0.00 0)- (effects (font (size 0.762 0.762)) (justify left)))- (text "UART" (at 11.68 0.00 0)- (effects (font (size 0.762 0.762)) (justify right)))- (text "Power" (at -7.72 7.08 900)- (effects (font (size 0.762 0.762)) (justify right)))- (text "Ground" (at -7.72 -22.65 900)- (effects (font (size 0.762 0.762)) (justify left)))- (pin passive line (at -15.24 0 0) (length 2.54)- (name "A" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))- )))"#;- let sym = parse_kicad_sym(src, None).expect("parses");- let by = |t: &str| sym.group_labels.iter().find(|g| g.text == t).expect(t).clone();- // left: unrotated, anchored on its left end- let l = by("Handshake"); assert_eq!(l.angle, 0); assert!(!l.justify_right);- // right: unrotated, anchored on its RIGHT end. Emitting a bare align="right" here- // is what made Fusion warn and fall back to left anchoring, so the caption ran out- // across the body line.- let r = by("UART"); assert_eq!(r.angle, 0); assert!(r.justify_right);- // top and bottom: rotated. 900 is tenths of a degree; reading it as degrees gives- // 900 % 360 = 180 and loses the rotation.- let t = by("Power"); assert_eq!(t.angle, 90); assert!(t.justify_right);- let b = by("Ground"); assert_eq!(b.angle, 90); assert!(!b.justify_right);- }-- #[test]- fn user_layers_are_mapped_and_declared_rather_than_dropped() {- use super::{kicad_layer_to_eagle, used_user_layers, user_layer_decls};- // An unmapped layer returned None and the geometry vanished: the LAN7800 build lost- // 296 solder-ball circles that way, with nothing said.- assert_eq!(kicad_layer_to_eagle("Adom.SolderBalls"), Some(101));- assert_eq!(kicad_layer_to_eagle("User.9"), Some(101));- assert_eq!(kicad_layer_to_eagle("Adom.HeatsinkVias"), Some(102));- assert_eq!(kicad_layer_to_eagle("User.4"), Some(114));- assert_eq!(kicad_layer_to_eagle("F.SilkS"), Some(21)); // unchanged- assert_eq!(kicad_layer_to_eagle("F.Mask"), None); // still skipped- let km = r#"(footprint "T" (fp_circle (center 0 0) (end 1 0) (layer "Adom.SolderBalls"))- (fp_line (start 0 0) (end 1 1) (layer "User.4")))"#;- let used = used_user_layers(km);- assert!(used.contains(&101) && used.contains(&114) && used.len() == 2);- // Fusion IGNORES geometry on a layer the file never declares, so the declaration is- // not decoration.- let decls = user_layer_decls(&used);- assert!(decls.contains(r#"<layer number="101" name="SolderBalls""#), "{decls}");- assert!(decls.contains(r#"<layer number="114" name="User4""#), "{decls}");- // and layers nothing draws on are not declared- assert!(!decls.contains("102"), "{decls}");- }-- #[test]- fn a_tall_thru_pad_elongates_along_its_own_long_axis() {- use super::tht_pad_geometry;- // The JST PH pad from wiki #19: 1.2 wide, 1.75 tall, on a 2.0 mm pitch. Passing- // dx.max(dy) as the diameter drew it 3.5 x 1.75 elongated along X, so its copper- // reached 0.35 mm inside its neighbour: a short between pin 1 and pin 2.- let (shape, dia, rot, warn) = tht_pad_geometry("oval", 1.2, 1.75);- assert_eq!(shape, "long");- assert_eq!(dia, 1.2, "diameter is the SHORT side");- assert_eq!(rot, 90.0, "a tall pad elongates along Y, which EAGLE writes as R90");- assert!(warn.is_some(), "the fixed 2:1 draws 2.4 where the source said 1.75");- // a WIDE oval keeps the same rule with no rotation- let (_, dia, rot, _) = tht_pad_geometry("oval", 1.75, 1.2);- assert_eq!((dia, rot), (1.2, 0.0));- // roundrect has no thru-hole equivalent: elongated becomes long...- let (shape, dia, _, warn) = tht_pad_geometry("roundrect", 1.2, 1.75);- assert_eq!((shape.as_str(), dia), ("long", 1.2));- assert!(warn.is_some());- // ...and a squarish one becomes round on the SHORT side, never the long one, and- // says that it dropped something.- let (shape, dia, _, warn) = tht_pad_geometry("roundrect", 1.2, 1.3);- assert_eq!((shape.as_str(), dia), ("round", 1.2));- assert!(warn.is_some(), "silently rounding away the long side is what this fixes");- // an ordinary circle is untouched- assert_eq!(tht_pad_geometry("circle", 1.2, 1.2).0, "round");- }-- #[test]- fn pads_that_overlap_are_a_lint_error_but_same_net_vias_are_not() {- use super::lint_lbr;- let lib = |pads: &str| format!(r#"<eagle><drawing><layers></layers><library>- <packages><package name="P">{pads}</package></packages>- <symbols><symbol name="S"><pin name="A" x="0" y="0" length="short"/></symbol></symbols>- <devicesets><deviceset name="D"><gates><gate name="G$1" symbol="S" x="0" y="0"/></gates>- <devices><device name="" package="P"><connects><connect gate="G$1" pin="A" pad="1"/></connects>- <technologies><technology name=""/></technologies></device></devices></deviceset></devicesets>- </library></drawing></eagle>"#);- // the shipped defect: a long pad drawn 2:1 along X reaches into its neighbour- let bad = lib(r#"<pad name="1" x="0" y="0" drill="0.75" diameter="1.2" shape="round"/>- <pad name="2" x="2.0" y="0" drill="0.75" diameter="1.75" shape="long"/>"#);- let r = lint_lbr(&bad);- assert!(!r.passed, "overlapping copper must fail");- assert!(r.errors.iter().any(|e| e.code == "pad-clearance"), "{:?}", r.errors);- // the same pads mapped correctly: short side as diameter, rotated to the long axis- let good = lib(r#"<pad name="1" x="0" y="0" drill="0.75" diameter="1.2" shape="long" rot="R90"/>- <pad name="2" x="2.0" y="0" drill="0.75" diameter="1.2" shape="long" rot="R90"/>"#);- assert!(!lint_lbr(&good).errors.iter().any(|e| e.code == "pad-clearance"));- // heatsink vias sit INSIDE the pad they vent and share its net, so they are not a- // clearance problem: comparing them would fail every thermal-via footprint.- let vias = lib(r#"<smd name="EP" x="0" y="0" dx="2" dy="2"/>- <smd name="EP@2" x="0.5" y="0.5" dx="0.4" dy="0.4"/>- <smd name="EP@3" x="-0.5" y="0.5" dx="0.4" dy="0.4"/>"#);- assert!(!lint_lbr(&vias).errors.iter().any(|e| e.code == "pad-clearance"));- }-- #[test]- fn fine_pitch_gaps_warn_instead_of_failing_and_the_exact_boundary_passes_clean() {- // wiki #20: a flat 0.2 mm ERROR rejected 559 of 1314 stock KiCad footprints,- // and 225 of them sat at exactly 0.200 mm because of float representation.- use super::lint_lbr;- let lib = |pads: &str| format!(r#"<eagle><drawing><layers></layers><library>- <packages><package name="P">{pads}</package></packages>- <symbols><symbol name="S"><pin name="A" x="0" y="0" length="short"/></symbol></symbols>- <devicesets><deviceset name="D"><gates><gate name="G$1" symbol="S" x="0" y="0"/></gates>- <devices><device name="" package="P"><connects><connect gate="G$1" pin="A" pad="1"/></connects>- <technologies><technology name=""/></technologies></device></devices></deviceset></devicesets>- </library></drawing></eagle>"#);- // a 0.5 mm-pitch SOT-563 row: 0.150 mm apart by construction. WARNING, never error.- let fine = lib(r#"<smd name="1" x="0" y="0" dx="0.675" dy="0.35"/>- <smd name="2" x="0" y="0.5" dx="0.675" dy="0.35"/>"#);- let r = lint_lbr(&fine);- assert!(r.errors.iter().all(|e| e.code != "pad-clearance"), "fine pitch must not FAIL: {:?}", r.errors);- assert!(r.warnings.iter().any(|w| w.code == "pad-clearance"), "but it should still WARN: {:?}", r.warnings);- // exactly 0.200 mm apart: 1.0 mm rect pads at x -0.6 and 0.6 compute a gap of- // 0.19999999999999996 in floats. The epsilon keeps the rule's own wording true:- // "closer than 0.2 mm" cannot reject "0.200 mm apart". Clean, not even a warning.- let boundary = lib(r#"<smd name="1" x="-0.6" y="0" dx="1.0" dy="1.0"/>- <smd name="2" x="0.6" y="0" dx="1.0" dy="1.0"/>"#);- let rb = lint_lbr(&boundary);- assert!(rb.errors.iter().all(|e| e.code != "pad-clearance"), "{:?}", rb.errors);- assert!(rb.warnings.iter().all(|w| w.code != "pad-clearance"), "{:?}", rb.warnings);- // negative control: genuinely overlapping copper is still a hard error.- let overlap = lib(r#"<smd name="1" x="0" y="0" dx="1.2" dy="1.2"/>- <smd name="2" x="1.0" y="0" dx="1.2" dy="1.2"/>"#);- assert!(lint_lbr(&overlap).errors.iter().any(|e| e.code == "pad-clearance"));- }-- #[test]- fn roundrect_pads_carry_their_corner_radius_into_eagle() {- use super::parse_mod_pads;- let km = r#"(footprint "T"- (pad "1" smd roundrect (at -2 0) (size 1.0 0.5) (roundrect_rratio 0.25) (layers "F.Cu"))- (pad "2" smd roundrect (at 2 0) (size 1.0 0.5) (roundrect_rratio 0.5) (layers "F.Cu"))- (pad "3" smd rect (at 0 2) (size 1.0 0.5) (layers "F.Cu"))- (pad "4" smd oval (at 0 -2) (size 1.0 0.5) (layers "F.Cu")))"#;- let pads = parse_mod_pads(km);- let r = |n: &str| pads.iter().find(|p| p.name == n).unwrap().roundness;- // KiCad states the radius as a RATIO of the smaller side, EAGLE as a PERCENT of it,- // and a ratio of 0.5 is a fully rounded end, so N = r * 200.- assert_eq!(r("1"), Some(50));- assert_eq!(r("2"), Some(100));- // a plain rect stays sharp: absent, not zero, so the attribute is simply not emitted- assert_eq!(r("3"), None);- // an OVAL pad is a stadium, which is a fully rounded roundrect. Not in the original- // ask, but it is what oval MEANS, and these were importing square too.- assert_eq!(r("4"), Some(100));- }-- #[test]- fn a_heatsink_via_joins_the_pad_it_sits_inside() {- use super::{owning_pad, parse_mod_pads};- let km = r#"(footprint "T"- (pad "EP" smd rect (at 0 0) (size 2 2) (layers "F.Cu"))- (pad "1" smd rect (at -5 0) (size 1 1) (layers "F.Cu"))- (pad "V1" thru_hole circle (at 0.5 0.5) (size 0.4 0.4) (drill 0.2) (layers "*.Cu"))- (pad "V9" thru_hole circle (at 9 9) (size 0.4 0.4) (drill 0.2) (layers "*.Cu")))"#;- let pads = parse_mod_pads(km);- let base: Vec<_> = parse_mod_pads(km).into_iter().filter(|p| p.name == "EP" || p.name == "1").collect();- let v1 = pads.iter().find(|p| p.name == "V1").unwrap();- let v9 = pads.iter().find(|p| p.name == "V9").unwrap();- // A via inside the thermal pad joins it: naming cannot decide this ("V1" says- // nothing about which pin it serves) and a bare footprint carries no nets.- assert_eq!(owning_pad(v1, &base).map(|p| p.name.as_str()), Some("EP"));- // ...and one outside every pad belongs to nothing, which is warned about rather- // than silently attached to whatever happens to be nearest.- assert!(owning_pad(v9, &base).is_none());- }-- #[test]- fn a_symbol_that_draws_itself_is_not_relaid_out() {- use super::{relayout_two_columns, KicadPin, ParsedSymbol, SymGraphic};- let pin = |number: &str, angle: u32, y: f64| KicadPin {- name: "~".to_string(), number: number.to_string(),- x: 0.0, y, angle, length: 2.794, pin_type: "passive".to_string(),- };- // A vertical two-terminal passive: pins on top and bottom, which is the same tell- // an IC laid out like its package gives, plus artwork drawn around those positions.- let mk = |graphics: Vec<SymGraphic>| ParsedSymbol {- name: "C".into(), reference: "C".into(), value: "100pF".into(),- footprint: String::new(), description: String::new(),- pins: vec![pin("1", 270, 3.81), pin("2", 90, -3.81)],- body: None, group_labels: vec![], graphics,- package_name: None, package_description: None, deviceset_name: None,- ref_pos: None, val_pos: None,- };- // with its own drawing (the capacitor's two plates) it must be LEFT ALONE- let mut drawn = mk(vec![SymGraphic::Poly(vec![(-2.032, 0.762), (2.032, 0.762)], 0.0)]);- assert!(!relayout_two_columns(&mut drawn, false));- assert_eq!(drawn.pins[0].y, 3.81, "pins must not move");- // ...but forcing it is still allowed for a caller who means it- let mut forced = mk(vec![SymGraphic::Poly(vec![(-2.032, 0.762), (2.032, 0.762)], 0.0)]);- assert!(relayout_two_columns(&mut forced, true));- // and a symbol with NO artwork of its own still gets the EE-convention relayout- let mut plain = mk(vec![]);- assert!(relayout_two_columns(&mut plain, false));- }-- #[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 {- package_name: None,- package_description: None,- ref_pos: None,- val_pos: None,- deviceset_name: None,- 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);- }-}+ "rect" => {+ // EAGLE has no rectangular thru pad. A square on the LONG side grew KiCad's stock+ // TO-92_Inline pad 1 (1.05 x 1.5 mm at 1.27 mm pitch) into a 1.5 mm square whose+ // copper reached 0.005 mm into pad 2: a real short in the .lbr, which the clearance+ // lint rightly rejected (21 TO-92 parts dropped from the factory, 2026-09-23). Same+ // rule as roundrect below: an elongated pad is drawn `long` on its SHORT side along+ // its length; a squarish one stays square on its SHORT side. Both stay inside the+ // KiCad copper's width, never outside it.+ if aspect > 1.2 {+ let drawn = 2.0 * short;+ ("long".into(), short, rot,+ Some(format!("rect thru pad {short:.2} x {long:.2} mm has no EAGLE equivalent; drawn as a long pad {short:.2} x {drawn:.2} mm (the square pin-1 corner is lost; mark pin 1 in silk)")))+ } else {+ let warn = if long - short > 0.05 {+ Some(format!("rect thru pad {short:.2} x {long:.2} mm drawn square at {short:.2} mm; the long side has no EAGLE equivalent on a thru pad"))+ } else { None };+ ("square".into(), short, 0.0, warn)+ }+ }+ "oval" => {+ // EAGLE's 2:1 is fixed, so the drawn length is 2 * short whatever the source+ // said. Worth stating when that overshoots by a visible margin rather than+ // letting the pad silently grow.+ let drawn = 2.0 * short;+ let warn = if (drawn - long).abs() > 0.3 {+ Some(format!("EAGLE draws a long pad at a fixed 2:1, so this {short:.2} x {long:.2} mm pad is drawn {short:.2} x {drawn:.2} mm"))+ } else { None };+ ("long".into(), short, rot, warn)+ }+ "roundrect" | "custom" => {+ // No roundness exists on a thru pad. An elongated one is closer to `long`; a+ // squarish one becomes round on its SHORT side, and says that it lost the rest,+ // because rounding on the LONG side is what grew a pad into its neighbour.+ if aspect > 1.2 {+ let drawn = 2.0 * short;+ let warn = if (drawn - long).abs() > 0.3 {+ Some(format!("roundrect thru pad {short:.2} x {long:.2} mm has no EAGLE equivalent; drawn as a long pad {short:.2} x {drawn:.2} mm"))+ } else { None };+ ("long".into(), short, rot, warn)+ } else {+ ("round".into(), short, 0.0,+ Some(format!("roundrect thru pad {short:.2} x {long:.2} mm drawn round at {short:.2} mm; the long side has no EAGLE equivalent on a thru pad")))+ }+ }+ _ => ("round".into(), short, 0.0, None),+ }+}++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()+}++/// One extra PACKAGE of the same part: the same silicon in a different footprint, or the+/// same footprint with extra geometry (heatsink vias as through pads, solder-ball outlines).+/// EAGLE models this as another `<device>` inside the SAME deviceset, which is why Fusion's+/// CHANGE PACKAGE swaps between them without re-wiring the schematic.+#[derive(Clone)]+pub(crate) struct PackageVariant {+ /// Device-name suffix, e.g. "-HV". The baseline device keeps the empty name.+ pub suffix: String,+ pub km: String,+ pub description: Option<String>,+}++/// Emit one `<package>` from a `.kicad_mod`, returning its pad groups for the connects.+/// This mirrors the baseline package branch rather than refactoring it: the baseline path+/// is exercised by every part we ship and is not worth destabilising for this.+fn emit_variant_package(xml: &mut String, km: &str, name: &str, description: &str)+ -> std::collections::HashMap<String, Vec<String>> {+ let pads = parse_mod_pads(km);+ xml.push_str(&format!("<package name=\"{}\">\n", esc_xml(name)));+ xml.push_str(&format!(" <description>{}</description>\n", esc_xml(description)));+ let pad_names: Vec<String> = pads.iter().map(|p| p.name.clone()).collect();+ let (out_names, groups) = dedup_pad_names(&pad_names);+ for (i, p) in 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),+ p.roundness.map(|r| format!(" roundness=\"{r}\"")).unwrap_or_default()));+ } else {+ let drill = p.drill.unwrap_or_else(|| p.dx.min(p.dy) * 0.6);+ let (tht_shape, diameter, extra_rot, warn) = tht_pad_geometry(&p.kicad_shape, p.dx, p.dy);+ if let Some(w) = warn { eprintln!("WARN: pad {}: {}", p.name, w); }+ 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, tht_shape, smd_rotation(p.angle + extra_rot)));+ }+ }+ xml.push_str(&package_graphics(km));+ xml.push_str("</package>\n");+ groups+}++/// Which BASELINE pad an extra variant pad belongs to, by geometry: a heatsink via sits+/// inside the thermal pad it vents, so the pad that CONTAINS its centre is the one whose+/// net it joins. Naming cannot decide this ("V1" says nothing about which pin it serves)+/// and a footprint on its own carries no nets, so position is the only honest signal.+fn owning_pad<'a>(extra: &ModPad, baseline: &'a [ModPad]) -> Option<&'a ModPad> {+ baseline.iter().find(|b| {+ (extra.x - b.x).abs() <= b.dx / 2.0 && (extra.y - b.y).abs() <= b.dy / 2.0+ })+}++/// 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),+ // USER LAYERS. KiCad's User.N and the named Adom.* layers carry real geometry that+ // was being dropped on the floor: the LAN7800 footprint lost 296 solder-ball circles+ // that way, silently, because an unmapped layer returned None. EAGLE reserves 1..99+ // and leaves 100..255 for user layers, so they are mapped there and DECLARED in the+ // <layers> block, since Fusion ignores geometry on a layer the file never declares.+ "Adom.SolderBalls" | "User.9" => Some(101),+ "Adom.HeatsinkVias" => Some(102),+ other => other.strip_prefix("User.")+ .and_then(|n| n.parse::<u32>().ok())+ .filter(|n| *n <= 145)+ .map(|n| 110 + n),+ }+}++/// Which user layers this footprint actually draws on.+fn used_user_layers(km: &str) -> std::collections::BTreeSet<u32> {+ let re = regex_lite::Regex::new(r#"\(layer "?([A-Za-z0-9_.]+)"?\)"#).unwrap();+ re.captures_iter(km)+ .filter_map(|c| kicad_layer_to_eagle(&c[1]))+ .filter(|n| *n >= 100)+ .collect()+}++/// The EAGLE `<layer>` declarations for whatever user layers a package actually draws on.+/// Only layers in use are declared: a file full of empty layer declarations is noise, and+/// Fusion lists every declared layer in its layer dialog whether or not anything is on it.+fn user_layer_decls(used: &std::collections::BTreeSet<u32>) -> String {+ let mut out = String::new();+ for n in used.iter().filter(|n| **n >= 100) {+ let name = match n {+ 101 => "SolderBalls".to_string(),+ 102 => "HeatsinkVias".to_string(),+ other => format!("User{}", other - 110),+ };+ // colour 13 is EAGLE's light-grey user default; fill 1 is solid, matching how the+ // silk layers are declared above.+ out.push_str(&format!(+ "<layer number=\"{}\" name=\"{}\" color=\"13\" fill=\"1\" visible=\"yes\" active=\"yes\"/>\n",+ n, name));+ }+ out+}++/// 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.+/// What adom-symbol states about a symbol's interior, all in kicad-symbol-local mm, Y-up,+/// the same space as the pins. adom-symbol is AUTHORITATIVE for these: it is the tool that+/// fits the icon and writes the KiCad Reference/Value properties, so taking its numbers is+/// what makes the KiCad render and the Fusion render agree pin for pin and label for label.+/// Two tools computing the same rectangle from different font models will always disagree+/// slightly, and the disagreement is invisible until someone measures it.+#[derive(Debug, Clone, Default)]+struct ContractLayout {+ /// Where the icon was fitted, already clear of the inside designators. When present it+ /// is honoured as given: re-centring it here would undo that fitting.+ icon_rect: Option<(f64, f64, f64, f64)>,+ /// Stroke width adom-symbol suggests for the icon, in mm. None means it did not say.+ stroke_mm: Option<f64>,+ /// Text CENTRES for >NAME and >VALUE. None means adom-symbol hid that designator.+ reference: Option<(f64, f64)>,+ value: Option<(f64, f64)>,+ inside_body: bool,+}++enum SymbolArt {+ Contract { mm_svg: String, dx: f64, svg_cy: f64, e_cy: f64, layout: ContractLayout },+ 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"),+ ) {+ // adom-symbol 1.1.12 and later also state the interior it laid out.+ let arr4 = |k: &str| -> Option<(f64, f64, f64, f64)> {+ let a = v.get(k)?.as_array()?;+ if a.len() < 4 { return None; }+ Some((a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?, a[3].as_f64()?))+ };+ let pt = |k: &str| -> Option<(f64, f64)> {+ let a = v.get("designators")?.get(k)?.as_array()?;+ if a.len() < 2 { return None; }+ Some((a[0].as_f64()?, a[1].as_f64()?))+ };+ let layout = ContractLayout {+ icon_rect: arr4("icon_rect"),+ stroke_mm: v.get("stroke_mm").and_then(|n| n.as_f64()),+ reference: pt("reference"),+ value: pt("value"),+ inside_body: v.get("designators").and_then(|d| d.get("inside_body"))+ .and_then(|b| b.as_bool()).unwrap_or(false),+ };+ return Ok(SymbolArt::Contract { mm_svg: content, dx: svg_cx - e_cx, svg_cy, e_cy, layout });+ }+ }+ }+ 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)`.+/// CENTRED ON THE BODY. The contract places art in exact symbol mm-space, which is right+/// when the symbol is drawn around the origin and wrong the moment it is not: measured on+/// the CH340G variant set, the body centres sit at y = 0, -4.61, -7.79, -20.05 and -27.86+/// depending on the layout, while the art stayed at the origin every time. The thumbnail+/// ended up jammed against the top edge of the taller symbols, overlapping the group+/// labels, with the middle of the symbol left empty. So the art keeps its SIZE and shape+/// from the contract and is translated as a whole onto the body's centre.+fn contract_wires(mm_svg: &str, dx: f64, svg_cy: f64, e_cy: f64, body: Option<&KicadBody>, stroke_mm: Option<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(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); } }+ if pts.len() >= 2 { polys.push(pts); }+ }++ // Translate the whole drawing so its bounding box sits on the body's centre. Shape and+ // size are untouched; only where it sits changes.+ let (mut ox, mut oy) = (0.0f64, 0.0f64);+ if let Some(b) = body {+ let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ for p in &polys { for &(x, y) in p { x0 = x0.min(x); y0 = y0.min(y); x1 = x1.max(x); y1 = y1.max(y); } }+ if x0 <= x1 {+ ox = (b.left + b.right) / 2.0 - (x0 + x1) / 2.0;+ oy = (b.top + b.bottom) / 2.0 - (y0 + y1) / 2.0;+ }+ }++ // STROKE SCALES WITH THE ICON. A fixed 0.1016 mm is fine on a large icon and turns a+ // small one into a solid blob: measured on CH340G-2C-CHIP, the fitted icon is 3.7 x 2.4+ // mm with 343 segments whose median length is 0.090 mm, so 89% of them are SHORTER than+ // the line is wide and the drawing fills in. adom-symbol's suggested stroke_mm wins when+ // the contract carries one; otherwise the same rule of thumb from the icon's own width.+ // EAGLE treats 0 as a hairline, so that is the floor.+ let icon_w = {+ let (mut x0, mut x1) = (f64::MAX, f64::MIN);+ for p in &polys { for &(x, _) in p { x0 = x0.min(x); x1 = x1.max(x); } }+ if x0 <= x1 { x1 - x0 } else { 0.0 }+ };+ let stroke = stroke_mm.unwrap_or_else(|| (icon_w / 120.0).max(0.0508)).max(0.0);+ let mut out = String::new();+ let mut segs = 0usize;+ for pts in &polys {+ for w in pts.windows(2) {+ out.push_str(&format!(" <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"{:.4}\" layer=\"94\"/>\n",+ w[0].0 + ox, w[0].1 + oy, w[1].0 + ox, w[1].1 + oy, stroke));+ 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);+ // Stroke scales with the drawn size, same rule as the contract path: a fixed width on a+ // small icon draws segments wider than they are long and the whole thing fills in.+ let stroke = ((sw * scale) / 120.0).max(0.0508);+ 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=\"{:.4}\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1, stroke));+ 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, layout } => {+ // A contract that states where the icon goes has already fitted it clear of the+ // inside designators. Re-centring it on the body would undo exactly that.+ let recentre = if layout.icon_rect.is_some() { None } else { body };+ contract_wires(mm_svg, *dx, *svg_cy, *e_cy, recentre, layout.stroke_mm)+ }+ SymbolArt::Legacy(svg) => symbol_art_wires(svg, body, scale_frac),+ }+}++/// KiCad's stock symbol libraries name a symbol after its CLASS ("R", "C", "L", "D",+/// "LED", "Crystal_GND24", "Conn_01x02"), because they describe a kind of part rather than+/// a specific one. That name is right for a symbol and wrong for a DEVICESET: a user who+/// opens a library and sees "L" cannot tell which inductor it is.+fn is_generic_part_name(name: &str) -> bool {+ let n = name.trim();+ const EXACT: [&str; 14] = ["R", "C", "L", "D", "Q", "Y", "U", "J", "K", "LED", "FB",+ "Fuse", "Crystal", "Jumper"];+ if EXACT.iter().any(|e| e.eq_ignore_ascii_case(n)) { return true; }+ const PREFIX: [&str; 12] = ["Conn_", "Crystal", "R_", "C_", "L_", "D_", "LED_", "Q_",+ "Fuse_", "Ferrite", "Jumper_", "TestPoint"];+ PREFIX.iter().any(|p| n.len() > p.len() && n.to_lowercase().starts_with(&p.to_lowercase()))+}++/// A description that names a CLASS of part rather than the part. KiCad's stock libraries+/// ship these, so a converted library shows "Inductor" or "Unpolarized capacitor" where the+/// wiki page's brief says which inductor and which capacitor. When both exist, the brief is+/// the one worth carrying into Fusion.+fn is_generic_description(text: &str) -> bool {+ let t = text.trim().to_lowercase();+ if t.is_empty() { return true; }+ if t.contains("script generated") || t.contains("kicad-library-utils") { return true; }+ const CLASSES: [&str; 22] = [+ "resistor", "capacitor", "unpolarized capacitor", "polarized capacitor",+ "inductor", "ferrite bead", "diode", "light emitting diode", "led",+ "zener diode", "schottky diode", "crystal", "oscillator", "transistor",+ "npn transistor", "pnp transistor", "mosfet", "jfet", "fuse", "jumper",+ "test point", "connector",+ ];+ if CLASSES.iter().any(|c| t == *c) { return true; }+ // "Four pin crystal, GND on pins 2 and 4" and "Generic connector, single row, 01x02"+ t.starts_with("generic connector")+ || t.starts_with("generic ")+ || (t.contains("crystal") && t.contains("pin") && t.len() < 60)+}++/// Catalogue boilerplate that belongs on a wiki PAGE, not in a part's description. A page+/// brief is written for a reader browsing the library; the same sentence repeated in every+/// Fusion Description cell is noise that pushes the real text out of a narrow column.+fn strip_catalog_boilerplate(text: &str) -> String {+ // LONGEST FIRST: "Datasheet-verified pinout" has to go before "Datasheet-verified",+ // or the short form eats its prefix and strands the word "pinout" on its own.+ const NOISE: [&str; 7] = [+ "Datasheet-verified pinout",+ "KiCad symbol/footprint/3D",+ "Symbol, footprint and 3D model",+ "Generated by adom",+ "Verified pinout",+ "Datasheet-verified",+ "Datasheet verified",+ ];+ // Remove the boilerplate PHRASES in place. An earlier version split the text on every+ // '.' and rejoined it, which turned "2.5V precision reference" into "2. 5V precision+ // reference": a part number or a voltage is full of periods that are not sentence ends.+ let mut out = text.to_string();+ for n in NOISE {+ while let Some(i) = out.find(n) {+ // take the phrase plus any trailing separator it brought with it+ let mut j = i + n.len();+ let bytes = out.as_bytes();+ while j < bytes.len() && matches!(bytes[j], b'.' | b',' | b';' | b' ' | b'\n') { j += 1; }+ out.replace_range(i..j, "");+ }+ }+ let out = out.trim().trim_end_matches(&['.', ',', ';', ' '][..]).trim().to_string();+ if out.is_empty() { text.trim().to_string() } else { out }+}++/// A human line for the PACKAGE, GEOMETRY FIRST.+///+/// Fusion's Content Manager shows about 20 characters of this column before it truncates+/// with an ellipsis (the full text is on hover), measured by the fusion-bridge thread. So+/// the first words have to be the ones that identify the package. A manufacturer-first+/// `(descr …)` truncates to "Walsin Technology C…", which identifies nothing, while the+/// geometry derived from the footprint NAME truncates to "SOIC-16, 3.9 x 9.9 m…", which+/// still tells you the family, the pin count and the size.+///+/// The `.kicad_mod`'s own `(descr …)` is appended after it when it adds something. It is+/// skipped when it merely restates the geometry: "SOIC, 8 Pin" beside+/// "SOIC-8, 3.9 x 4.9 mm body, 1.27 mm pitch" is noise, not detail.+fn package_description(fp_content: Option<&str>, package_name: &str) -> String {+ let descr = fp_content.and_then(|c| {+ let re = regex_lite::Regex::new(r#"\(descr\s+"([^"]*)""#).ok()?;+ let raw = re.captures(c)?.get(1)?.as_str();+ let mut t = raw.split("http").next().unwrap_or(raw).trim().to_string();+ // Cutting a URL can leave the bracket that introduced it, e.g. "(JEDEC MS-012AA, ".+ if t.matches('(').count() > t.matches(')').count() {+ if let Some(i) = t.rfind('(') { t.truncate(i); }+ }+ let t = t.trim().trim_end_matches(&[',', ';', '\u{b7}', '-'][..]).trim().to_string();+ if t.is_empty() { None } else { Some(t) }+ });++ let mut family = String::new();+ let mut body = String::new();+ let mut pitch = String::new();+ let dims = regex_lite::Regex::new(r"^([\d.]+)x([\d.]+)mm$").unwrap();+ let pit = regex_lite::Regex::new(r"^P([\d.]+)mm$").unwrap();+ for (i, tok) in package_name.split('_').enumerate() {+ if i == 0 { family = tok.to_string(); continue; }+ if let Some(c) = dims.captures(tok) { body = format!("{} x {} mm", &c[1], &c[2]); continue; }+ if let Some(c) = pit.captures(tok) { pitch = format!("{} mm pitch", &c[1]); continue; }+ }+ // Geometry only counts when the NAME actually encodes it. "0603N101J500CT" is a part+ // number, not a package description. Rather than fall back to the manufacturer-first+ // descr (which truncates to "Walsin Technology Co...", identifying nothing) or to the+ // package name (which just repeats the Name column beside it, the thing John objected+ // to), MEASURE the footprint: the pad count and the outline are always available and+ // they are what actually distinguishes one package from another.+ let geometry = if body.is_empty() && pitch.is_empty() {+ match fp_content {+ Some(km) => {+ let pads = parse_mod_pads(km);+ if pads.is_empty() {+ String::new()+ } else {+ // Measure the COPPER, not the silkscreen. package_outline() prefers the+ // F.SilkS box, which on a two-terminal chip part is a pair of short+ // ticks beside the pads: it measured an 0603 as "0.6 x 0.0 mm", a+ // number that is confidently wrong. The pad extent is always defined.+ let (mut x1, mut y1, mut x2, mut y2) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ for pd in &pads {+ x1 = x1.min(pd.x - pd.dx / 2.0); y1 = y1.min(pd.y - pd.dy / 2.0);+ x2 = x2.max(pd.x + pd.dx / 2.0); y2 = y2.max(pd.y + pd.dy / 2.0);+ }+ let (w, h) = ((x2 - x1).abs(), (y2 - y1).abs());+ // Refuse to state a measurement that cannot be right rather than print+ // a tidy-looking zero.+ if w < 0.2 || h < 0.2 {+ format!("{} pads", pads.len())+ } else {+ format!("{} pads, {:.1} x {:.1} mm", pads.len(), w, h)+ }+ }+ }+ None => String::new(),+ }+ } else {+ let mut parts = vec![family];+ if !body.is_empty() { parts.push(body); }+ if !pitch.is_empty() { parts.push(pitch); }+ parts.join(", ")+ };++ match (geometry.is_empty(), descr) {+ (true, Some(d)) => d,+ (true, None) => package_name.to_string(),+ (false, None) => geometry,+ (false, Some(d)) => {+ // Skip a descr that just restates the geometry: compare on alphanumeric tokens+ // rather than on the exact string, so "SOIC, 8 Pin" is recognised as a restating+ // of "SOIC-8, ...".+ let norm = |t: &str| -> Vec<String> {+ t.to_lowercase()+ .split(|c: char| !c.is_ascii_alphanumeric() && c != '.')+ .filter(|w| !w.is_empty() && *w != "pin" && *w != "pins" && *w != "mm")+ .map(|w| w.to_string())+ .collect()+ };+ let g = norm(&geometry);+ let dt = norm(&d);+ let covered = dt.iter().filter(|w| g.iter().any(|x| x == *w)).count();+ let redundant = !dt.is_empty() && covered * 100 / dt.len() >= 60;+ if redundant { geometry } else { format!("{}. {}", geometry, d) }+ }+ }+}++/// The wiki page's one-line `brief`, used when the `.kicad_sym` carries no Description.+/// Several published sources have none at all (adom/s10b-ph-sm4-tb, ray/stm32l431rct6tr,+/// ray/xl7026e1), so their devicesets reached Fusion with an empty Description column and+/// nothing to search on. The page brief is the right text and it is already sitting beside+/// the symbol, so the library carries it rather than the importer having to guess.+fn page_brief(path: &std::path::Path) -> Option<String> {+ let raw = fs::read_to_string(path).ok()?;+ let doc = serde_json::from_str::<serde_json::Value>(&raw).ok()?;+ for probe in [doc.get("brief"), doc.get("component").and_then(|c| c.get("brief")),+ doc.get("description"), doc.get("component").and_then(|c| c.get("description"))] {+ if let Some(t) = probe.and_then(|v| v.as_str()) {+ let t = t.trim();+ if !t.is_empty() { return Some(t.to_string()); }+ }+ }+ None+}++/// 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` / `part` / `sku` (the wiki component pages+/// write `part`), because all of those shapes are 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.+/// Every EE attaches the orderable part numbers to a library part before it reaches a+/// design: the manufacturer part number and the Mouser, DigiKey and LCSC/JLCPCB numbers+/// are what the BOM and CPL carry to the fab with the gerbers (John, 2026-09-19). A+/// KiCad symbol carries them as properties (MPN, Manufacturer, Mouser, DigiKey, LCSC;+/// adom-symbol writes them from `mpn` + `distributors` in its create JSON); import-kicad+/// turns them into Altium parameters (plus Supplier N / Supplier Part Number N pairs)+/// and generate writes them as EAGLE attributes. Say so when any are missing, and say+/// where to get them: `adom-parts-search search <MPN>` looks all three up at once.+/// Heatsink (thermal) vias travel INSIDE the footprint as plated through-hole pads that+/// carry the exposed pad's own number (0.3 mm drill, 0.8 mm pitch, 0.15 mm ring, tented+/// is the wiki's standard rule; adom-footprint create `heatsinkVias` writes them). Every+/// lane keeps them: KiCad as the pads themselves, Altium as multi-layer pads with holes,+/// EAGLE/Fusion as <pad drill=...> - so a user never redraws them per tool (John,+/// 2026-09-19). Say when a large SMD pad has none.+fn heatsink_hint(fp_txt: &str, mpn: &str) {+ let pad_re = regex_lite::Regex::new(r#"\(pad\s+"([^"]+)"\s+(smd|thru_hole|np_thru_hole)\b[^\n]*?\(size\s+([-\d.]+)\s+([-\d.]+)\)"#).unwrap();+ let mut smd_big: Vec<(String, f64)> = Vec::new();+ let mut th: std::collections::HashMap<String, usize> = std::collections::HashMap::new();+ for c in pad_re.captures_iter(fp_txt) {+ let (name, kind) = (c[1].to_string(), c[2].to_string());+ let area = c[3].parse::<f64>().unwrap_or(0.0) * c[4].parse::<f64>().unwrap_or(0.0);+ if kind == "smd" { smd_big.push((name, area)); } else if kind == "thru_hole" { *th.entry(name).or_insert(0) += 1; }+ }+ let Some(max_area) = smd_big.iter().map(|(_, a)| *a).fold(None, |m: Option<f64>, a| Some(m.map_or(a, |x| x.max(a)))) else { return };+ let median = { let mut v: Vec<f64> = smd_big.iter().map(|(_, a)| *a).collect(); v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v[v.len() / 2] };+ for (name, area) in &smd_big {+ if *area >= 1.0 && *area >= 3.0 * median && *area == max_area {+ match th.get(name) {+ Some(n) => println!("HINT: {mpn} exposed pad {name} carries {n} heatsink via(s) as plated holes with its own number; they reach Altium (multi-layer pads) and Fusion (<pad drill>) unchanged."),+ None => println!("HINT: {mpn} exposed pad {name} ({area:.1} mm2) has NO heatsink vias in the footprint. A thermal pad without vias into the ground plane is a part that overheats; add them once in the KiCad footprint as thru_hole pads numbered {name} (adom-footprint create `heatsinkVias`, or the wiki page's artwork/<MPN>-heatsink-via.json rule: 0.3 mm drill, 0.8 mm pitch, 0.15 mm ring, tented) and every lane carries them."),+ }+ }+ }+}++fn sourcing_hint(sym_txt: &str, mpn: &str) {+ let has = |n: &str| sym_txt.contains(&format!("(property \"{n}\""));+ let missing: Vec<&str> = ["MPN", "Manufacturer", "Datasheet", "Description", "Mouser", "DigiKey", "LCSC", "Adom Wiki"].into_iter().filter(|n| !has(n)).collect();+ if missing.is_empty() {+ println!("HINT: identity complete (MPN, Manufacturer, Datasheet, Description, Mouser, DigiKey, LCSC, Adom Wiki) - they become Altium parameters + Supplier links and EAGLE attributes.");+ } else {+ println!("HINT: {mpn} symbol has no {} property. A part without its MPN and Mouser/DigiKey/LCSC numbers cannot be ordered from the BOM/CPL the fab gets with the gerbers. Look them up with `adom-parts-search search {mpn}` and put them in the symbol (adom-symbol create: `mpn` + `distributors` in the create JSON) or the wiki page.json (component.distributor_links) before converting; LCSC may honestly be absent (say so on the page). The Adom Wiki property is the link to the part's component page (https://wiki.adom.inc/<owner>/<slug>), the one place an EE goes for the reviewed symbol, footprint, 3D, datasheet and provenance.", missing.join(" / "));+ }+}++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)); }+ // The datasheet and the Adom wiki page ride along as attributes too: the wiki page is+ // the one place that holds the reviewed symbol/footprint/3D/datasheet/provenance for+ // the part, so every tool carries the link back to it (John, 2026-09-19).+ if let Some(v) = clean(c.get("datasheet")).or_else(|| clean(c.get("datasheet_url"))).or_else(|| clean(doc.get("datasheet"))) {+ out.push(("DATASHEET".to_string(), v));+ }+ let wiki = clean(c.get("wiki_url")).or_else(|| clean(doc.get("wiki_url"))).or_else(|| clean(doc.get("url"))).or_else(|| {+ let slug = clean(doc.get("slug"))?;+ let owner = clean(doc.get("owner")).or_else(|| clean(doc.get("org"))).unwrap_or_else(|| "adom".to_string());+ Some(format!("https://wiki.adom.inc/{owner}/{slug}"))+ });+ if let Some(v) = wiki { out.push(("ADOM_WIKI".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")).or_else(|| other.get("part")).or_else(|| other.get("sku")).cloned(),+ });+ if let Some(v) = flat.or_else(|| clean(nested.as_ref())) {+ out.push((attr.to_string(), v));+ }+ }+ out+}++/// One extra gate of the deviceset: an alternate symbol for the same part.+pub(crate) struct AltGate {+ pub sym: ParsedSymbol,+ pub gate: String,+ pub art: Option<SymbolArt>,+}++/// The `<symbol>` block of ONE symbol: body, chip drawing, pins, group labels.+/// Returns the pin names as emitted (deduplicated); the connects must reuse them.+fn emit_symbol_block(xml: &mut String, sym: &ParsedSymbol, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64) -> Vec<String> {+ xml.push_str(&format!("<symbol name=\"{}\">\n", esc_xml(&sym.name)));+ // Fusion shows a Description column for every row in the tree, and the SYMBOL rows were+ // blank for every part we ship. The deviceset text is the right text for the symbol too:+ // it is what a person is looking for when they are reading the list.+ if !sym.description.trim().is_empty() {+ xml.push_str(&format!(" <description>{}</description>\n", esc_xml(&sym.description)));+ }++ 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, src_w) => {+ let ww = eagle_symbol_wire_width(*src_w);+ for w in pts.windows(2) {+ xml.push_str(&format!(" <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"{:.4}\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1, ww));+ }+ }+ 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));+ }+ }+ SymGraphic::Rect { x1, y1, x2, y2 } => {+ xml.push_str(&format!(" <rectangle x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" layer=\"94\"/>\n", x1, y1, x2, y2));+ }+ SymGraphic::Circle { cx, cy, r } => {+ xml.push_str(&format!(" <circle x=\"{:.4}\" y=\"{:.4}\" radius=\"{:.4}\" width=\"0.1524\" layer=\"94\"/>\n", cx, cy, r));+ }+ }+ }+ }++ // >NAME and >VALUE GO WHERE A WIRE CANNOT. They used to sit 2.54 mm above the body top+ // and below the body bottom, which is correct for a two-column symbol and wrong for a+ // four-sided one: that is exactly the lane the top and bottom pin stubs occupy, so every+ // net a user draws to those pins runs straight through the label (John, on the CH340G-SQ+ // frame). When the symbol has pins on the top or bottom edge, both labels move INSIDE+ // the body, into the interior rectangle left free by the pin names.+ // ADOM-SYMBOL IS AUTHORITATIVE when it states where the designators go. Its points are+ // the text centres it wrote into the .kicad_sym Reference/Value properties, so emitting+ // >NAME and >VALUE there with align="center" makes the KiCad render and the Fusion+ // render agree 1:1, which is the whole point of having a contract. A null means it+ // deliberately hid that designator, and hidden is honoured rather than second-guessed.+ // Note the two tools measure text differently (adom-symbol 1.5 mm per character at the+ // 1.27 mm font, adom-lbr 0.6 x height), so the rects they compute differ; that is+ // precisely why one of them has to own the number rather than both approximating it.+ const GLYPH_ADVANCE: f64 = 1.18;++ // SECOND AUTHORITY: the source's own Reference/Value positions, used when there is no+ // contract, so a symbol and its -CHIP twin do not give two different answers side by+ // side in the tree (John, comparing CH340G-SQ with CH340G-SQ-CHIP).+ //+ // VALIDATED, NOT OBEYED. A position is honoured only if a wire cannot run through it.+ // Older adom-symbol builds and KiCad's stock libraries put Reference above the body and+ // Value below it, which on a four-sided symbol is exactly the lane the top and bottom+ // nets occupy: CH340G-SQ's own source puts Reference at y = 16.64, past the top pin ends+ // at 10.54, so obeying it blindly would put the label back where this was just fixed.+ let in_a_wire_lane = |pt: (f64, f64), label_w: f64| -> bool {+ let half = label_w / 2.0;+ sym.pins.iter().any(|p| match p.angle {+ 270 => (pt.0 - p.x).abs() < half + 0.5 && pt.1 >= p.y,+ 90 => (pt.0 - p.x).abs() < half + 0.5 && pt.1 <= p.y,+ 0 => (pt.1 - p.y).abs() < 1.27 && pt.0 <= p.x,+ _ => (pt.1 - p.y).abs() < 1.27 && pt.0 >= p.x,+ })+ };+ let source_designators = match (sym.ref_pos, sym.val_pos) {+ (Some(r), Some(v))+ if !in_a_wire_lane(r, 5.0 * GLYPH_ADVANCE * 1.27)+ && !in_a_wire_lane(v, 6.0 * GLYPH_ADVANCE * 1.27) => Some((r, v)),+ _ => None,+ };++ let contract_designators = match symbol_art {+ Some(SymbolArt::Contract { layout, .. }) if layout.inside_body+ && (layout.reference.is_some() || layout.value.is_some()) => Some(layout),+ _ => None,+ };+ if let Some(layout) = contract_designators {+ if let Some((x, y)) = layout.reference {+ xml.push_str(&format!(" <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"95\" align=\"center\">>NAME</text>\n", x, y));+ }+ if let Some((x, y)) = layout.value {+ xml.push_str(&format!(" <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"96\" align=\"center\">>VALUE</text>\n", x, y));+ }+ println!("HINT: >NAME/>VALUE placed from the outline contract (adom-symbol's own designator points), so the KiCad and Fusion renders match.");+ } else if let Some((r, v)) = source_designators {+ xml.push_str(&format!(" <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"95\" align=\"center\">>NAME</text>\n", r.0, r.1));+ xml.push_str(&format!(" <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"96\" align=\"center\">>VALUE</text>\n", v.0, v.1));+ println!("HINT: >NAME/>VALUE placed from the source .kicad_sym's own Reference/Value positions, so this symbol matches its chip-outline twin.");+ } else {+ let has_vertical_pins = sym.pins.iter().any(|p| p.angle == 90 || p.angle == 270);+ // GLYPH ADVANCE, taken from adom-symbol rather than from my own estimate. I used+ // 0.6 x height, which is the usual approximation for EAGLE's vector font and is roughly+ // HALF what Fusion actually draws: on the CH340G-SQ frame that made >NAME land against+ // the bottom of "R232" while my arithmetic claimed 2.69 mm of clearance. adom-symbol+ // measures 1.5 mm per character at the 1.27 mm font, so about 1.18 x height, and its+ // margins read correctly in Fusion. One model, and it is the one that has been checked+ // against the renderer. Over-reserving is the safe direction: it costs interior space,+ // never a collision.+ let pin_name_w = |p: &KicadPin| dedup_pin_names(std::slice::from_ref(p))[0].chars().count() as f64 * GLYPH_ADVANCE * 1.778;+ match (&sym.body, has_vertical_pins) {+ (Some(b), true) => {+ // Interior free rect: pull each side in by that side's longest pin name plus+ // KiCad's 1.016 mm name offset. This is the same rectangle adom-symbol computes+ // to fit the chip icon.+ let inset = |sel: &dyn Fn(&KicadPin) -> bool| -> f64 {+ sym.pins.iter().filter(|p| sel(p)).map(pin_name_w).fold(0.0, f64::max) + 1.016+ };+ let l = b.left + inset(&|p: &KicadPin| p.angle == 0);+ let r = b.right - inset(&|p: &KicadPin| p.angle == 180);+ let mut t = b.top - inset(&|p: &KicadPin| p.angle == 270);+ let mut bo = b.bottom + inset(&|p: &KicadPin| p.angle == 90);+ let cx = (l + r) / 2.0;++ // THE NEAREST INK IS NOT ALWAYS A PIN NAME. On the grouped layouts a group+ // caption sits above or below the pin names, so it, not the name, is what a+ // label would crowd. Pull the rect in past any caption that shares the labels'+ // horizontal span. A caption's own extent depends on how it is anchored: an+ // unrotated one is centred on its y, a rotated top one hangs DOWN from its y,+ // a rotated bottom one runs UP from it.+ let cap_h = 0.762;+ let label_half_w = 6.0 * GLYPH_ADVANCE * 1.27 / 2.0; // ">VALUE" is the wider of the two+ for g in &sym.group_labels {+ let len = g.text.chars().count() as f64 * GLYPH_ADVANCE * cap_h;+ let (gy0, gy1) = if g.angle == 90 || g.angle == 270 {+ if g.justify_right { (g.y - len, g.y) } else { (g.y, g.y + len) }+ } else {+ (g.y - cap_h / 2.0, g.y + cap_h / 2.0)+ };+ let (gx0, gx1) = if g.angle == 90 || g.angle == 270 {+ (g.x - cap_h / 2.0, g.x + cap_h / 2.0)+ } else if g.justify_right {+ (g.x - len, g.x)+ } else {+ (g.x, g.x + len)+ };+ if gx1 < cx - label_half_w || gx0 > cx + label_half_w { continue; }+ let mid = (b.top + b.bottom) / 2.0;+ if gy0 > mid { t = t.min(gy0); } else { bo = bo.max(gy1); }+ }++ // BREATHING ROOM, ON THE GRID. Sitting exactly on the rect edge puts the label+ // flush against the ink below it, which reads as crowded even though nothing+ // overlaps (John, on the SQG frame). One full text height of gap, snapped to+ // EAGLE's 2.54 mm grid so the labels land where a user's own text would.+ const MARGIN: f64 = 2.54;+ let (ny, vy) = (t - MARGIN, bo + MARGIN);+ // The two labels must clear EACH OTHER as well as the ink around them: a rect+ // that fits them only by letting them touch is the squeeze the corner fallback+ // exists to avoid. Two text heights plus a gap of one.+ if ny - vy >= 3.0 * 1.27 && r - l >= 4.0 {+ xml.push_str(&format!(" <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"95\" align=\"top-center\">>NAME</text>\n", cx, ny));+ xml.push_str(&format!(" <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"96\" align=\"bottom-center\">>VALUE</text>\n", cx, vy));+ } else {+ // The interior is too small for 1.27 mm text (a tiny square symbol). Put the+ // labels in the corners BEYOND the pin extents, where a wire still cannot go.+ let far = sym.pins.iter().map(|p| p.x.abs() + p.length).fold(0.0, f64::max) + 1.27;+ xml.push_str(&format!(" <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"95\" align=\"bottom-left\">>NAME</text>\n", far, b.top));+ xml.push_str(&format!(" <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"96\" align=\"top-left\">>VALUE</text>\n", far, b.bottom));+ }+ }+ (body, _) => {+ // No pins on the top or bottom edge: above and below the body is free space, and+ // it is where an engineer expects to read the designator.+ let name_y = body.as_ref().map(|b| b.top + 2.54).unwrap_or(10.0);+ let value_y = 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);+ // A GLYPH SYMBOL HIDES ITS PIN NAMES. EAGLE draws a pin's name at its INNER end, which+ // is inside the body for an IC and inside the DRAWING for a two-terminal part that has+ // no body box: measured on the published LED, both names land on the triangle, and on+ // the crystal all four land on the plates ("G@2" drawn straight through the box). No+ // engineer labels a diode's pins on a schematic anyway, so show the pad NUMBER and hide+ // the name, which is what EAGLE's own passive libraries do. Tightly scoped: only a+ // symbol with NO body box, its own artwork, and at most four pins, so no IC is touched.+ // NOT keyed on "has no body box": a crystal's first rectangle IS its can, so it parses+ // as a body while being part of the drawing, and the four pin names printed straight+ // through it. The test that holds for both is artwork plus a small pin count: an IC+ // with a body and no artwork of its own keeps its names.+ let glyph_only = !sym.graphics.is_empty() && sym.pins.len() <= 4;+ if glyph_only {+ println!("HINT: glyph symbol ({} pins, no body box): pin NAMES hidden and pad numbers shown, so the names do not print on top of the drawing.", sym.pins.len());+ }+ 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),+ if glyph_only { " visible=\"pad\"" } else { "" }+ ));+ }++ 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 {+ // FAITHFUL MAPPING of adom-symbol's four caption forms, rather than re-deriving the+ // side from x. EAGLE's align vocabulary is the nine {bottom|center|top}-{left|+ // center|right} values; a bare "right" is not one of them, so Fusion logged a+ // warning per caption, ignored it, and fell back to LEFT anchoring, which made every+ // right-hand caption run outward across the body line (John saw it on 2CG and SQG).+ // KiCad's vertical default is centre, so center-* is the faithful vertical anchor.+ // left (at x y 0) justify left -> R0, center-left+ // right (at x y 0) justify right -> R0, center-right+ // top (at x y 90) justify right -> R90, center-right+ // bottom (at x y 90) justify left -> R90, center-left+ let align = if label.justify_right { "center-right" } else { "center-left" };+ let rot = if label.angle == 90 || label.angle == 270 { " rot=\"R90\"" } else { "" };+ // The source already places captions 1.02 mm inside the edge. Use its coordinates+ // instead of recomputing them from the body, which is how the old code lost the+ // top and bottom captions entirely.+ xml.push_str(&format!(+ " <text x=\"{:.4}\" y=\"{:.4}\" size=\"0.762\" layer=\"94\" font=\"vector\" ratio=\"15\"{} align=\"{}\">{}</text>\n",+ label.x, label.y, rot, align, esc_xml(&label.text)+ ));+ }+ let _ = (body_center_x, body_left, body_right);++ xml.push_str("</symbol>\n");+ deduped_names+}++pub(crate) fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64, pin_map: &std::collections::HashMap<String, String>, part_attrs: &[(String, String)]) -> String {+ generate_lbr_gates(sym, fp_content, symbol_art, symbol_art_scale, pin_map, part_attrs, &[], &[])+}++pub(crate) fn generate_lbr_gates(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64, pin_map: &std::collections::HashMap<String, String>, part_attrs: &[(String, String)], alts: &[AltGate], variants: &[PackageVariant]) -> 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. User layers are declared only when a footprint draws on one: Fusion IGNORES+ // geometry on a layer the file never declares, and lists every declared layer in its+ // dialog whether or not anything sits on it.+ let mut used: std::collections::BTreeSet<u32> = std::collections::BTreeSet::new();+ if let Some(km) = fp_content { used.extend(used_user_layers(km)); }+ for v in variants { used.extend(used_user_layers(&v.km)); }+ if used.is_empty() {+ xml.push_str(EAGLE_LAYERS);+ } else {+ let decls = user_layer_decls(&used);+ xml.push_str(&EAGLE_LAYERS.replace("</layers>", &format!("{}</layers>", decls)));+ println!("HINT: declared {} user layer(s) ({}) so their geometry survives into Fusion.",+ used.len(), used.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(", "));+ }++ 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.package_name.as_deref().unwrap_or(&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();+ // The PACKAGE's description names the package, not the symbol. It used to carry+ // sym.name, so two symbol variants of one chip produced packages that were+ // byte-identical except for this line, and assemble's content dedup could not see+ // that they were the same footprint: the merged library gained a second package+ // (and the bridge a second 3D model) per variant.+ xml.push_str(&format!(" <description>{}</description>\n", esc_xml(sym.package_description.as_deref().unwrap_or(sym.package_name.as_deref().unwrap_or(&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),+ p.roundness.map(|r| format!(" roundness=\"{r}\"")).unwrap_or_default()));+ } 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 (tht_shape, diameter, extra_rot, warn) = tht_pad_geometry(&p.kicad_shape, p.dx, p.dy);+ if let Some(w) = warn { eprintln!("WARN: pad {}: {}", p.name, w); }+ 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, tht_shape, smd_rotation(p.angle + extra_rot)));+ }+ }+ // 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");+ // One package per VARIANT. Same part, different footprint, so they belong in the same+ // deviceset as sibling devices rather than as separate parts.+ let base_pkg = sym.package_name.as_deref().unwrap_or(&sym.name).to_string();+ let mut variant_groups: Vec<(String, std::collections::HashMap<String, Vec<String>>)> = Vec::new();+ for v in variants {+ let pkg = format!("{}{}", base_pkg, v.suffix);+ let desc = match &v.description {+ Some(d) => d.clone(),+ None => format!("{}{}", sym.package_description.as_deref().unwrap_or(&base_pkg),+ if v.suffix.is_empty() { String::new() } else { format!(", variant {}", v.suffix.trim_start_matches('-')) }),+ };+ let g = emit_variant_package(&mut xml, &v.km, &pkg, &desc);+ variant_groups.push((pkg, g));+ }+ xml.push_str("</packages>\n");++ // Symbols section+ xml.push_str("<symbols>\n");+ let deduped_names = emit_symbol_block(&mut xml, sym, symbol_art, symbol_art_scale);+ let alt_names: Vec<Vec<String>> = alts.iter()+ .map(|a| emit_symbol_block(&mut xml, &a.sym, a.art.as_ref(), symbol_art_scale))+ .collect();+ xml.push_str("</symbols>\n");++ // Deviceset with COMPLETE device (package + connects)+ 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.deviceset_name.as_deref().unwrap_or(&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),+ if alts.is_empty() { "" } else { " addlevel=\"next\"" }));+ // Alternate gates sit SIDE BY SIDE in the deviceset (Fusion's component preview draws+ // every gate at its offset; ten gates at 0,0 were an unreadable pile on the first run).+ let width = |p: &ParsedSymbol| p.body.as_ref().map(|b| b.right - b.left).unwrap_or(20.0) + 2.0 * 7.62;+ let mut gx = width(sym) / 2.0;+ for a in alts {+ let w = width(&a.sym);+ gx += w / 2.0 + 5.08;+ let x = (gx / 2.54).round() * 2.54; // keep gate origins on the 0.1in grid+ xml.push_str(&format!(" <gate name=\"{}\" symbol=\"{}\" x=\"{:.2}\" y=\"0\" addlevel=\"can\"/>\n", esc_xml(&a.gate), esc_xml(&a.sym.name), x));+ gx += w / 2.0;+ }+ xml.push_str(" </gates>\n");+ xml.push_str(" <devices>\n");+ xml.push_str(&format!(" <device name=\"\" package=\"{}\">\n", esc_xml(sym.package_name.as_deref().unwrap_or(&sym.name))));+ xml.push_str(" <connects>\n");++ // Generate connect mappings: gate pin name → pad number, for the primary gate and+ // then for every alternate gate (same pads: they are the same physical pins).+ let mut gate_pins: Vec<(&str, &ParsedSymbol, &Vec<String>)> = vec![("G$1", sym, &deduped_names)];+ for (a, names) in alts.iter().zip(alt_names.iter()) {+ gate_pins.push((a.gate.as_str(), &a.sym, names));+ }+ let gate_pins2 = gate_pins.clone();+ for (gate_name, gsym, gnames) in gate_pins {+ for (i, pin) in gsym.pins.iter().enumerate() {+ // The pad reference is the symbol pin's NUMBER, which is the KiCad+ // convention and right for almost every part. A pin map is the escape+ // hatch for the ones where the footprint names that pad something else.+ 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=\"{}\" pin=\"{}\" pad=\"{}\"/>\n",+ esc_xml(gate_name), esc_xml(&gnames[i]), esc_xml(&pad_ref)+ ));+ }+ }++ xml.push_str(" </connects>\n");+ // Attributes ride on <technology>, which is where Fusion's BOM export and the+ // 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");++ // ONE DEVICE PER VARIANT, sharing the gates and the connects. An extra pad that sits+ // inside a baseline pad joins that pad's connect as a space-separated multi-pad ref,+ // which is EAGLE's own form for one pin on several pads: sixteen heatsink vias in a+ // thermal pad become <connect pin="EP" pad="EP V1 V2 ...">. Attributes are repeated+ // verbatim, because it is the same orderable part whichever package you fit.+ let base_pads = fp_content.map(parse_mod_pads).unwrap_or_default();+ for (v, (pkg, vgroups)) in variants.iter().zip(variant_groups.iter()) {+ xml.push_str(&format!(" <device name=\"{}\" package=\"{}\">\n", esc_xml(&v.suffix), esc_xml(pkg)));+ xml.push_str(" <connects>\n");+ let vpads = parse_mod_pads(&v.km);+ // extra pad name -> the baseline pad whose area contains it+ let mut extra_for: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();+ let mut orphans: Vec<String> = Vec::new();+ for ep in &vpads {+ if base_pads.iter().any(|b| b.name == ep.name) { continue; }+ match owning_pad(ep, &base_pads) {+ Some(owner) => extra_for.entry(owner.name.clone()).or_default().push(ep.name.clone()),+ None => orphans.push(ep.name.clone()),+ }+ }+ if !orphans.is_empty() {+ eprintln!("WARN: variant {} has pad(s) {} that sit inside no baseline pad, so nothing connects them. \+Place them within the pad they serve, or they will import as unconnected copper.",+ v.suffix, orphans.join(", "));+ }+ for (gate_name, gsym, gnames) in &gate_pins2 {+ for (i, pin) in gsym.pins.iter().enumerate() {+ let pad = pin_map.get(&pin.number).unwrap_or(&pin.number);+ let mut refs: Vec<String> = match vgroups.get(pad) {+ Some(group) => group.clone(),+ None => vec![pad.clone()],+ };+ if let Some(extra) = extra_for.get(pad) { refs.extend(extra.clone()); }+ xml.push_str(&format!(+ " <connect gate=\"{}\" pin=\"{}\" pad=\"{}\"/>\n",+ gate_name, esc_xml(&gnames[i]), esc_xml(&refs.join(" "))));+ }+ }+ xml.push_str(" </connects>\n");+ if part_attrs.is_empty() {+ xml.push_str(" <technologies><technology name=\"\"/></technologies>\n");+ } else {+ xml.push_str(" <technologies>\n <technology name=\"\">\n");+ for (k, val) in part_attrs {+ xml.push_str(&format!(+ " <attribute name=\"{}\" value=\"{}\" constant=\"no\"/>\n",+ esc_xml(k), esc_xml(val)));+ }+ 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() });+ }++ // PAD CLEARANCE. A mapping that grows a pad into its neighbour is a SHORT, and the file+ // is still perfectly valid EAGLE, so nothing else here would catch it: adom/b2b-ph-k-s+ // shipped with pin 2's copper 0.35 mm inside pin 1 (wiki #19). Copper-to-copper under+ // 0.2 mm is an error rather than a warning, because a converter must refuse before a+ // board house does.+ {+ #[derive(Clone)]+ struct P { name: String, x: f64, y: f64, hx: f64, hy: f64, round: bool }+ let num = |s: &str| s.parse::<f64>().unwrap_or(0.0);+ let rot90 = |r: Option<&str>| matches!(r, Some("R90") | Some("R270"));+ let mut overlaps: Vec<String> = Vec::new();+ let mut close: Vec<String> = Vec::new();+ let pkg_re = regex_lite::Regex::new(r#"(?s)<package name="([^"]*)">(.*?)</package>"#).unwrap();+ // Match the whole element, then read each attribute from it. An optional (?:rot=...)?+ // group followed by a greedy [^>]* silently matches EMPTY and lets the tail eat the+ // rotation, so every pad reads as unrotated: that made this lint fail the file it+ // had just been used to fix.+ let smd_re = regex_lite::Regex::new(r#"<smd [^>]*/>"#).unwrap();+ let pad_re = regex_lite::Regex::new(r#"<pad [^>]*/>"#).unwrap();+ let attr = |el: &str, k: &str| -> Option<String> {+ regex_lite::Regex::new(&format!(r#"{}="([^"]*)""#, k)).ok()?+ .captures(el).map(|c| c[1].to_string())+ };+ for pk in pkg_re.captures_iter(content) {+ let (pkg, body) = (pk[1].to_string(), pk[2].to_string());+ let mut pads: Vec<P> = Vec::new();+ for m in smd_re.find_iter(&body) {+ let el = m.as_str();+ let g = |k: &str| attr(el, k).unwrap_or_default();+ let (mut hx, mut hy) = (num(&g("dx")) / 2.0, num(&g("dy")) / 2.0);+ if rot90(attr(el, "rot").as_deref()) { std::mem::swap(&mut hx, &mut hy); }+ pads.push(P { name: g("name"), x: num(&g("x")), y: num(&g("y")), hx, hy, round: false });+ }+ for m in pad_re.find_iter(&body) {+ let el = m.as_str();+ let g = |k: &str| attr(el, k).unwrap_or_default();+ let shape = g("shape");+ let d = num(&g("diameter"));+ let (mut hx, mut hy) = match shape.as_str() {+ // EAGLE's `long` is a 2:1 stadium elongated along the pad's ROTATION+ // axis, which is X when unrotated: 2d wide by d tall, so the half+ // extents are (d, d/2). Writing them the other way round is what made+ // this lint pass the very file it was written for.+ "long" => (d, d / 2.0),+ _ => (d / 2.0, d / 2.0),+ };+ if rot90(attr(el, "rot").as_deref()) { std::mem::swap(&mut hx, &mut hy); }+ pads.push(P { name: g("name"), x: num(&g("x")), y: num(&g("y")), hx, hy, round: shape == "round" });+ }+ for i in 0..pads.len() {+ for j in i + 1..pads.len() {+ let (a, b) = (&pads[i], &pads[j]);+ // Pads of the SAME net legitimately touch or nest: heatsink vias sit+ // inside the thermal pad they vent, and a same-net array is emitted as+ // NAME, NAME@2, NAME@3. Comparing those would fail every such footprint.+ let base = |n: &str| n.split('@').next().unwrap_or(n).to_string();+ if base(&a.name) == base(&b.name) { continue; }+ let gx = (a.x - b.x).abs() - (a.hx + b.hx);+ let gy = (a.y - b.y).abs() - (a.hy + b.hy);+ let gap = if a.round && b.round {+ ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt() - (a.hx + b.hx)+ } else if a.round != b.round {+ // A round pad against a rectangular one: when the circle sits off the+ // rectangle's CORNER, the copper gap is the circle-to-corner distance, not+ // the gap between the two bounding boxes. KiCad's stock staggered TO-92 (a+ // 1.3 mm square pad 1 at (0,0), a 1.3 mm round pad 2 at (1.27,-1.27)) has+ // 0.227 mm of real clearance, but its boxes overlap, so every TO-92 part+ // failed "pads 1 and 2 OVERLAP" (21 factory pages on 2026-09-23). Beside an+ // edge, or inside, the box gap is already exact.+ let (c, r) = if a.round { (a, b) } else { (b, a) };+ let dx = ((c.x - r.x).abs() - r.hx).max(0.0);+ let dy = ((c.y - r.y).abs() - r.hy).max(0.0);+ if dx > 0.0 && dy > 0.0 { (dx * dx + dy * dy).sqrt() - c.hx } else { gx.max(gy) }+ } else {+ gx.max(gy)+ };+ // True copper OVERLAP is a short and stays a hard error. A small+ // POSITIVE gap is what fine-pitch land patterns look like by+ // construction (a 0.5 mm-pitch SOT-563 has 0.150 mm between pads in+ // KiCad's own library), so it warns instead of failing: a flat 0.2 mm+ // ERROR rejected 559 of 1314 stock KiCad footprints (wiki #20), each+ // one a part silently dropped from the factory worklist. The epsilon+ // keeps a gap of exactly the threshold clean: 1.2 - 1.0 is+ // 0.19999999999999996 in floats, and a message that says "closer than+ // 0.2 mm" must not reject a pair that is "0.200 mm apart".+ const EPS: f64 = 1e-4;+ if gap < -EPS {+ overlaps.push(format!("{} pads {} and {} OVERLAP by {:.3} mm", pkg, a.name, b.name, -gap));+ } else if gap < 0.2 - EPS {+ close.push(format!("{} pads {} and {} are {:.3} mm apart", pkg, a.name, b.name, gap));+ }+ }+ }+ }+ if !overlaps.is_empty() {+ errors.push(LintError {+ code: "pad-clearance".into(),+ message: format!("copper OVERLAPS: {}. A pad mapping that grows into its neighbour is a short.",+ overlaps.join("; ")),+ });+ }+ if !close.is_empty() {+ warnings.push(LintError {+ code: "pad-clearance".into(),+ message: format!("copper closer than 0.2 mm: {}. Fine-pitch land patterns sit here by construction; \+check the source footprint intends it rather than a mapping having grown a pad.",+ close.join("; ")),+ });+ }+ }++ // EAGLE's align vocabulary is exactly nine values. Anything else makes Fusion log a+ // warning per element on open and fall back to bottom-left, so text anchored to an+ // edge runs OUTWARD across the body line instead of inward. adom-lbr shipped+ // align="right" on right-side group captions until 2.26.0; this stops any align value+ // reaching a library without a name EAGLE knows.+ {+ const VALID: [&str; 9] = ["bottom-left", "bottom-center", "bottom-right",+ "center-left", "center", "center-right",+ "top-left", "top-center", "top-right"];+ let re = regex_lite::Regex::new(r#"align="([^"]*)""#).unwrap();+ let mut bad: Vec<String> = Vec::new();+ for c in re.captures_iter(content) {+ let v = c[1].to_string();+ if !VALID.contains(&v.as_str()) && !bad.contains(&v) { bad.push(v); }+ }+ if !bad.is_empty() {+ errors.push(LintError {+ code: "invalid-align".into(),+ message: format!("align={:?} is not an EAGLE alignment. Valid: {}. Fusion warns per element and falls back to bottom-left, so edge-anchored text runs across the body line.",+ bad.join(", "), VALID.join(", ")),+ });+ }+ }++ // 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.+/// Write `bytes` to `path` (creating the directory), read the file back and+/// require it to match, returning the sha256. Any failure is a coded error+/// and exit 2: export-kicad once printed "OK: wrote" for two files in a+/// directory that did not exist, because every write went through+/// `fs::write(..).ok()` and nothing created the directory (wiki #26).+fn write_verified(path: &std::path::Path, bytes: &[u8]) -> String {+ use sha2::{Digest, Sha256};+ if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {+ if let Err(e) = fs::create_dir_all(dir) {+ eprintln!("ERROR: [OUTPUT_NOT_WRITTEN] cannot create {}: {e}", dir.display());+ process::exit(2);+ }+ }+ if let Err(e) = fs::write(path, bytes) {+ eprintln!("ERROR: [OUTPUT_NOT_WRITTEN] cannot write {}: {e}", path.display());+ process::exit(2);+ }+ match fs::read(path) {+ Ok(back) if back == bytes => format!("{:x}", Sha256::digest(bytes)),+ Ok(back) => {+ eprintln!("ERROR: [OUTPUT_NOT_WRITTEN] {} read back {} bytes, expected {}", path.display(), back.len(), bytes.len());+ process::exit(2);+ }+ Err(e) => {+ eprintln!("ERROR: [OUTPUT_NOT_WRITTEN] {} was written but cannot be read back: {e}", path.display());+ process::exit(2);+ }+ }+}++/// The per-file receipt after a verified write.+fn written_hint(path: &std::path::Path, bytes: usize, sha: &str) {+ println!("Hint: verified {} ({bytes} bytes, sha256 {sha}, read back identical)", path.display());+}++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.+/// Pads whose per-pad solder-mask/paste margins the Altium encoder cannot+/// carry yet (its pad-record expansion bytes are uncalibrated): named so the+/// loss is explicit, never a silent success (wiki #25).+/// A source feature a target lane cannot carry. One shape for every lane+/// (Colby's policy call on wiki #25): `code` is the machine key, `pads` the+/// pads it applies to in source order, `effect` what the target does instead.+#[derive(Debug, Clone, PartialEq)]+struct LostFeature {+ code: &'static str,+ part: String,+ feature: String,+ pads: Vec<String>,+ effect: String,+}++impl LostFeature {+ fn json(&self) -> serde_json::Value {+ serde_json::json!({"code": self.code, "part": self.part, "feature": self.feature,+ "pads": self.pads, "effect": self.effect})+ }+ fn pad_list(&self) -> String { self.pads.join(",") }+}++/// The three outputs every lane emits for a loss, in the default warn-and-ship+/// policy: the human WARN on stderr (the shape people already read), and the+/// structured LOST: json line on stdout for the wiki export workers.+fn report_lost(l: &LostFeature) {+ eprintln!("WARN: [{}] pads {} carry {}; {}.", l.code, l.pad_list(), l.feature, l.effect);+ println!("LOST: {}", l.json());+}++/// The --strict decision for a single-part lane: refuse only when something+/// is actually lost. Pure on `strict` (not the global) so tests cannot race.+fn strict_refusal(strict: bool, lane: &str, lost: &[LostFeature]) -> Option<String> {+ if !strict || lost.is_empty() { return None; }+ let what: Vec<String> = lost.iter().map(|l| format!("{} on pads {}", l.feature, l.pad_list())).collect();+ Some(format!("nothing written for the {lane} lane: refused under --strict: {}", what.join("; ")))+}++/// Pads whose KiCad per-pad solder-mask/paste margins the EAGLE lane drops.+/// parse_mod_pads runs several times per part (baseline, variants, connects),+/// so it only COLLECTS here, deduped in source order; the generate handler+/// drains the collector once, before it lints or writes, and reports with the+/// part name (wiki #25).+static EAGLE_MARGIN_PADS: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());++fn eagle_margin_collect(pad: &str) {+ if let Ok(mut v) = EAGLE_MARGIN_PADS.lock() {+ if !v.iter().any(|p| p == pad) { v.push(pad.to_string()); }+ }+}++fn eagle_margin_drain() -> Vec<String> {+ EAGLE_MARGIN_PADS.lock().map(|mut v| std::mem::take(&mut *v)).unwrap_or_default()+}++fn eagle_lost_feature(part: &str, pads: Vec<String>) -> Option<LostFeature> {+ if pads.is_empty() { return None; }+ Some(LostFeature {+ code: "lost-pad-margins",+ part: part.to_string(),+ feature: "per-pad solder-mask/paste margins".into(),+ pads,+ effect: "EAGLE has no per-pad aperture margin, so Fusion applies the library DRC stop/cream rules instead; the copper is exact, review the mask/paste apertures in Fusion before fab".into(),+ })+}++/// The Altium lane's loss: signed mm expansions ride the pad record since+/// 2.41.0; a KiCad paste RATIO has no radial equivalent.+fn altium_lost_feature(part: &str, fp: &altium_codec::Footprint) -> Option<LostFeature> {+ let pads = lost_margin_pads(fp);+ if pads.is_empty() { return None; }+ Some(LostFeature {+ code: "lost-pad-margins",+ part: part.to_string(),+ feature: "per-pad solder-paste ratio".into(),+ pads,+ effect: "Altium carries the mm mask/paste expansions; a paste ratio has no radial equivalent and is dropped, review the paste apertures before fab".into(),+ })+}++fn lost_margin_pads(fp: &altium_codec::Footprint) -> Vec<String> {+ // Signed mm expansions now ride the Altium pad record (manual mode); a+ // KiCad paste RATIO has no radial equivalent, so it is the one that is+ // still named as lost.+ fp.pads.iter()+ .filter(|p| p.paste_margin_ratio.is_some())+ .map(|p| p.number.clone())+ .collect()+}++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, package_name, variants, description, symbol_art, symbol_art_scale, pin_map, part_metadata, symbol_layout, alt_sym } => {+ let fp_content = fp.as_ref().map(|p| {+ fs::read_to_string(p).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot read footprint {}: {}", p.display(), e);+ 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);+ }+ };++ // The PACKAGE takes the footprint's own name, not the symbol's. --name renames+ // the symbol and deviceset only; it used to rename the package too, so eight+ // symbol variants of one chip produced eight differently-named copies of the+ // same footprint and `assemble` could not share a single package between them.+ parsed.package_name = package_name.clone().or_else(|| {+ fp_content.as_deref().and_then(kicad_mod_footprint_name)+ });+ if let Some(pkg) = &parsed.package_name {+ if pkg != &parsed.name {+ println!("HINT: package named \"{}\" (from the footprint); the symbol and deviceset are \"{}\". --package-name overrides.", pkg, parsed.name);+ }+ }++ let pkg_variants: Vec<PackageVariant> = variants.iter().filter_map(|spec| {+ let mut it = spec.splitn(3, '=');+ let suffix = it.next().unwrap_or_default().to_string();+ let path = it.next().unwrap_or_default();+ let desc = it.next().filter(|d| !d.is_empty()).map(|d| d.to_string());+ if suffix.is_empty() || path.is_empty() {+ eprintln!("ERROR: --variant wants SUFFIX=<file.kicad_mod>[=description], got '{spec}'");+ process::exit(1);+ }+ match fs::read_to_string(path) {+ Ok(km) => Some(PackageVariant {+ suffix: if suffix.starts_with('-') { suffix } else { format!("-{suffix}") },+ km, description: desc,+ }),+ Err(e) => { eprintln!("ERROR: cannot read variant {path}: {e}"); process::exit(1); }+ }+ }).collect();+ if !pkg_variants.is_empty() {+ println!("HINT: {} package variant(s) in ONE deviceset: {}. Fusion's CHANGE PACKAGE swaps between them without re-wiring.",+ pkg_variants.len(),+ pkg_variants.iter().map(|v| v.suffix.trim_start_matches('-')).collect::<Vec<_>>().join(", "));+ // The bridge pairs a 3D model to a package by NAME first, so the STEP for a+ // variant has to be named after the variant package rather than after the+ // part. Said here because the person generating the STEPs is usually not the+ // person reading the .lbr, and a mismatch shows up as a variant that imports+ // with no 3D at all.+ println!("HINT: name each variant's STEP after its PACKAGE so the bridge pairs it: {}.",+ pkg_variants.iter()+ .map(|v| format!("<package>{}.step", v.suffix))+ .collect::<Vec<_>>().join(", "));+ }++ 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();+ // Description precedence: an explicit --description, then the .kicad_sym's own+ // Description property, then the wiki page's brief. Whatever wins has catalogue+ // boilerplate stripped: a page brief is written for someone browsing the wiki,+ // and repeating "Datasheet-verified pinout, KiCad symbol/footprint/3D" in every+ // Fusion Description cell crowds out the words that identify the part.+ if let Some(d) = description.clone() {+ parsed.description = d;+ } else if is_generic_description(&parsed.description) {+ // The source Description is empty, or it names a CLASS rather than a part+ // ("Inductor", "Unpolarized capacitor", "Generic connector, single row").+ // The page brief says WHICH inductor, so it wins. An explicit --description+ // still beats both.+ if let Some(brief) = pm_path.as_ref().and_then(|p| page_brief(p)) {+ if parsed.description.trim().is_empty() {+ println!("HINT: symbol had no Description; using the page brief so the deviceset is not blank in Fusion.");+ } else {+ println!("HINT: the source Description is a class word (\"{}\"); using the page brief instead, which names the actual part.", parsed.description.trim());+ }+ parsed.description = brief;+ }+ }+ parsed.description = strip_catalog_boilerplate(&parsed.description);+ if parsed.description.trim().is_empty() {+ println!("HINT: this part has NO description: the deviceset, the symbol and the ADD dialog will all read blank in Fusion. Pass --description, or give the source a Description property.");+ }+ parsed.package_description = Some(package_description(fp_content.as_deref(),+ parsed.package_name.as_deref().unwrap_or(&parsed.name)));++ // A DEVICESET IS THE ORDERABLE PART, so when the symbol carries a KiCad class+ // name ("L", "Crystal_GND24", "Conn_01x02") and the page knows the MPN, the+ // deviceset takes the MPN. The SYMBOL keeps its class name: it is the drawing,+ // it is shared between every part of that class, and assemble dedupes symbols+ // by content anyway. Without this a user opening the library sees a deviceset+ // called "L" and cannot tell which inductor it is.+ if name.is_none() && is_generic_part_name(&parsed.name) {+ if let Some(mpn) = part_attrs.iter().find(|(k, _)| k == "MPN").map(|(_, v)| v.clone()) {+ let mpn = eagle_safe_name(&mpn);+ if mpn != parsed.name {+ println!("HINT: deviceset named \"{}\" from the part's MPN; the symbol keeps the class name \"{}\". --name overrides.", mpn, parsed.name);+ parsed.deviceset_name = Some(mpn);+ }+ }+ }++ // A SEMANTIC PART WITH NO ARTWORK IS AN UNFINISHED SYMBOL, and it is worth+ // saying so at generate time. A diode, LED, crystal or inductor that reaches a+ // schematic as two bare pin stubs reads as a placeholder to any engineer (wiki+ // issue #5). Almost always the source .kicad_sym does carry the glyph and it+ // was being dropped in translation, which is fixed; what is left is a source+ // that genuinely draws nothing, and that has to be fixed in the source rather+ // than invented here, because guessing a part's class from its designator and+ // drawing a triangle would be wrong the moment the guess is.+ if parsed.graphics.is_empty() && parsed.body.is_none() {+ let cls = match parsed.reference.trim_end_matches(|c: char| c.is_ascii_digit() || c == '?') {+ "D" => Some("a diode or LED"),+ "Y" | "X" => Some("a crystal or oscillator"),+ "L" => Some("an inductor"),+ "Q" => Some("a transistor"),+ _ => None,+ };+ if let Some(what) = cls {+ println!("HINT: this symbol has NO drawing of its own, and its reference \"{}\" says it is {}. \+It will render as bare pin stubs, which reads as an unfinished placeholder. Add the glyph to the source .kicad_sym \+(or pass --symbol-art), because the shape is what an engineer reads first.", parsed.reference, what);+ }+ }+ 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);+ }+ }++ // Alternate gates: `path[=GATE[=art]]`, each one more symbol of THIS part.+ let mut alts: Vec<AltGate> = Vec::new();+ for spec in &alt_sym {+ let mut it = spec.splitn(3, '=');+ let path = PathBuf::from(it.next().unwrap_or_default());+ let gate_opt = it.next().filter(|g| !g.is_empty()).map(|g| g.to_string());+ let art_opt = it.next().filter(|a| !a.is_empty() && *a != "-").map(PathBuf::from);+ let content = fs::read_to_string(&path).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot read --alt-sym {}: {}", path.display(), e);+ process::exit(1);+ });+ let mut alt = parse_kicad_sym(&content, None).unwrap_or_else(|e| {+ eprintln!("ERROR: --alt-sym {}: {}", path.display(), e);+ process::exit(1);+ });+ let gate = gate_opt.unwrap_or_else(|| {+ let pfx = format!("{}-", parsed.name);+ alt.name.strip_prefix(&pfx).map(|s| s.to_string()).unwrap_or_else(|| alt.name.clone())+ });+ if alt.name == parsed.name || alts.iter().any(|a| a.sym.name == alt.name) {+ alt.name = format!("{}-{}", parsed.name, gate);+ }+ if alt.pins.len() != parsed.pins.len() {+ eprintln!("ERROR: --alt-sym {} has {} pins, the primary symbol has {}: an alternate symbol must be the SAME part (same pin numbers)", path.display(), alt.pins.len(), parsed.pins.len());+ process::exit(1);+ }+ let art = match art_opt.as_ref().map(|p| resolve_symbol_art(p)) {+ Some(Ok(a)) => Some(a),+ Some(Err(e)) => { eprintln!("ERROR: --alt-sym art: {e}"); process::exit(1); }+ None => None,+ };+ if alts.iter().any(|a| a.gate == gate) || gate == "G$1" {+ eprintln!("ERROR: duplicate gate name {:?} in --alt-sym; give it path=GATE", gate);+ process::exit(1);+ }+ alts.push(AltGate { sym: alt, gate, art });+ }+ if !alts.is_empty() {+ println!("HINT: {} alternate symbol(s) as extra gates of {}: {}", alts.len(), parsed.name,+ alts.iter().map(|a| format!("{} ({})", a.gate, a.sym.name)).collect::<Vec<_>>().join(", "));+ }+ let lbr = generate_lbr_gates(&parsed, fp_content.as_deref(), art.as_ref(), symbol_art_scale, &pin_map, &part_attrs, &alts, &pkg_variants);++ // Losses are decided here, once, before anything is linted or+ // written: the parser only collected the margin pads.+ let lost: Vec<LostFeature> = eagle_lost_feature(&parsed.name, eagle_margin_drain()).into_iter().collect();+ for l in &lost { report_lost(l); }+ if let Some(why) = strict_refusal(cli.strict, "EAGLE/Fusion", &lost) {+ eprintln!("ERROR: [LOST_FEATURES] {why}");+ process::exit(2);+ }++ // 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);+ sourcing_hint(&sym_txt, &mpn);+ heatsink_hint(&fp_txt, &mpn);+ // 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")));+ let json_out = serde_json::to_string_pretty(&part).unwrap();+ let sha = write_verified(&outp, json_out.as_bytes());+ println!("OK: wrote {} (adom-lbr JSON — the canonical hub)", outp.display());+ written_hint(&outp, json_out.len(), &sha);+ 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 lost: Vec<LostFeature> = altium_lost_feature(&p.mpn, &p.footprint).into_iter().collect();+ for l in &lost { report_lost(l); }+ if let Some(why) = strict_refusal(cli.strict, "Altium", &lost) {+ eprintln!("ERROR: [LOST_FEATURES] {why}");+ process::exit(2);+ }+ 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));+ let sha_s = write_verified(&sp, &sym_bytes);+ let sha_p = write_verified(&pp, &fp_bytes);+ println!("OK: wrote {} ({} pins) + {} ({} pads)",+ sp.display(), p.symbol.pins.len(), pp.display(), p.footprint.pads.len());+ written_hint(&sp, sym_bytes.len(), &sha_s);+ written_hint(&pp, fp_bytes.len(), &sha_p);+ }++ Commands::AssembleAltium { out, parts, no_dedupe } => {+ use serde_json::{json, Value};+ use std::collections::{BTreeMap, BTreeSet};+ let mut symbols: Vec<altium_codec::Symbol> = Vec::new();+ let mut fps: Vec<altium_codec::Footprint> = Vec::new();+ // Content key (footprint minus its name) -> the shipped footprint+ // name, so identical land patterns collapse to ONE footprint (and+ // ONE 3D binding downstream), mirroring the EAGLE assemble and the+ // 2.21.0 package-sharing rule.+ let mut models: Vec<Option<altium_codec::EmbeddedModel>> = Vec::new();+ let mut fp_index: BTreeMap<String, usize> = BTreeMap::new();+ let mut model_missing: Vec<Value> = Vec::new();+ let mut lost_features: Vec<Value> = Vec::new();+ let mut refused: Vec<Value> = Vec::new();+ let mut fp_by_content: BTreeMap<String, String> = BTreeMap::new();+ let mut fp_shared: BTreeMap<String, Vec<String>> = BTreeMap::new();+ let mut fp_names: BTreeSet<String> = BTreeSet::new();+ let mut comp_names: BTreeSet<String> = BTreeSet::new();+ let mut renamed: Vec<Value> = Vec::new();+ let mut skipped: Vec<Value> = Vec::new();+ let unique = |taken: &BTreeSet<String>, want: &str| -> String {+ if !taken.contains(want) { return want.to_string(); }+ (2..).map(|i| format!("{want}@{i}")).find(|c| !taken.contains(c)).unwrap()+ };+ for path in &parts {+ let raw = match fs::read_to_string(path) {+ Ok(r) => r,+ Err(e) => { skipped.push(json!({"part": path.display().to_string(), "reason": format!("read: {e}")})); continue; }+ };+ let p: altium_codec::AdomLbrPart = match serde_json::from_str(&raw) {+ Ok(p) => p,+ Err(e) => { skipped.push(json!({"part": path.display().to_string(), "reason": format!("parse: {e}")})); continue; }+ };+ let mut sym = p.symbol.clone();+ seed_parameters(&mut sym, &p);+ if let Some(l) = altium_lost_feature(&p.mpn, &p.footprint) {+ report_lost(&l);+ if let Some(why) = strict_refusal(cli.strict, "Altium", std::slice::from_ref(&l)) {+ // Under --strict the part is refused, the rest still+ // assemble; the loss stays a NAMED entry, never silent.+ skipped.push(json!({"part": path.display().to_string(), "reason": why}));+ refused.push(l.json());+ continue;+ }+ lost_features.push(l.json());+ }+ // The component a user picks in Altium is the ORDERABLE part,+ // so it takes the MPN (the deviceset=MPN doctrine); the source+ // symbol name is often a shared class name like Conn_01x02.+ let want = if p.mpn.is_empty() { sym.name.clone() } else { p.mpn.clone() };+ let comp_name = unique(&comp_names, &want);+ if comp_name != want {+ renamed.push(json!({"kind": "component", "from": want, "to": comp_name, "part": p.mpn}));+ }+ comp_names.insert(comp_name.clone());+ sym.name = comp_name;+ // Footprint content-dedup: same geometry = one footprint.+ let mut content_probe = p.footprint.clone();+ content_probe.name = if no_dedupe { p.footprint.name.clone() } else { String::new() };+ let key = serde_json::to_string(&content_probe).unwrap_or_default();+ // The part's STEP body (wiki adom-lbr#22): model_3d as a path+ // (relative to the JSON), else <mpn>.step beside it. A missing+ // model is a NAMED report entry, never silent.+ // Path::parent() on a BARE file name yields Some("") rather+ // than None, and read_dir("") finds nothing, so a part passed+ // as a plain relative name silently lost its sibling STEP+ // (wiki #23). Empty means the current directory.+ let json_dir = match path.parent() {+ Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),+ _ => PathBuf::from("."),+ };+ let step_path = p.model_3d.as_deref().filter(|m| !m.is_empty())+ .map(|m| { let mp = PathBuf::from(m); if mp.is_absolute() { mp } else { json_dir.join(mp) } })+ .filter(|mp| mp.is_file())+ .or_else(|| {+ // wiki pages store the STEP under the lowercase slug+ // stem, so match the <mpn>.step candidate by+ // case-insensitive file name.+ let want: Vec<String> = ["step", "stp"].iter()+ .map(|e| format!("{}.{e}", p.mpn).to_lowercase()).collect();+ fs::read_dir(&json_dir).ok().and_then(|rd| {+ rd.flatten()+ .map(|e| e.path())+ .find(|c| c.file_name()+ .map(|n| want.contains(&n.to_string_lossy().to_lowercase()))+ .unwrap_or(false))+ })+ });+ let part_model = step_path.and_then(|sp| match fs::read(&sp) {+ Ok(step) => Some(altium_codec::EmbeddedModel {+ name: format!("{}.step", p.mpn), step,+ rot_x: 0.0, rot_y: 0.0, rot_z: 0.0, dz_mil: 0.0, overall_height_mil: 0.0,+ }),+ Err(e) => { model_missing.push(json!({"part": p.mpn, "reason": format!("read {}: {e}", sp.display())})); None }+ });+ if part_model.is_none() && model_missing.iter().all(|m| m["part"] != json!(p.mpn.clone())) {+ model_missing.push(json!({"part": p.mpn,+ "reason": "no 3D model: model_3d is empty and no <mpn>.step sits beside the JSON"}));+ }+ let fp_name = if let Some(existing) = fp_by_content.get(&key) {+ fp_shared.entry(existing.clone()).or_default().push(p.mpn.clone());+ // A later part can supply the model its shared footprint lacks.+ if let (Some(i), Some(m)) = (fp_index.get(existing), part_model) {+ if models[*i].is_none() { models[*i] = Some(m); }+ }+ existing.clone()+ } else {+ let want_fp = if p.footprint.name.is_empty() { format!("{}-fp", p.mpn) } else { p.footprint.name.clone() };+ let n = unique(&fp_names, &want_fp);+ if n != want_fp {+ renamed.push(json!({"kind": "footprint", "from": want_fp, "to": n, "part": p.mpn}));+ }+ fp_names.insert(n.clone());+ fp_by_content.insert(key, n.clone());+ fp_shared.insert(n.clone(), vec![p.mpn.clone()]);+ let mut f = p.footprint.clone();+ f.name = n.clone();+ fp_index.insert(n.clone(), fps.len());+ fps.push(f);+ models.push(part_model);+ n+ };+ sym.footprint = Some(fp_name);+ symbols.push(sym);+ }+ if symbols.is_empty() {+ println!("{}", json!({"status": "error", "error": "no parts assembled", "skipped": skipped}));+ process::exit(2);+ }+ let sch_bytes = altium_codec::encode_schlib(&symbols)+ .unwrap_or_else(|e| { println!("{}", json!({"status":"error","error": format!("encode SchLib: {e:#}"), "skipped": skipped})); process::exit(2); });+ let pcb_bytes = altium_codec::encode_pcblib_many_with_models(&fps, &models)+ .unwrap_or_else(|e| { println!("{}", json!({"status":"error","error": format!("encode PcbLib: {e:#}"), "skipped": skipped})); process::exit(2); });+ let base = out.with_extension("");+ let sp = base.with_extension("SchLib");+ let pp = base.with_extension("PcbLib");+ if let Err(e) = fs::write(&sp, &sch_bytes) { println!("{}", json!({"status":"error","error": format!("write {}: {e}", sp.display())})); process::exit(2); }+ if let Err(e) = fs::write(&pp, &pcb_bytes) { println!("{}", json!({"status":"error","error": format!("write {}: {e}", pp.display())})); process::exit(2); }+ let deduped: Vec<Value> = fp_shared.iter().filter(|(_, v)| v.len() > 1)+ .map(|(k, v)| json!({"footprint": k, "sharedBy": v})).collect();+ let partial = !refused.is_empty();+ println!("{}", serde_json::to_string_pretty(&json!({+ "status": if partial { "partial" } else { "ok" },+ "schlib": sp.display().to_string(),+ "pcblib": pp.display().to_string(),+ "components": symbols.len(),+ "footprints": fps.len(),+ "deduped": deduped,+ "renamed": renamed,+ "skipped": skipped,+ "models": {+ "embedded": models.iter().filter(|m| m.is_some()).count(),+ "missing": model_missing,+ },+ "lostFeatures": lost_features,+ "refused": refused,+ "_hint": "components are named by MPN; identical land patterns share one footprint. skipped[] is the complete drop list, never silent. lostFeatures[] names what the Altium lane could not carry (warn and ship); under --strict those parts move to refused[] and skipped[], the rest still assemble, status is partial and the exit code 2 while the files written stay valid."+ })).unwrap());+ if partial {+ process::exit(2);+ }+ }++ Commands::ExportIntlib { part, out } => {+ let p = load_part(&part);+ let lost: Vec<LostFeature> = altium_lost_feature(&p.mpn, &p.footprint).into_iter().collect();+ for l in &lost { report_lost(l); }+ if let Some(why) = strict_refusal(cli.strict, "Altium", &lost) {+ eprintln!("ERROR: [LOST_FEATURES] {why}");+ process::exit(2);+ }+ 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)));+ let sha = write_verified(&outp, &bytes);+ println!("OK: wrote {} ({} bytes) — symbol + footprint bundled", outp.display(), bytes.len());+ written_hint(&outp, bytes.len(), &sha);+ }++ 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));+ let sha_s = write_verified(&sp, sym_txt.as_bytes());+ let sha_f = write_verified(&fp, fp_txt.as_bytes());+ println!("OK: wrote {} ({} pins) + {} ({} pads, {} graphics)",+ sp.display(), p.symbol.pins.len(), fp.display(),+ p.footprint.pads.len(), p.footprint.graphics.len());+ written_hint(&sp, sym_txt.len(), &sha_s);+ written_hint(&fp, fp_txt.len(), &sha_f);+ }++ 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"));+ let sha = write_verified(&outp, &merged);+ println!("OK: wrote {} — added {} symbol(s) into the library", outp.display(), syms.len());+ written_hint(&outp, merged.len(), &sha);+ }++ 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")));+ let json_out = serde_json::to_string_pretty(&part).unwrap();+ let sha = write_verified(&outp, json_out.as_bytes());+ println!("OK: wrote {} — {} pins, {} pads, {} footprint graphics",+ outp.display(), part.symbol.pins.len(), part.footprint.pads.len(), part.footprint.graphics.len());+ written_hint(&outp, json_out.len(), &sha);+ }++ 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");+ write_verified(&skill_dir.join("SKILL.md"), SKILL.as_bytes());+ 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 lost_feature_tests {+ use super::*;++ #[test]+ fn eagle_collector_dedupes_across_parses_and_drains() {+ // the parser runs several times per part; the collector keeps one+ // entry per pad in source order and empties on drain+ let km = r#"(footprint "M"+ (pad "1" smd rect (at 0 0) (size 1 1) (solder_mask_margin -0.05))+ (pad "2" smd rect (at 1 0) (size 1 1) (solder_paste_margin -0.05))+ (pad "3" smd rect (at 2 0) (size 1 1)))"#;+ let _ = eagle_margin_drain();+ let _ = parse_mod_pads(km);+ let _ = parse_mod_pads(km);+ let _ = parse_mod_pads(km);+ let pads = eagle_margin_drain();+ assert_eq!(pads, vec!["1".to_string(), "2".to_string()], "one entry per pad, source order");+ assert!(eagle_margin_drain().is_empty(), "drain empties the collector");+ let lost = eagle_lost_feature("M", pads).expect("a loss");+ assert_eq!(lost.code, "lost-pad-margins");+ assert_eq!(lost.json()["pads"], serde_json::json!(["1", "2"]));+ assert!(eagle_lost_feature("M", vec![]).is_none(), "no margin pads, no loss");+ }++ #[test]+ fn strict_refuses_only_when_something_is_lost() {+ let l = LostFeature { code: "lost-pad-margins", part: "P".into(),+ feature: "per-pad solder-mask/paste margins".into(), pads: vec!["1".into(), "2".into()],+ effect: "x".into() };+ assert!(strict_refusal(false, "EAGLE/Fusion", std::slice::from_ref(&l)).is_none(), "default ships");+ assert!(strict_refusal(true, "EAGLE/Fusion", &[]).is_none(), "nothing lost, nothing refused");+ let why = strict_refusal(true, "EAGLE/Fusion", std::slice::from_ref(&l)).expect("refused");+ assert_eq!(why, "nothing written for the EAGLE/Fusion lane: refused under --strict: per-pad solder-mask/paste margins on pads 1,2");+ }++ #[test]+ fn altium_lane_loses_only_the_paste_ratio() {+ let sym = r#"(kicad_symbol_lib (version 20231120) (generator "t")+ (symbol "P" (property "Reference" "U" (at 0 0 0))+ (symbol "P_1_1"+ (pin passive line (at -5.08 0 0) (length 2.54) (name "A" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))+ (pin passive line (at -5.08 -2.54 0) (length 2.54) (name "B" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))))"#;+ let km = r#"(footprint "P" (layer "F.Cu")+ (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu" "F.Paste" "F.Mask") (solder_mask_margin -0.05) (solder_paste_margin -0.05))+ (pad "2" smd rect (at 2 0) (size 1 1) (layers "F.Cu" "F.Paste" "F.Mask") (solder_paste_margin_ratio -0.1)))"#;+ let part = altium_codec::import_kicad(sym, km, "P").expect("import");+ let lost = altium_lost_feature(&part.mpn, &part.footprint).expect("the ratio is a loss");+ assert_eq!(lost.pads, vec!["2".to_string()], "mm margins on pad 1 are carried; only the ratio pad is lost");+ assert_eq!(lost.feature, "per-pad solder-paste ratio");+ let mut carried = part.footprint.clone();+ carried.pads.retain(|p| p.number == "1");+ assert!(altium_lost_feature("P", &carried).is_none(), "mm margins alone are not a loss on the Altium lane");+ }+}++#[cfg(test)]+mod lint_tests {+ #[test]+ fn write_verified_creates_the_directory_and_returns_the_hash() {+ use sha2::{Digest, Sha256};+ let dir = std::env::temp_dir().join(format!("adom-lbr-wv-{}-{}", std::process::id(),+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()));+ let path = dir.join("nested").join("out.txt");+ assert!(!dir.exists(), "fresh temp dir must not pre-exist");+ let sha = super::write_verified(&path, b"hello export");+ assert_eq!(std::fs::read(&path).unwrap(), b"hello export", "the file must exist with the bytes");+ assert_eq!(sha, format!("{:x}", Sha256::digest(b"hello export")));+ let _ = std::fs::remove_dir_all(&dir);+ }++ use super::{dedup_pad_names, lint_lbr};++ // ---- 2.21.0 / 2.22.x conversion fidelity -------------------------------------+ // Every one of these guards a bug that reached a user's Fusion install, so each test+ // names the symptom rather than the function.++ #[test]+ fn kicad_pin_length_maps_to_the_nearest_eagle_stop_not_the_next_one_up() {+ use super::pin_length_keyword;+ // 2.794 is the standard 0.11 in pin on the wiki's passives. The old ceiling rule+ // sent it to "middle" (5.08), so a two-pin part drew as one line with its pins+ // overlapping by 2.54 mm.+ assert_eq!(pin_length_keyword(2.794), "short");+ assert_eq!(pin_length_keyword(0.0), "point");+ assert_eq!(pin_length_keyword(2.54), "short");+ assert_eq!(pin_length_keyword(5.08), "middle");+ assert_eq!(pin_length_keyword(7.62), "long");+ assert_eq!(pin_length_keyword(20.0), "long");+ // a value nearer the upper stop still rounds up+ assert_eq!(pin_length_keyword(4.9), "middle");+ }++ #[test]+ fn unnamed_pins_take_the_pad_number_and_a_pin_really_called_p_survives() {+ use super::{dedup_pin_names, KicadPin};+ let pin = |name: &str, number: &str| KicadPin {+ name: name.to_string(), number: number.to_string(),+ x: 0.0, y: 0.0, angle: 0, length: 2.54, pin_type: "passive".to_string(),+ };+ // "~" is KiCad's unnamed marker; it used to sanitise to the literal "P", so an+ // unnamed two-pin part shipped pins called "P" and "P@2".+ let got = dedup_pin_names(&[pin("~", "1"), pin("~", "2")]);+ assert_eq!(got, vec!["1".to_string(), "2".to_string()]);+ // an empty name behaves the same way+ assert_eq!(dedup_pin_names(&[pin("", "7")]), vec!["7".to_string()]);+ // ...but a pin genuinely named P keeps its name+ assert_eq!(dedup_pin_names(&[pin("P", "3")]), vec!["P".to_string()]);+ // and real names are untouched, including the @-suffix for a repeat+ assert_eq!(dedup_pin_names(&[pin("GND", "4"), pin("GND", "5")]),+ vec!["GND".to_string(), "GND@2".to_string()]);+ }++ #[test]+ fn catalog_boilerplate_is_stripped_without_mangling_decimals() {+ use super::strip_catalog_boilerplate;+ assert_eq!(+ strip_catalog_boilerplate("WCH USB Converters, SOP-16. Datasheet-verified pinout, KiCad symbol/footprint/3D."),+ "WCH USB Converters, SOP-16");+ // the first version split on every '.' and rejoined, turning a voltage into "2. 5V"+ assert_eq!(strip_catalog_boilerplate("2.5V precision reference"), "2.5V precision reference");+ assert_eq!(strip_catalog_boilerplate("STM32F103. 72 MHz Cortex-M3"), "STM32F103. 72 MHz Cortex-M3");+ // nothing but boilerplate: keep the original rather than emit an empty description+ assert!(!strip_catalog_boilerplate("Datasheet-verified pinout").is_empty());+ // the real ray/srn6045-100m brief ends with a BARE "Datasheet-verified.", which the+ // longer phrase did not match, so it survived on the end of the description+ assert_eq!(+ strip_catalog_boilerplate("Bourns 10 uH semi-shielded SMD power inductor, 6.0x6.0 mm. 3.0 A Isat, 58.6 mOhm DCR. Datasheet-verified."),+ "Bourns 10 uH semi-shielded SMD power inductor, 6.0x6.0 mm. 3.0 A Isat, 58.6 mOhm DCR");+ // and the longer form must not strand the word "pinout"+ assert!(!strip_catalog_boilerplate("ADR4525 reference. Datasheet-verified pinout.").contains("pinout"));+ }++ #[test]+ fn package_description_leads_with_geometry_and_never_states_a_bad_measurement() {+ use super::package_description;+ // Fusion's Description column shows ~20 characters, so the identifying words go first.+ assert_eq!(package_description(None, "SOIC-8_3.9x4.9mm_P1.27mm"),+ "SOIC-8, 3.9 x 4.9 mm, 1.27 mm pitch");+ // a descr that merely restates the geometry is dropped rather than appended+ let restating = r#"(footprint "SOIC-8_3.9x4.9mm_P1.27mm" (descr "SOIC, 8 Pin"))"#;+ assert_eq!(package_description(Some(restating), "SOIC-8_3.9x4.9mm_P1.27mm"),+ "SOIC-8, 3.9 x 4.9 mm, 1.27 mm pitch");+ // a descr that adds something is appended AFTER the geometry+ let adds = r#"(footprint "SOIC-8_3.9x4.9mm_P1.27mm" (descr "Analog Devices precision reference"))"#;+ assert!(package_description(Some(adds), "SOIC-8_3.9x4.9mm_P1.27mm")+ .starts_with("SOIC-8, 3.9 x 4.9 mm, 1.27 mm pitch. "));+ // a URL in the descr does not leave the bracket that introduced it behind+ let urly = r#"(footprint "X" (descr "SOIC, 8 Pin (JEDEC MS-012AA, https://example.com/x.pdf)"))"#;+ let out = package_description(Some(urly), "X");+ assert!(!out.contains("http"), "{out}");+ assert!(!out.ends_with('('), "{out}");+ assert!(out.matches('(').count() == out.matches(')').count(), "unbalanced: {out}");+ }++ #[test]+ fn a_footprint_named_by_mpn_is_measured_rather_than_repeating_its_own_name() {+ use super::package_description;+ // Two 1.0 x 0.5 mm pads 1.0 mm apart: a plain chip land pattern.+ let km = r#"(footprint "0603N101J500CT"+ (pad "1" smd rect (at -0.75 0) (size 1.0 0.5) (layers "F.Cu"))+ (pad "2" smd rect (at 0.75 0) (size 1.0 0.5) (layers "F.Cu")))"#;+ let out = package_description(Some(km), "0603N101J500CT");+ assert!(out.starts_with("2 pads,"), "{out}");+ assert!(!out.contains("0603N101J500CT"), "must not repeat the Name column: {out}");+ // The first version measured the SILKSCREEN, which on a chip part is two ticks, and+ // reported "0.6 x 0.0 mm". A confidently wrong measurement is worse than none.+ assert!(!out.contains("0.0 mm"), "{out}");+ assert!(out.contains("2.5 x 0.5 mm"), "expected the copper extent: {out}");+ }++ #[test]+ fn group_captions_map_to_eagle_alignment_not_a_bare_right() {+ use super::parse_kicad_sym;+ // adom-symbol's four caption forms, verbatim in shape: left and right unrotated,+ // top and bottom rotated, with the angle written in TENTHS of a degree (900 = 90).+ let src = r#"(kicad_symbol_lib (version 20211014) (generator adom)+ (symbol "T" (property "Reference" "U" (id 0) (at 0 0 0))+ (symbol "T_1_1"+ (text "Handshake" (at -11.68 0.00 0)+ (effects (font (size 0.762 0.762)) (justify left)))+ (text "UART" (at 11.68 0.00 0)+ (effects (font (size 0.762 0.762)) (justify right)))+ (text "Power" (at -7.72 7.08 900)+ (effects (font (size 0.762 0.762)) (justify right)))+ (text "Ground" (at -7.72 -22.65 900)+ (effects (font (size 0.762 0.762)) (justify left)))+ (pin passive line (at -15.24 0 0) (length 2.54)+ (name "A" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))+ )))"#;+ let sym = parse_kicad_sym(src, None).expect("parses");+ let by = |t: &str| sym.group_labels.iter().find(|g| g.text == t).expect(t).clone();+ // left: unrotated, anchored on its left end+ let l = by("Handshake"); assert_eq!(l.angle, 0); assert!(!l.justify_right);+ // right: unrotated, anchored on its RIGHT end. Emitting a bare align="right" here+ // is what made Fusion warn and fall back to left anchoring, so the caption ran out+ // across the body line.+ let r = by("UART"); assert_eq!(r.angle, 0); assert!(r.justify_right);+ // top and bottom: rotated. 900 is tenths of a degree; reading it as degrees gives+ // 900 % 360 = 180 and loses the rotation.+ let t = by("Power"); assert_eq!(t.angle, 90); assert!(t.justify_right);+ let b = by("Ground"); assert_eq!(b.angle, 90); assert!(!b.justify_right);+ }++ #[test]+ fn user_layers_are_mapped_and_declared_rather_than_dropped() {+ use super::{kicad_layer_to_eagle, used_user_layers, user_layer_decls};+ // An unmapped layer returned None and the geometry vanished: the LAN7800 build lost+ // 296 solder-ball circles that way, with nothing said.+ assert_eq!(kicad_layer_to_eagle("Adom.SolderBalls"), Some(101));+ assert_eq!(kicad_layer_to_eagle("User.9"), Some(101));+ assert_eq!(kicad_layer_to_eagle("Adom.HeatsinkVias"), Some(102));+ assert_eq!(kicad_layer_to_eagle("User.4"), Some(114));+ assert_eq!(kicad_layer_to_eagle("F.SilkS"), Some(21)); // unchanged+ assert_eq!(kicad_layer_to_eagle("F.Mask"), None); // still skipped+ let km = r#"(footprint "T" (fp_circle (center 0 0) (end 1 0) (layer "Adom.SolderBalls"))+ (fp_line (start 0 0) (end 1 1) (layer "User.4")))"#;+ let used = used_user_layers(km);+ assert!(used.contains(&101) && used.contains(&114) && used.len() == 2);+ // Fusion IGNORES geometry on a layer the file never declares, so the declaration is+ // not decoration.+ let decls = user_layer_decls(&used);+ assert!(decls.contains(r#"<layer number="101" name="SolderBalls""#), "{decls}");+ assert!(decls.contains(r#"<layer number="114" name="User4""#), "{decls}");+ // and layers nothing draws on are not declared+ assert!(!decls.contains("102"), "{decls}");+ }++ #[test]+ fn a_tall_thru_pad_elongates_along_its_own_long_axis() {+ use super::tht_pad_geometry;+ // The JST PH pad from wiki #19: 1.2 wide, 1.75 tall, on a 2.0 mm pitch. Passing+ // dx.max(dy) as the diameter drew it 3.5 x 1.75 elongated along X, so its copper+ // reached 0.35 mm inside its neighbour: a short between pin 1 and pin 2.+ let (shape, dia, rot, warn) = tht_pad_geometry("oval", 1.2, 1.75);+ assert_eq!(shape, "long");+ assert_eq!(dia, 1.2, "diameter is the SHORT side");+ assert_eq!(rot, 90.0, "a tall pad elongates along Y, which EAGLE writes as R90");+ assert!(warn.is_some(), "the fixed 2:1 draws 2.4 where the source said 1.75");+ // a WIDE oval keeps the same rule with no rotation+ let (_, dia, rot, _) = tht_pad_geometry("oval", 1.75, 1.2);+ assert_eq!((dia, rot), (1.2, 0.0));+ // roundrect has no thru-hole equivalent: elongated becomes long...+ let (shape, dia, _, warn) = tht_pad_geometry("roundrect", 1.2, 1.75);+ assert_eq!((shape.as_str(), dia), ("long", 1.2));+ assert!(warn.is_some());+ // ...and a squarish one becomes round on the SHORT side, never the long one, and+ // says that it dropped something.+ let (shape, dia, _, warn) = tht_pad_geometry("roundrect", 1.2, 1.3);+ assert_eq!((shape.as_str(), dia), ("round", 1.2));+ assert!(warn.is_some(), "silently rounding away the long side is what this fixes");+ // an ordinary circle is untouched+ assert_eq!(tht_pad_geometry("circle", 1.2, 1.2).0, "round");+ // rect: never square on the LONG side (that grew TO-92_Inline pad 1 into pad 2).+ let (shape, dia, rot, warn) = tht_pad_geometry("rect", 1.05, 1.5);+ assert_eq!((shape.as_str(), dia, rot), ("long", 1.05, 90.0));+ assert!(warn.is_some());+ let (shape, dia, _, warn) = tht_pad_geometry("rect", 1.3, 1.3);+ assert_eq!((shape.as_str(), dia), ("square", 1.3));+ assert!(warn.is_none(), "a truly square pad converts silently");+ }++ #[test]+ fn pads_that_overlap_are_a_lint_error_but_same_net_vias_are_not() {+ use super::lint_lbr;+ let lib = |pads: &str| format!(r#"<eagle><drawing><layers></layers><library>+ <packages><package name="P">{pads}</package></packages>+ <symbols><symbol name="S"><pin name="A" x="0" y="0" length="short"/></symbol></symbols>+ <devicesets><deviceset name="D"><gates><gate name="G$1" symbol="S" x="0" y="0"/></gates>+ <devices><device name="" package="P"><connects><connect gate="G$1" pin="A" pad="1"/></connects>+ <technologies><technology name=""/></technologies></device></devices></deviceset></devicesets>+ </library></drawing></eagle>"#);+ // the shipped defect: a long pad drawn 2:1 along X reaches into its neighbour+ let bad = lib(r#"<pad name="1" x="0" y="0" drill="0.75" diameter="1.2" shape="round"/>+ <pad name="2" x="2.0" y="0" drill="0.75" diameter="1.75" shape="long"/>"#);+ let r = lint_lbr(&bad);+ assert!(!r.passed, "overlapping copper must fail");+ assert!(r.errors.iter().any(|e| e.code == "pad-clearance"), "{:?}", r.errors);+ // the same pads mapped correctly: short side as diameter, rotated to the long axis+ let good = lib(r#"<pad name="1" x="0" y="0" drill="0.75" diameter="1.2" shape="long" rot="R90"/>+ <pad name="2" x="2.0" y="0" drill="0.75" diameter="1.2" shape="long" rot="R90"/>"#);+ assert!(!lint_lbr(&good).errors.iter().any(|e| e.code == "pad-clearance"));+ // heatsink vias sit INSIDE the pad they vent and share its net, so they are not a+ // clearance problem: comparing them would fail every thermal-via footprint.+ let vias = lib(r#"<smd name="EP" x="0" y="0" dx="2" dy="2"/>+ <smd name="EP@2" x="0.5" y="0.5" dx="0.4" dy="0.4"/>+ <smd name="EP@3" x="-0.5" y="0.5" dx="0.4" dy="0.4"/>"#);+ assert!(!lint_lbr(&vias).errors.iter().any(|e| e.code == "pad-clearance"));+ }++ #[test]+ fn a_round_pad_off_a_square_pads_corner_is_measured_as_copper_not_boxes() {+ // KiCad's stock Package_TO_SOT_THT:TO-92 (staggered): square pad 1 at (0,0), round pad+ // 2 at (1.27,-1.27), both 1.3 mm. Real clearance 0.227 mm; the bounding boxes overlap,+ // and the box test failed every TO-92 part as "pads 1 and 2 OVERLAP".+ use super::lint_lbr;+ let lib = |pads: &str| format!(r#"<eagle><drawing><layers></layers><library>+ <packages><package name="P">{pads}</package></packages>+ <symbols><symbol name="S"><pin name="A" x="0" y="0" length="short"/></symbol></symbols>+ <devicesets><deviceset name="D"><gates><gate name="G$1" symbol="S" x="0" y="0"/></gates>+ <devices><device name="" package="P"><connects><connect gate="G$1" pin="A" pad="1"/></connects>+ <technologies><technology name=""/></technologies></device></devices></deviceset></devicesets>+ </library></drawing></eagle>"#);+ let to92 = lib(r#"<pad name="1" x="0" y="0" drill="0.75" diameter="1.3" shape="square"/>+ <pad name="2" x="1.27" y="-1.27" drill="0.75" diameter="1.3" shape="round"/>+ <pad name="3" x="2.54" y="0" drill="0.75" diameter="1.3" shape="round"/>"#);+ let r = lint_lbr(&to92);+ assert!(!r.errors.iter().any(|e| e.code == "pad-clearance"), "{:?}", r.errors);+ // KiCad's TO-92_Inline, converted with the rect rule: pad 1 long on its 1.05 short side+ let inline = lib(r#"<pad name="1" x="0" y="0" drill="0.75" diameter="1.05" shape="long" rot="R90"/>+ <pad name="2" x="1.27" y="0" drill="0.75" diameter="1.05" shape="long" rot="R90"/>+ <pad name="3" x="2.54" y="0" drill="0.75" diameter="1.05" shape="long" rot="R90"/>"#);+ assert!(!lint_lbr(&inline).errors.iter().any(|e| e.code == "pad-clearance"));+ // a round pad that really does reach into the square pad's corner still fails+ let corner = lib(r#"<pad name="1" x="0" y="0" drill="0.75" diameter="1.3" shape="square"/>+ <pad name="2" x="0.9" y="-0.9" drill="0.5" diameter="1.3" shape="round"/>"#);+ assert!(lint_lbr(&corner).errors.iter().any(|e| e.code == "pad-clearance"));+ // and beside an edge the gap is the plain axis gap: side by side, overlapping, fails+ let side = lib(r#"<pad name="1" x="0" y="0" drill="0.75" diameter="1.3" shape="square"/>+ <pad name="2" x="1.2" y="0" drill="0.75" diameter="1.3" shape="round"/>"#);+ assert!(lint_lbr(&side).errors.iter().any(|e| e.code == "pad-clearance"));+ }++ #[test]+ fn fine_pitch_gaps_warn_instead_of_failing_and_the_exact_boundary_passes_clean() {+ // wiki #20: a flat 0.2 mm ERROR rejected 559 of 1314 stock KiCad footprints,+ // and 225 of them sat at exactly 0.200 mm because of float representation.+ use super::lint_lbr;+ let lib = |pads: &str| format!(r#"<eagle><drawing><layers></layers><library>+ <packages><package name="P">{pads}</package></packages>+ <symbols><symbol name="S"><pin name="A" x="0" y="0" length="short"/></symbol></symbols>+ <devicesets><deviceset name="D"><gates><gate name="G$1" symbol="S" x="0" y="0"/></gates>+ <devices><device name="" package="P"><connects><connect gate="G$1" pin="A" pad="1"/></connects>+ <technologies><technology name=""/></technologies></device></devices></deviceset></devicesets>+ </library></drawing></eagle>"#);+ // a 0.5 mm-pitch SOT-563 row: 0.150 mm apart by construction. WARNING, never error.+ let fine = lib(r#"<smd name="1" x="0" y="0" dx="0.675" dy="0.35"/>+ <smd name="2" x="0" y="0.5" dx="0.675" dy="0.35"/>"#);+ let r = lint_lbr(&fine);+ assert!(r.errors.iter().all(|e| e.code != "pad-clearance"), "fine pitch must not FAIL: {:?}", r.errors);+ assert!(r.warnings.iter().any(|w| w.code == "pad-clearance"), "but it should still WARN: {:?}", r.warnings);+ // exactly 0.200 mm apart: 1.0 mm rect pads at x -0.6 and 0.6 compute a gap of+ // 0.19999999999999996 in floats. The epsilon keeps the rule's own wording true:+ // "closer than 0.2 mm" cannot reject "0.200 mm apart". Clean, not even a warning.+ let boundary = lib(r#"<smd name="1" x="-0.6" y="0" dx="1.0" dy="1.0"/>+ <smd name="2" x="0.6" y="0" dx="1.0" dy="1.0"/>"#);+ let rb = lint_lbr(&boundary);+ assert!(rb.errors.iter().all(|e| e.code != "pad-clearance"), "{:?}", rb.errors);+ assert!(rb.warnings.iter().all(|w| w.code != "pad-clearance"), "{:?}", rb.warnings);+ // negative control: genuinely overlapping copper is still a hard error.+ let overlap = lib(r#"<smd name="1" x="0" y="0" dx="1.2" dy="1.2"/>+ <smd name="2" x="1.0" y="0" dx="1.2" dy="1.2"/>"#);+ assert!(lint_lbr(&overlap).errors.iter().any(|e| e.code == "pad-clearance"));+ }++ #[test]+ fn roundrect_pads_carry_their_corner_radius_into_eagle() {+ use super::parse_mod_pads;+ let km = r#"(footprint "T"+ (pad "1" smd roundrect (at -2 0) (size 1.0 0.5) (roundrect_rratio 0.25) (layers "F.Cu"))+ (pad "2" smd roundrect (at 2 0) (size 1.0 0.5) (roundrect_rratio 0.5) (layers "F.Cu"))+ (pad "3" smd rect (at 0 2) (size 1.0 0.5) (layers "F.Cu"))+ (pad "4" smd oval (at 0 -2) (size 1.0 0.5) (layers "F.Cu")))"#;+ let pads = parse_mod_pads(km);+ let r = |n: &str| pads.iter().find(|p| p.name == n).unwrap().roundness;+ // KiCad states the radius as a RATIO of the smaller side, EAGLE as a PERCENT of it,+ // and a ratio of 0.5 is a fully rounded end, so N = r * 200.+ assert_eq!(r("1"), Some(50));+ assert_eq!(r("2"), Some(100));+ // a plain rect stays sharp: absent, not zero, so the attribute is simply not emitted+ assert_eq!(r("3"), None);+ // an OVAL pad is a stadium, which is a fully rounded roundrect. Not in the original+ // ask, but it is what oval MEANS, and these were importing square too.+ assert_eq!(r("4"), Some(100));+ }++ #[test]+ fn a_heatsink_via_joins_the_pad_it_sits_inside() {+ use super::{owning_pad, parse_mod_pads};+ let km = r#"(footprint "T"+ (pad "EP" smd rect (at 0 0) (size 2 2) (layers "F.Cu"))+ (pad "1" smd rect (at -5 0) (size 1 1) (layers "F.Cu"))+ (pad "V1" thru_hole circle (at 0.5 0.5) (size 0.4 0.4) (drill 0.2) (layers "*.Cu"))+ (pad "V9" thru_hole circle (at 9 9) (size 0.4 0.4) (drill 0.2) (layers "*.Cu")))"#;+ let pads = parse_mod_pads(km);+ let base: Vec<_> = parse_mod_pads(km).into_iter().filter(|p| p.name == "EP" || p.name == "1").collect();+ let v1 = pads.iter().find(|p| p.name == "V1").unwrap();+ let v9 = pads.iter().find(|p| p.name == "V9").unwrap();+ // A via inside the thermal pad joins it: naming cannot decide this ("V1" says+ // nothing about which pin it serves) and a bare footprint carries no nets.+ assert_eq!(owning_pad(v1, &base).map(|p| p.name.as_str()), Some("EP"));+ // ...and one outside every pad belongs to nothing, which is warned about rather+ // than silently attached to whatever happens to be nearest.+ assert!(owning_pad(v9, &base).is_none());+ }++ #[test]+ fn a_symbol_that_draws_itself_is_not_relaid_out() {+ use super::{relayout_two_columns, KicadPin, ParsedSymbol, SymGraphic};+ let pin = |number: &str, angle: u32, y: f64| KicadPin {+ name: "~".to_string(), number: number.to_string(),+ x: 0.0, y, angle, length: 2.794, pin_type: "passive".to_string(),+ };+ // A vertical two-terminal passive: pins on top and bottom, which is the same tell+ // an IC laid out like its package gives, plus artwork drawn around those positions.+ let mk = |graphics: Vec<SymGraphic>| ParsedSymbol {+ name: "C".into(), reference: "C".into(), value: "100pF".into(),+ footprint: String::new(), description: String::new(),+ pins: vec![pin("1", 270, 3.81), pin("2", 90, -3.81)],+ body: None, group_labels: vec![], graphics,+ package_name: None, package_description: None, deviceset_name: None,+ ref_pos: None, val_pos: None,+ };+ // with its own drawing (the capacitor's two plates) it must be LEFT ALONE+ let mut drawn = mk(vec![SymGraphic::Poly(vec![(-2.032, 0.762), (2.032, 0.762)], 0.0)]);+ assert!(!relayout_two_columns(&mut drawn, false));+ assert_eq!(drawn.pins[0].y, 3.81, "pins must not move");+ // ...but forcing it is still allowed for a caller who means it+ let mut forced = mk(vec![SymGraphic::Poly(vec![(-2.032, 0.762), (2.032, 0.762)], 0.0)]);+ assert!(relayout_two_columns(&mut forced, true));+ // and a symbol with NO artwork of its own still gets the EE-convention relayout+ let mut plain = mk(vec![]);+ assert!(relayout_two_columns(&mut plain, false));+ }++ #[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 {+ package_name: None,+ package_description: None,+ ref_pos: None,+ val_pos: None,+ deviceset_name: None,+ 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,26 +1,31 @@ # Changelog +## 2.46.3 (2026-09-23)+- Pad clearance measures a round pad against a rectangular one as copper: off a corner it is the circle-to-corner distance, not the gap between bounding boxes. KiCad's stock staggered `TO-92` (square pad 1 at (0,0), round pad 2 at (1.27,-1.27), 1.3 mm) has 0.227 mm of real clearance but overlapping boxes, so every TO-92 part failed `pads 1 and 2 OVERLAP` and dropped out of the component factory. Real overlaps still fail.+- A rectangular thru pad is no longer drawn as a square on its LONG side. KiCad's `TO-92_Inline` pad 1 (1.05 x 1.5 mm at 1.27 mm pitch) became a 1.5 mm square whose copper reached into pad 2, a real short the clearance lint rightly rejected. An elongated rect is now drawn `long` on its short side along its length (with a warning that the square pin-1 corner is lost), a squarish one square on its short side, the same rule roundrect already used. Of KiCad's 11 `TO-92*` footprints, 8 failed the lint on 2.46.2 and none do on 2.46.3.+ ## 2.46.1 (2026-09-20) - Drilled pads in the native PcbLib are written as plated Multi-Layer pads: the pad record carries layer 74 plus the two bytes Altium sets when it plates a pad (114 = 15, 116 = 3, diffed against a library Altium saved after `altium_plate_vias`). Before this the heatsink vias loaded on the Top Layer with a hole. `altium_plate_vias {libraryPath}` (altium-bridge 0.2.20) repairs an older library in place. ⋯ 18 unchanged lines ⋯ - Multi-part libraries: `assemble-altium --no-dedupe` keeps one footprint per part even when land patterns match, so each part carries its own STEP body and its symbol links its own footprint name. Earlier history is in the wiki repo commit log.+@@ -1,1 +1,2 @@-2.46.2+2.46.3+@@ -1,29 +1,30 @@ [package] name = "adom-lbr"-version = "2.46.2"+version = "2.46.3" 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." ⋯ 20 unchanged lines ⋯ opt-level = "z" lto = true strip = true+@@ -1,621 +1,622 @@⋯ 9 unchanged lines ⋯ [[package]] name = "adom-lbr"-version = "2.46.2"+version = "2.46.3" dependencies = [ "altium-codec", "anyhow",⋯ 602 unchanged lines ⋯ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"+@@ -1,86 +1,87 @@ { "slug": "adom-lbr",- "version": "2.46.2",+ "version": "2.46.3", "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": {},⋯ 77 unchanged lines ⋯ } ] }+
Comments
Log in to comment.
No comments yet.