app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
← 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
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());