app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
//! `.kicad_sym` generator — faithful Rust port of the Node `lib/sym-gen.js`
//! `generateICSym`. Category A (IC): rectangle body, pins on sides, group
//! labels. Output is byte-for-byte identical to the Node generator (verified
//! by the golden-file parity test in `tests/`).
//!
//! No gallia, no Node. (Category B / discrete baseline fetch — `symGet` — is
//! ported separately and routes through service-kicad.)
// ── Pin angle constants ──
const ANGLE_LEFT_SIDE: i32 = 0; // pin extends right from tip → left side of body
const ANGLE_RIGHT_SIDE: i32 = 180;
const ANGLE_BOTTOM_SIDE: i32 = 90;
const ANGLE_TOP_SIDE: i32 = 270;
const PIN_LENGTH: f64 = 2.54;
const PIN_PITCH: f64 = 2.54; // within a group
const GROUP_GAP: f64 = 3.81; // between groups
const LABEL_OFFSET: f64 = 2.0; // group label above first pin in group
const BODY_TOP_MARGIN: f64 = 2.54;
const BODY_BOTTOM_MARGIN: f64 = 2.54;
const MIN_BODY_WIDTH: f64 = 20.0;
const BODY_PADDING: f64 = 4.0;
const FS_PIN_NAME: f64 = 1.27;
const FS_GROUP_LABEL: f64 = 0.762;
#[derive(Clone)]
pub struct Pin {
pub name: String,
pub number: String,
pub ptype: String, // input | output | power_in | bidirectional | tri_state | …
pub group: Option<String>,
pub side: Option<String>, // explicit override: left|right|top|bottom
}
pub struct IcInput {
pub symbol_name: String,
pub manufacturer: String,
pub package: String,
pub description: String,
pub datasheet_url: String,
pub reference_prefix: String,
pub pins: Vec<Pin>,
pub pin_layout: String, // "left-right" | "all-sides"
pub chip_name_centered: bool,
/// Reference designator placement (see `designator_at` for the position set).
pub ref_pos: String,
/// Value (chip name) placement (same position set as ref_pos).
pub value_pos: String,
/// distributor part numbers, insertion-ordered: (distributor, pn)
pub distributor_pn: Vec<(String, String)>,
}
#[derive(Clone)]
struct SideGroup {
group_name: String,
pins: Vec<Pin>,
}
#[derive(Clone)]
struct PosPin {
name: String,
number: String,
ptype: String,
x: f64,
y: f64,
}
#[derive(Clone)]
struct Label {
name: String,
x: f64,
y: f64,
}
/// JS Number.toFixed(2) — round-half-away-from-zero, 2 decimals, "-0.00" → "0.00".
fn fx(v: f64) -> String {
// JS toFixed rounds half away from zero; Rust's {:.2} rounds half-to-even.
// Re-round explicitly to match JS for the rare exact-half case.
let scaled = v * 100.0;
let rounded = if scaled >= 0.0 { (scaled + 0.5).floor() } else { (scaled - 0.5).ceil() };
let r = rounded / 100.0;
let s = format!("{:.2}", r);
if s == "-0.00" { "0.00".to_string() } else { s }
}
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")
}
fn layout_vertical(side_groups: &[SideGroup]) -> (Vec<PosPin>, Vec<Label>, f64) {
let mut pins = Vec::new();
let mut labels = Vec::new();
let mut y = 0.0_f64;
for (i, sg) in side_groups.iter().enumerate() {
if i > 0 {
y -= GROUP_GAP;
}
let label_y = y;
y -= LABEL_OFFSET;
for pin in &sg.pins {
pins.push(PosPin { name: pin.name.clone(), number: pin.number.clone(), ptype: pin.ptype.clone(), x: 0.0, y });
y -= PIN_PITCH;
}
if sg.group_name != "_ungrouped" {
labels.push(Label { name: sg.group_name.clone(), x: 0.0, y: label_y });
}
}
(pins, labels, y.abs())
}
fn layout_horizontal(side_groups: &[SideGroup], start_x: f64) -> (Vec<PosPin>, Vec<Label>, f64) {
let mut pins = Vec::new();
let mut labels = Vec::new();
let mut x = start_x;
for (i, sg) in side_groups.iter().enumerate() {
if i > 0 {
x += GROUP_GAP;
}
let label_x = x;
if sg.group_name != "_ungrouped" {
x += LABEL_OFFSET;
}
for pin in &sg.pins {
pins.push(PosPin { name: pin.name.clone(), number: pin.number.clone(), ptype: pin.ptype.clone(), x, y: 0.0 });
x += PIN_PITCH;
}
if sg.group_name != "_ungrouped" {
labels.push(Label { name: sg.group_name.clone(), x: label_x, y: 0.0 });
}
}
(pins, labels, x - start_x)
}
/// Where the symbol's content actually extends past each body edge, so "out"
/// designators clear the pins/labels on that side (auto-overlap-avoidance).
struct Clear {
top: f64, // y for out-top (above any top pins)
bot: f64, // y for out-bot (below any bottom pins)
left: f64, // x for out-left (left of left pin numbers)
right: f64, // x for out-right (right of right pin numbers)
}
/// Position a designator (Reference or Value) relative to the body box, using
/// `clr` for the "out" positions so they don't collide with pins.
/// Positions: out-top / in-top / out-bot / in-bot / out-left / in-left /
/// out-right / in-right / center. Returns (x, y, justify-suffix).
fn designator_at(pos: &str, bl: f64, br: f64, bt: f64, bb: f64, clr: &Clear) -> (f64, f64, &'static str) {
let cy = (bt + bb) / 2.0;
match pos {
"center" => (0.0, cy, ""),
"in-top" => (0.0, bt - 2.2, ""),
"out-bot" | "out-bottom" => (0.0, clr.bot, ""),
"in-bot" | "in-bottom" => (0.0, bb + 2.2, ""),
"out-left" => (clr.left, cy, " (justify right)"),
"in-left" => (bl + 2.0, cy, " (justify left)"),
"out-right" => (clr.right, cy, " (justify left)"),
"in-right" => (br - 2.0, cy, " (justify right)"),
// legacy corner positions
"top-left" => (bl + 2.0, bt - 1.8, " (justify left)"),
"bottom-left" => (bl + 2.0, bb + 1.8, " (justify left)"),
_ => (0.0, clr.top, ""), // out-top default
}
}
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() };
// Group pins by group name, preserving first-seen order.
let mut groups: Vec<(String, Vec<Pin>)> = Vec::new();
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());
} else {
groups.push((g, vec![pin.clone()]));
}
}
let mut left: Vec<SideGroup> = Vec::new();
let mut right: Vec<SideGroup> = Vec::new();
let mut top: Vec<SideGroup> = Vec::new();
let mut bottom: Vec<SideGroup> = Vec::new();
for (group_name, group_pins) in &groups {
// Partition the group's pins by their EXPLICIT side first, so a group
// (incl. the flat "_ungrouped" group) can span sides — e.g. flat layout
// splitting pins half-left / half-right. Pins with no explicit side fall
// through to the type-based auto-placement below.
for side in ["left", "right", "top", "bottom"] {
let pins: Vec<Pin> = group_pins.iter().filter(|p| p.side.as_deref() == Some(side)).cloned().collect();
if pins.is_empty() {
continue;
}
let sg = SideGroup { group_name: group_name.clone(), pins };
match side {
"left" => left.push(sg),
"right" => right.push(sg),
"top" => top.push(sg),
"bottom" => bottom.push(sg),
_ => {}
}
}
let group_pins: Vec<Pin> = group_pins.iter().filter(|p| p.side.is_none()).cloned().collect();
if !group_pins.is_empty() {
let sg = SideGroup { group_name: group_name.clone(), pins: group_pins.clone() };
let has_inputs = group_pins.iter().any(|p| p.ptype == "input" || p.ptype == "bidirectional");
let has_power = group_pins.iter().any(|p| p.ptype == "power_in" || p.ptype == "power_out");
let has_outputs = group_pins.iter().any(|p| p.ptype == "output" || p.ptype == "tri_state");
let is_ground = group_pins.iter().all(|p| p.ptype == "power_in" && is_ground_name(&p.name));
if pin_layout == "all-sides" {
if is_ground {
bottom.push(sg);
} else if has_power && !has_inputs && !has_outputs {
top.push(sg);
} else if has_outputs && !has_inputs {
right.push(sg);
} else {
left.push(sg);
}
} else {
if is_ground {
left.push(sg);
} else if has_outputs && !has_inputs {
right.push(sg);
} else if has_power && !has_inputs {
right.push(sg);
} else {
left.push(sg);
}
}
}
}
let (left_pins, left_labels, _lh) = layout_vertical(&left);
let (right_pins, right_labels, _rh) = layout_vertical(&right);
// 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 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();
let top_group_gaps = (top.len().saturating_sub(1)) as f64 * GROUP_GAP;
let bottom_group_gaps = (bottom.len().saturating_sub(1)) as f64 * GROUP_GAP;
let top_label_offsets = top.iter().filter(|g| g.group_name != "_ungrouped").count() as f64 * LABEL_OFFSET;
let bottom_label_offsets = bottom.iter().filter(|g| g.group_name != "_ungrouped").count() as f64 * LABEL_OFFSET;
let top_width = top_pin_count as f64 * PIN_PITCH + top_group_gaps + top_label_offsets + BODY_PADDING * 2.0;
let bottom_width = bottom_pin_count as f64 * PIN_PITCH + bottom_group_gaps + bottom_label_offsets + BODY_PADDING * 2.0;
let body_width = name_width.max(MIN_BODY_WIDTH).max(top_width).max(bottom_width);
let half_width = body_width / 2.0;
let body_left = -(half_width / 2.54).ceil() * 2.54;
let body_right = (half_width / 2.54).ceil() * 2.54;
let (_td_pins, _td_labels, top_total) = layout_horizontal(&top, 0.0);
let (_bd_pins, _bd_labels, bottom_total) = layout_horizontal(&bottom, 0.0);
let top_start_x = -top_total / 2.0;
let bottom_start_x = -bottom_total / 2.0;
let (top_pins, top_labels, _tw) = layout_horizontal(&top, top_start_x);
let (bottom_pins, bottom_labels, _bw) = layout_horizontal(&bottom, bottom_start_x);
let mut all_label_ys: Vec<f64> = Vec::new();
all_label_ys.extend(left_labels.iter().map(|l| l.y));
all_label_ys.extend(right_labels.iter().map(|l| l.y));
let top_label_y = all_label_ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let top_label_y = if all_label_ys.is_empty() { 0.0 } else { top_label_y };
let mut top_pin_clearance = 0.0_f64;
let mut bottom_pin_clearance = 0.0_f64;
if !top_pins.is_empty() {
let longest_top_name = top_pins.iter().map(|p| p.name.chars().count()).max().unwrap_or(0) as f64;
let longest_top_label = top_labels.iter().map(|l| l.name.chars().count()).max().unwrap_or(0) as f64;
top_pin_clearance = (longest_top_name * FS_PIN_NAME * 0.7).max(longest_top_label * FS_GROUP_LABEL * 0.7) + 2.0;
}
if !bottom_pins.is_empty() {
let longest_bottom_name = bottom_pins.iter().map(|p| p.name.chars().count()).max().unwrap_or(0) as f64;
let longest_bottom_label = bottom_labels.iter().map(|l| l.name.chars().count()).max().unwrap_or(0) as f64;
bottom_pin_clearance = (longest_bottom_name * FS_PIN_NAME * 0.7).max(longest_bottom_label * FS_GROUP_LABEL * 0.7) + 2.5;
}
let body_top = top_label_y + BODY_TOP_MARGIN + top_pin_clearance;
let mut all_pin_ys: Vec<f64> = Vec::new();
all_pin_ys.extend(left_pins.iter().map(|p| p.y));
all_pin_ys.extend(right_pins.iter().map(|p| p.y));
let lowest_pin_y = if all_pin_ys.is_empty() { -10.0 } else { all_pin_ys.iter().cloned().fold(f64::INFINITY, f64::min) };
let body_bottom = lowest_pin_y - BODY_BOTTOM_MARGIN - bottom_pin_clearance;
let mut lines: Vec<String> = Vec::new();
lines.push("(kicad_symbol_lib".to_string());
lines.push(" (version 20231120)".to_string());
lines.push(" (generator \"adom\")".to_string());
lines.push(format!(" (symbol \"{}\"", input.symbol_name));
lines.push(" (pin_names (offset 1.016))".to_string());
lines.push(" (exclude_from_sim no)".to_string());
lines.push(" (in_bom yes)".to_string());
lines.push(" (on_board yes)".to_string());
// Auto-avoidance: push "out" designators beyond whatever's actually on each
// side (top/bottom pins in square layouts; left/right pin numbers).
let longest_left_num = left_pins.iter().map(|p| p.number.chars().count()).max().unwrap_or(0) as f64;
let longest_right_num = right_pins.iter().map(|p| p.number.chars().count()).max().unwrap_or(0) as f64;
let clr = Clear {
top: if top_pins.is_empty() { body_top + 2.54 } else { body_top + PIN_LENGTH + 6.0 },
bot: if bottom_pins.is_empty() { body_bottom - 2.54 } else { body_bottom - PIN_LENGTH - 6.0 },
left: body_left - PIN_LENGTH - longest_left_num * 0.8 - 2.5,
right: body_right + PIN_LENGTH + longest_right_num * 0.8 + 2.5,
};
lines.push(format!(" (property \"Reference\" \"{}\"", input.reference_prefix));
{
let pos = if input.ref_pos.is_empty() { "out-top" } else { input.ref_pos.as_str() };
let (rx, ry, just) = designator_at(pos, body_left, body_right, body_top, body_bottom, &clr);
lines.push(format!(" (at {} {} 0)", fx(rx), fx(ry)));
lines.push(format!(" (effects (font (size 1.27 1.27)){just})"));
}
lines.push(" )".to_string());
lines.push(format!(" (property \"Value\" \"{}\"", input.symbol_name));
{
// "none" hides the Value (e.g. the chip name is laser-marked on the 3D
// chip instead). KiCad requires the property to exist, so emit it hidden.
if input.value_pos == "none" {
let (vx, vy, _) = designator_at("out-bot", body_left, body_right, body_top, body_bottom, &clr);
lines.push(format!(" (at {} {} 0)", fx(vx), fx(vy)));
lines.push(" (effects (font (size 1.27 1.27)) hide)".to_string());
} else {
let pos = if input.value_pos.is_empty() { "out-bot" } else { input.value_pos.as_str() };
let (vx, vy, just) = designator_at(pos, body_left, body_right, body_top, body_bottom, &clr);
lines.push(format!(" (at {} {} 0)", fx(vx), fx(vy)));
lines.push(format!(" (effects (font (size 1.27 1.27)){just})"));
}
}
lines.push(" )".to_string());
for (prop, val) in [
("Footprint", input.package.as_str()),
("Datasheet", input.datasheet_url.as_str()),
("Description", input.description.as_str()),
("Manufacturer", input.manufacturer.as_str()),
] {
lines.push(format!(" (property \"{prop}\" \"{val}\""));
lines.push(" (at 0 0 0)".to_string());
lines.push(" (effects (font (size 1.27 1.27)) (hide yes))".to_string());
lines.push(" )".to_string());
}
for (dist, pn) in &input.distributor_pn {
let mut label = String::new();
let mut chars = dist.chars();
if let Some(c) = chars.next() {
label.extend(c.to_uppercase());
label.push_str(chars.as_str());
}
lines.push(format!(" (property \"{label}\" \"{pn}\""));
lines.push(" (at 0 0 0)".to_string());
lines.push(" (effects (font (size 1.27 1.27)) (hide yes))".to_string());
lines.push(" )".to_string());
}
// Graphics sub-symbol (_0_1)
lines.push(format!(" (symbol \"{}_0_1\"", input.symbol_name));
lines.push(format!(" (rectangle (start {} {}) (end {} {})", fx(body_left), fx(body_top), fx(body_right), fx(body_bottom)));
lines.push(" (stroke (width 0.254) (type default))".to_string());
lines.push(" (fill (type background))".to_string());
lines.push(" )".to_string());
for label in &left_labels {
lines.push(format!(" (text \"{}\" (at {} {} 0)", label.name, fx(body_left + 1.02), fx(label.y)));
lines.push(" (effects (font (size 0.762 0.762) (color 132 0 0 1)) (justify left))".to_string());
lines.push(" )".to_string());
}
for label in &right_labels {
lines.push(format!(" (text \"{}\" (at {} {} 0)", label.name, fx(body_right - 1.02), fx(label.y)));
lines.push(" (effects (font (size 0.762 0.762) (color 132 0 0 1)) (justify right))".to_string());
lines.push(" )".to_string());
}
for label in &top_labels {
lines.push(format!(" (text \"{}\" (at {} {} 900)", label.name, fx(label.x), fx(body_top - 1.02)));
lines.push(" (effects (font (size 0.762 0.762) (color 132 0 0 1)) (justify right))".to_string());
lines.push(" )".to_string());
}
for label in &bottom_labels {
lines.push(format!(" (text \"{}\" (at {} {} 900)", label.name, fx(label.x), fx(body_bottom + 1.02)));
lines.push(" (effects (font (size 0.762 0.762) (color 132 0 0 1)) (justify left))".to_string());
lines.push(" )".to_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());
lines.push(" )".to_string());
}
lines.push(" )".to_string());
// Pins sub-symbol (_1_1)
lines.push(format!(" (symbol \"{}_1_1\"", input.symbol_name));
for pin in &left_pins {
let tip_x = body_left - PIN_LENGTH;
lines.push(format!(" (pin {} line (at {} {} {}) (length 2.54) (name \"{}\") (number \"{}\"))", pin.ptype, fx(tip_x), fx(pin.y), ANGLE_LEFT_SIDE, pin.name, pin.number));
}
for pin in &right_pins {
let tip_x = body_right + PIN_LENGTH;
lines.push(format!(" (pin {} line (at {} {} {}) (length 2.54) (name \"{}\") (number \"{}\"))", pin.ptype, fx(tip_x), fx(pin.y), ANGLE_RIGHT_SIDE, pin.name, pin.number));
}
if !top_pins.is_empty() {
let top_pin_entry = body_top - 0.1;
for pin in &top_pins {
let tip_y = top_pin_entry + PIN_LENGTH;
lines.push(format!(" (pin {} line (at {} {} {}) (length 2.54) (name \"{}\") (number \"{}\"))", pin.ptype, fx(pin.x), fx(tip_y), ANGLE_TOP_SIDE, pin.name, pin.number));
}
}
if !bottom_pins.is_empty() {
let bottom_pin_entry = body_bottom + 0.1;
for pin in &bottom_pins {
let tip_y = bottom_pin_entry - PIN_LENGTH;
lines.push(format!(" (pin {} line (at {} {} {}) (length 2.54) (name \"{}\") (number \"{}\"))", pin.ptype, fx(pin.x), fx(tip_y), ANGLE_BOTTOM_SIDE, pin.name, pin.number));
}
}
lines.push(" )".to_string());
lines.push(" )".to_string());
lines.push(")".to_string());
lines.push(String::new());
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
fn pin(name: &str, number: &str, ptype: &str, group: &str) -> Pin {
Pin { name: name.into(), number: number.into(), ptype: ptype.into(), group: Some(group.into()), side: None }
}
#[test]
fn tst_ic8_matches_golden() {
let input = IcInput {
symbol_name: "TST-IC8".into(),
manufacturer: "Adom Test".into(),
package: "SOIC-8".into(),
description: "Parity-harness test IC".into(),
datasheet_url: "https://example.com/ds.pdf".into(),
reference_prefix: "U".into(),
pins: vec![
pin("VCC", "8", "power_in", "Power"),
pin("GND", "4", "power_in", "Power"),
pin("IN+", "1", "input", "Inputs"),
pin("IN-", "2", "input", "Inputs"),
pin("OUT", "6", "output", "Outputs"),
pin("EN", "3", "input", "Ctrl"),
],
pin_layout: "left-right".into(),
chip_name_centered: false,
ref_pos: "top".into(),
value_pos: "out-bot".into(),
distributor_pn: vec![],
};
bless_eq(generate_ic_sym(&input), "tests/golden/TST-IC8.kicad_sym");
}
/// Regression check: compare to the golden file, or rewrite it when BLESS=1.
fn bless_eq(got: String, path: &str) {
if std::env::var("BLESS").is_ok() {
std::fs::write(path, &got).unwrap();
return;
}
let golden = std::fs::read_to_string(path).unwrap_or_default();
assert_eq!(got, golden, "generator output drifted from {path} (run BLESS=1 cargo test to update)");
}
#[test]
fn tst_all_matches_golden() {
let p = |name:&str,number:&str,ptype:&str,group:&str| Pin{name:name.into(),number:number.into(),ptype:ptype.into(),group:Some(group.into()),side:None};
let input = IcInput {
symbol_name: "TST-ALL".into(), manufacturer: "Adom Test".into(), package: "QFN-16".into(),
description: "all-sides coverage".into(), datasheet_url: "https://x/y.pdf".into(),
reference_prefix: "U".into(),
pins: vec![
p("V3V3","16","power_in","Power"),
p("GND","8","power_in","Gnd"),
p("SDA","1","bidirectional","IO"),
p("SCL","2","bidirectional","IO"),
p("TX","5","output","Out"),
],
pin_layout: "all-sides".into(), chip_name_centered: false,
ref_pos: "out-top".into(), value_pos: "out-bot".into(),
distributor_pn: vec![("mouser".into(),"MOU-123".into())],
};
bless_eq(generate_ic_sym(&input), "tests/golden/TST-ALL.kicad_sym");
}
}