← Commit history

1.1.9: pinLayout square + square-grouped (true four-side layouts), outline contract for raster --iso, chipNameCentered render fix (KiCad rejects justify center), ground matcher PGND/OGND/EP

John Lauer ·f70f151511 ·1mo ago ·parent a946d02
9 files changed +179−32
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-symbol"-version = "1.1.0"+version = "1.1.9" dependencies = [  "anyhow",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-symbol"-version = "1.1.7"+version = "1.1.9" edition = "2021" description = "Create and preview KiCad/Fusion 360 schematic symbols with interactive viewer" 
README.md+1−1
@@ -46,7 +46,7 @@ it (per-symbol; your last-used choices fill in untouched chips).  **Shape it in the Layout studio.** Pick the **source** (auto-grouped from ds2sf, or any manufacturer / Altium / Fusion / KiCad library variant), the pin-**layout** (left-right or all-sides) and grouping, the **3D-chip** overlay style+**layout** (left-right, square, square-grouped) and grouping, the **3D-chip** overlay style (off / shaded iso / thin / bold / blue / teal / dark) and size, where the chip name shows (lasered on the chip, in the symbol, both, or hidden), and then **Send to** a desktop EDA tool.
SKILL.md+12−2
@@ -40,7 +40,7 @@ try { | Preference | Values | Effect | |-----------|--------|--------| | `edaTool` | `kicad`, `fusion360`, `altium`, `cadence`, `tscircuit`, `easyeda`, `other` | Controls primary output format and delivery target |-| `pinLayout` | `left-right`, `all-sides` | `left-right`: pins on left and right only (default Adom style). `all-sides`: pins on left, top, right, and bottom |+| `pinLayout` | `left-right`, `square`, `square-grouped`, `all-sides` | `left-right`: pins on left and right only (default Adom style, 2-column). `square`: package order walked like a QFP (pin 1 down the left, along the bottom, up the right, back across the top, ceil(n/4) per side; groups dropped). `square-grouped`: whole groups per side, Power on top, grounds split out onto the bottom as "Ground", the rest largest-first onto the emptiest side. `all-sides`: legacy name, behaves as `square-grouped` unless pins carry an explicit `side` | | `chipNameCentered` | `true`, `false` | If true, add a centered text element with the chip name in the middle of the symbol body (vertically centered) so it's always visible even when zoomed out | | `partNumber` | `none`, `mouser`, `digikey`, `jlcpcb` | If not "none", add a property field for the specified distributor's part number. Research and fill it in during Step 1 | | `deliveryTarget` | `webview`, `desktop`, `both` | `webview`: preview in Hydrogen webview first, only send to desktop when asked. `desktop`: send directly to desktop EDA after lint/validate pass. `both`: preview in Hydrogen webview AND send to desktop in parallel |@@ -290,7 +290,17 @@ Place pins ONLY on the left and right sides: - **Avoid bottom pins** — bottom pins add rotated text that clutters the symbol - **Avoid top pins** — rotated vertical text from top pins overlaps with side pin names at corners; prefer placing power pins on the right side instead -#### `pinLayout: "all-sides"`+#### `pinLayout: "square"` and `"square-grouped"` (true four-side layouts, v1.1.9+)++These are the two square variants a component page ships (John, 2026-08-29: standard, 2-column grouped, 4-side square, 4-side square grouped, each with/without the chip outline). No caller-side `side` assignment is needed:++- `square`: **package order walked like a QFP**. Pin 1 at the top of the left side going down, then along the bottom left to right, up the right side, back across the top right to left, ceil(n/4) pins per side. Groups are dropped so the walk is never broken by labels.+- `square-grouped`: **whole groups per side**. `Power` on top; ground pins (GND/VSS/AGND/DGND/PGND/EP) are split out of Power onto the bottom as a `Ground` group; any group that is all-ground goes to the bottom, all-power to the top; the remaining groups are placed largest-first onto the side with the fewest pins.+- Explicit `side` on any pin disables the inference for the whole symbol (explicit sides always win, so ds2sf and studio relayouts are untouched).++#### `pinLayout: "all-sides"` (legacy name)++With no explicit sides this is now the same as `square-grouped`. With explicit sides it is the four-side placement engine described below.  Place pins on all four sides for a more compact symbol: 
VERSION+1−1
@@ -1 +1 @@-1.1.7+1.1.9
bin/adom-symbol
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-symbol",-  "version": "1.1.8",+  "version": "1.1.9",   "type": "app",   "description": "KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360",   "dependencies": {
src/main.rs+39−20
@@ -62,10 +62,13 @@ enum Cmd {         file: PathBuf,         #[arg(long)]         out: PathBuf,-        /// Optional iso 3D chip PNG to composite into the symbol body centre —-        /// this is the "default symbol with the 3D chip in it" the dashboard-        /// card shows. Usually `<dir>/<mpn>-3d-iso-icon.png` (transparent) or-        /// `<mpn>-3d-iso.png`.+        /// Optional 3D chip to composite into the symbol body centre — this is+        /// the "default symbol with the 3D chip in it" the dashboard card shows.+        /// A vector outline (`<mpn>-3d-outline-*-named.svg`) is preferred: it is+        /// also transcribed into the OUTLINE CONTRACT (`<out-stem>-outline.mm.svg`+        /// + `.json`, written beside --out) that `adom-lbr --symbol-art` consumes.+        /// A raster (`<mpn>-3d-iso-icon.png`) is composited as-is and the contract+        /// comes from the sibling vector outline when one exists.         #[arg(long)]         iso: Option<PathBuf>,         /// 3D-chip size in the body: large (default, the historical size) | medium | small.@@ -82,7 +85,7 @@ enum Cmd {         /// Output SVG path         #[arg(long)]         out: PathBuf,-        /// Pin layout: left-right | all-sides+        /// Pin layout: left-right | square | square-grouped | all-sides         #[arg(long, default_value = "left-right")]         layout: String,         /// Also write the generated .kicad_sym here@@ -231,23 +234,39 @@ fn run() -> Result<()> {                 // (and other consumers) can transcribe the exact placement we drew                 // — a lite drop-in overlay .mm.svg + a self-describing .json. Only                 // for vector outlines (a raster --iso has no strokes to extract).-                if p.extension().and_then(|e| e.to_str()) == Some("svg") {-                    let stem = out.file_stem().and_then(|s| s.to_str()).unwrap_or(&name).to_string();-                    let dir = out.parent().map(|d| d.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));-                    let mm_name = format!("{stem}-outline.mm.svg");-                    let json_name = format!("{stem}-outline.json");-                    match fs::read_to_string(p).map_err(anyhow::Error::from).and_then(|ov| {-                        render::build_outline_contract(&svg, &content, &ov, p, &mm_name, frac)-                    }) {-                        Ok((mm_svg, json, segs)) => {-                            fs::write(dir.join(&mm_name), &mm_svg)-                                .with_context(|| format!("writing {mm_name}"))?;-                            fs::write(dir.join(&json_name), &json)-                                .with_context(|| format!("writing {json_name}"))?;-                            outline_note = format!(" (+outline contract: {mm_name} [{segs} segs] + {json_name})");+                // A raster --iso (PNG) has no strokes: fall back to the sibling+                // vector outline (`<mpn>-3d-outline-*-named.svg`) next to the+                // symbol or the --iso file, so the contract is STILL written.+                let is_svg = |q: &std::path::Path| q.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("svg")).unwrap_or(false);+                let contract_src: Option<PathBuf> = if is_svg(p) {+                    Some(p.clone())+                } else {+                    default_white_outline(&file).or_else(|| default_white_outline(p))+                };+                let stem = out.file_stem().and_then(|s| s.to_str()).unwrap_or(&name).to_string();+                let dir = out.parent().map(|d| d.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));+                let mm_name = format!("{stem}-outline.mm.svg");+                let json_name = format!("{stem}-outline.json");+                match contract_src {+                    Some(src) => {+                        match fs::read_to_string(&src).map_err(anyhow::Error::from).and_then(|ov| {+                            render::build_outline_contract(&svg, &content, &ov, &src, &mm_name, frac)+                        }) {+                            Ok((mm_svg, json, segs)) => {+                                fs::write(dir.join(&mm_name), &mm_svg)+                                    .with_context(|| format!("writing {mm_name}"))?;+                                fs::write(dir.join(&json_name), &json)+                                    .with_context(|| format!("writing {json_name}"))?;+                                outline_note = format!(+                                    "\n    outline contract → {} [{segs} segs] + {} (from {}) — pass the .mm.svg to `adom-lbr generate --symbol-art`",+                                    dir.join(&mm_name).display(), dir.join(&json_name).display(), src.display());+                            }+                            Err(e) => eprintln!("WARN: outline contract skipped: {e}"),                         }-                        Err(e) => eprintln!("WARN: outline contract skipped: {e}"),                     }+                    None => eprintln!(+                        "WARN: outline contract NOT written: --iso {} is a raster and no vector outline (<mpn>-3d-outline-{{heavy,dark,hairline,teal}}-named.svg) was found beside {} — run adom-chip-thumbnailer to make one, or pass the outline SVG as --iso",+                        p.display(), file.display()),                 }             }             fs::write(&out, &svg).with_context(|| format!("writing {}", out.display()))?;
src/sym_gen.rs+123−5
@@ -41,7 +41,7 @@ pub struct IcInput {     pub datasheet_url: String,     pub reference_prefix: String,     pub pins: Vec<Pin>,-    pub pin_layout: String, // "left-right" | "all-sides"+    pub pin_layout: String, // "left-right" | "square" | "square-grouped" | "all-sides" (= square-grouped unless pins carry explicit sides)     pub chip_name_centered: bool,     /// Reference designator placement (see `designator_at` for the position set).     pub ref_pos: String,@@ -86,7 +86,8 @@ fn fx(v: f64) -> String {  fn is_ground_name(name: &str) -> bool {     let n = name.to_uppercase();-    n.starts_with("GND") || n.starts_with("VSS") || n.starts_with("AGND") || n.starts_with("DGND") || n.starts_with("EP")+    n.starts_with("GND") || n.starts_with("VSS") || n.starts_with("AGND") || n.starts_with("DGND")+        || n.starts_with("PGND") || n.starts_with("OGND") || n == "EP" || n.starts_with("EP_") || n == "PAD" || n == "THERMAL" }  fn layout_vertical(side_groups: &[SideGroup]) -> (Vec<PosPin>, Vec<Label>, f64) {@@ -164,12 +165,121 @@ fn designator_at(pos: &str, bl: f64, br: f64, bt: f64, bb: f64, clr: &Clear) ->     } } ++/// True four-side ("square") layouts. Only used when NO pin carries an explicit+/// `side` (explicit sides always win, so ds2sf/studio relayouts are untouched).+///+/// * `square`: **package order walked like a QFP** — pin 1 at the top of the+///   left side going down, along the bottom left→right, up the right side, back+///   across the top right→left, ceil(n/4) pins per side. Groups are dropped so+///   the walk is never broken by labels.+/// * `square-grouped`: **whole groups per side** — Power on top, ground pins+///   (GND/VSS/AGND/PGND/EP…) split out of Power onto the bottom as "Ground",+///   the remaining groups placed largest-first onto the side with the fewest+///   pins. Falls back to `square` when no pin has a group.+/// * `all-sides` (legacy name) with no explicit sides → `square-grouped`.+pub fn infer_square_sides(pins: &[Pin], grouped: bool) -> Vec<Pin> {+    let grouped = grouped && pins.iter().any(|p| p.group.is_some());+    if !grouped {+        let n = pins.len();+        let per = (n + 3) / 4;+        let mut left = Vec::new();+        let mut bottom = Vec::new();+        let mut right = Vec::new();+        let mut top = Vec::new();+        for (i, p) in pins.iter().enumerate() {+            let mut q = p.clone();+            q.group = None;+            match (i / per.max(1)).min(3) {+                0 => { q.side = Some("left".into()); left.push(q); }+                1 => { q.side = Some("bottom".into()); bottom.push(q); }+                2 => { q.side = Some("right".into()); right.push(q); }+                _ => { q.side = Some("top".into()); top.push(q); }+            }+        }+        // The layout engine lays right pins top→down and top pins left→right;+        // the QFP walk goes UP the right side and right→left across the top.+        right.reverse();+        top.reverse();+        let mut out = left;+        out.extend(bottom);+        out.extend(right);+        out.extend(top);+        return out;+    }++    // Groups in first-seen order.+    let mut order: Vec<String> = Vec::new();+    let mut by_group: Vec<(String, Vec<Pin>)> = Vec::new();+    for p in pins {+        let g = p.group.clone().unwrap_or_else(|| "_ungrouped".to_string());+        if let Some(e) = by_group.iter_mut().find(|(n, _)| *n == g) {+            e.1.push(p.clone());+        } else {+            order.push(g.clone());+            by_group.push((g, vec![p.clone()]));+        }+    }+    // Split grounds out of Power (only when Power also has non-ground pins).+    let power_key = order.iter().find(|g| g.eq_ignore_ascii_case("power")).cloned();+    if let Some(pk) = power_key.clone() {+        let idx = by_group.iter().position(|(n, _)| *n == pk).unwrap();+        let (gnd, pwr): (Vec<Pin>, Vec<Pin>) = by_group[idx].1.iter().cloned().partition(|p| is_ground_name(&p.name));+        if !gnd.is_empty() && !pwr.is_empty() && !order.iter().any(|g| g.eq_ignore_ascii_case("ground")) {+            by_group[idx].1 = pwr;+            let gnd: Vec<Pin> = gnd.into_iter().map(|mut p| { p.group = Some("Ground".into()); p }).collect();+            by_group.push(("Ground".into(), gnd));+            order.push("Ground".into());+        }+    }+    let sides = ["left", "bottom", "right", "top"];+    let mut load = [0usize; 4];+    let mut side_of: Vec<Option<&str>> = vec![None; by_group.len()];+    for (i, (g, ps)) in by_group.iter().enumerate() {+        let all_ground = ps.iter().all(|p| is_ground_name(&p.name));+        let all_power = ps.iter().all(|p| p.ptype == "power_in" || p.ptype == "power_out");+        if g.eq_ignore_ascii_case("ground") || (all_ground && g != "_ungrouped") {+            side_of[i] = Some("bottom"); load[1] += ps.len();+        } else if g.eq_ignore_ascii_case("power") || (all_power && !all_ground && g != "_ungrouped") {+            side_of[i] = Some("top"); load[3] += ps.len();+        }+    }+    let mut rest: Vec<usize> = (0..by_group.len()).filter(|&i| side_of[i].is_none()).collect();+    rest.sort_by(|&a, &b| by_group[b].1.len().cmp(&by_group[a].1.len()));+    for i in rest {+        let k = (0..4).min_by_key(|&k| load[k]).unwrap();+        side_of[i] = Some(sides[k]);+        load[k] += by_group[i].1.len();+    }+    let mut out = Vec::new();+    for side in sides {+        for (i, (_, ps)) in by_group.iter().enumerate() {+            if side_of[i] == Some(side) {+                for p in ps {+                    let mut q = p.clone();+                    q.side = Some(side.to_string());+                    out.push(q);+                }+            }+        }+    }+    out+}+ pub fn generate_ic_sym(input: &IcInput) -> String {     let pin_layout = if input.pin_layout.is_empty() { "left-right" } else { input.pin_layout.as_str() };+    let has_explicit_side = input.pins.iter().any(|p| p.side.is_some());+    let square_pins: Option<Vec<Pin>> = match pin_layout {+        "square" if !has_explicit_side => Some(infer_square_sides(&input.pins, false)),+        "square-grouped" | "all-sides" if !has_explicit_side => Some(infer_square_sides(&input.pins, true)),+        _ => None,+    };+    let input_pins: &[Pin] = square_pins.as_deref().unwrap_or(&input.pins);+    let pin_layout = if square_pins.is_some() { "all-sides" } else { pin_layout };      // Group pins by group name, preserving first-seen order.     let mut groups: Vec<(String, Vec<Pin>)> = Vec::new();-    for pin in &input.pins {+    for pin in input_pins {         let g = pin.group.clone().unwrap_or_else(|| "_ungrouped".to_string());         if let Some(entry) = groups.iter_mut().find(|(n, _)| *n == g) {             entry.1.push(pin.clone());@@ -240,7 +350,13 @@ pub fn generate_ic_sym(input: &IcInput) -> String {     // Body width from longest pin names (left-right) OR top/bottom pin count.     let longest_left = left_pins.iter().map(|p| p.name.chars().count()).max().unwrap_or(0) as f64;     let longest_right = right_pins.iter().map(|p| p.name.chars().count()).max().unwrap_or(0) as f64;-    let name_width = (longest_left + longest_right) * 1.0 + BODY_PADDING * 2.0;+    let mut name_width = (longest_left + longest_right) * 1.0 + BODY_PADDING * 2.0;+    if input.chip_name_centered {+        // The centred chip name sits between the left and right pin names:+        // widen the body so it can't collide with either column.+        let name_len = input.symbol_name.chars().count() as f64;+        name_width = name_width.max((longest_left + longest_right + name_len) * 1.0 + BODY_PADDING * 2.0 + 2.0);+    }      let top_pin_count: usize = top.iter().map(|g| g.pins.len()).sum();     let bottom_pin_count: usize = bottom.iter().map(|g| g.pins.len()).sum();@@ -393,7 +509,9 @@ pub fn generate_ic_sym(input: &IcInput) -> String {     if input.chip_name_centered {         let center_y = fx((body_top + body_bottom) / 2.0);         lines.push(format!("      (text \"{}\" (at 0 {} 0)", input.symbol_name, center_y));-        lines.push("        (effects (font (size 1.27 1.27) (color 100 100 100 1)) (justify center))".to_string());+        // No `(justify center)`: centre is KiCad's default and "center" is not a+        // valid justify token (kicad-cli rejects the file → service-kicad 400).+        lines.push("        (effects (font (size 1.27 1.27) (color 100 100 100 1)))".to_string());         lines.push("      )".to_string());     }     lines.push("    )".to_string());