app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
//! Category B (discrete) `.kicad_sym` generator — Rust port of the Node
//! `generateDiscreteSym`, **with the symbol-resolution bug fixed**.
//!
//! The Node version fetched the whole `<lib>.kicad_sym`, ignored the requested
//! symbol, and keyed every extends/property/header lookup off the FIRST match
//! in the entire file — so every passive came out as `C_Feedthrough`. This port
//! extracts the **specific requested symbol's** block and follows **its own**
//! `(extends …)`, so `Device/R` yields a real 2-pin resistor.
//!
//! The library is fetched via `service-kicad` (`GET /symbols/<lib>.kicad_sym`).
//! No gallia, no Node.
use anyhow::{anyhow, Result};
use regex::Regex;
pub struct DiscreteInput {
pub symbol_name: String,
pub manufacturer: String,
pub package: String,
pub description: String,
pub datasheet_url: String,
pub reference_prefix: Option<String>,
pub baseline_library: String,
pub baseline_symbol: String,
/// (old_number,new_number) and/or (old_name,new_name)
pub pin_overrides: Vec<PinOverride>,
/// Short, human on-canvas Value (e.g. "33Ω", "22nF", "Red"). Empty → fall
/// back to the symbol name (the old behavior). The MPN always survives in the
/// symbol name + a hidden `MPN` property, so BOM/adom-lbr still get it.
pub value: String,
/// Pre-scaled, pre-offset 3D-chip outline polylines in KiCad mm (y-up),
/// ready to bake beside the symbol shape as thin `(polyline …)` geometry.
/// Empty → no icon. This is the small (~30%) physical-package thumbnail so
/// SMD-vs-through-hole and part type read at a glance on the schematic.
pub outline_polylines: Vec<Vec<(f64, f64)>>,
}
#[derive(Default)]
pub struct PinOverride {
pub old_number: Option<String>,
pub new_number: Option<String>,
pub old_name: Option<String>,
pub new_name: Option<String>,
}
struct Header {
hide_pin_names: bool,
pin_name_offset: String,
}
/// `fetch_lib(library)` returns the raw `<library>.kicad_sym` contents.
pub fn generate_discrete_sym(
input: &DiscreteInput,
fetch_lib: &dyn Fn(&str) -> Result<String>,
) -> Result<String> {
let lib = fetch_lib(&input.baseline_library)?;
// The requested symbol's OWN top-level block (the fix: not the whole file).
let baseline = extract_top_symbol(&lib, &input.baseline_symbol)
.ok_or_else(|| anyhow!("symbol \"{}\" not found in library \"{}\"", input.baseline_symbol, input.baseline_library))?;
// Follow THIS symbol's extends (if any) to the graphics/pins source.
let extends = Regex::new(r#"\(extends "([^"]+)"\)"#).unwrap();
let (graphics_source, graphics_source_name): (String, String) = if let Some(c) = extends.captures(&baseline) {
let parent = c[1].to_string();
let parent_block = extract_top_symbol(&lib, &parent)
.ok_or_else(|| anyhow!("parent symbol \"{}\" not found", parent))?;
(parent_block, parent)
} else {
(baseline.clone(), input.baseline_symbol.clone())
};
let graphics_block = extract_sub_symbol(&graphics_source, &graphics_source_name, "_0_1");
let pins_block = extract_sub_symbol(&graphics_source, &graphics_source_name, "_1_1");
let header = extract_header(&graphics_source);
let ref_prefix = match &input.reference_prefix {
Some(p) if !p.is_empty() => p.clone(),
_ => extract_property(&graphics_source, "Reference").unwrap_or_else(|| "U".to_string()),
};
// Properties come from the requested symbol's block; input overrides win.
// Value is the SHORT human value (33Ω/22nF/Red) when provided — never the
// MPN. The MPN stays as the symbol name + a hidden `MPN` property below.
let prop_value = if input.value.is_empty() {
input.symbol_name.clone()
} else {
input.value.clone()
};
let prop_footprint = first_nonempty(&[
Some(input.package.clone()),
extract_property(&baseline, "Footprint"),
extract_property(&graphics_source, "Footprint"),
]);
let prop_datasheet = first_nonempty(&[Some(input.datasheet_url.clone()), extract_property(&baseline, "Datasheet")]);
let prop_description = first_nonempty(&[Some(input.description.clone()), extract_property(&baseline, "Description")]);
let prop_manufacturer = first_nonempty(&[Some(input.manufacturer.clone()), extract_property(&baseline, "Manufacturer")]);
// Clean-discrete mode (a short value or an outline icon was supplied): place
// Reference + Value at consistent spots CLEAR of the body — Reference
// upper-right, Value lower-right, left-justified — instead of the KiCad
// baseline's `(0 0 90)` Value that sits on top of the shape. Zero overlap,
// identical placement on every discrete.
let clean = !input.value.is_empty() || !input.outline_polylines.is_empty();
let (ref_at, val_at, ref_effects, val_effects) = if clean {
(
"2.54 1.0 0".to_string(),
"2.54 -1.0 0".to_string(),
"(effects (font (size 1.27 1.27)) (justify left))".to_string(),
"(effects (font (size 1.27 1.27)) (justify left))".to_string(),
)
} else {
(
extract_property_at(&graphics_source, "Reference").unwrap_or_else(|| "0 2.54 0".to_string()),
extract_property_at(&graphics_source, "Value").unwrap_or_else(|| "0 -2.54 0".to_string()),
extract_property_effects(&graphics_source, "Reference").unwrap_or_else(|| "(effects (font (size 1.27 1.27)))".to_string()),
extract_property_effects(&graphics_source, "Value").unwrap_or_else(|| "(effects (font (size 1.27 1.27)))".to_string()),
)
};
// Rename the sub-symbol blocks to the new symbol name.
let mut renamed_graphics = graphics_block.replacen(
&format!("(symbol \"{}_0_1\"", graphics_source_name),
&format!("(symbol \"{}_0_1\"", input.symbol_name),
1,
);
let mut renamed_pins = pins_block.replacen(
&format!("(symbol \"{}_1_1\"", graphics_source_name),
&format!("(symbol \"{}_1_1\"", input.symbol_name),
1,
);
// Apply pin overrides.
for ov in &input.pin_overrides {
if let (Some(o), Some(n)) = (&ov.old_number, &ov.new_number) {
let re = Regex::new(&format!(r#"\(number "{}""#, esc_regex(o))).unwrap();
renamed_pins = re.replace_all(&renamed_pins, format!("(number \"{}\"", n).as_str()).into_owned();
}
if let (Some(o), Some(n)) = (&ov.old_name, &ov.new_name) {
let re = Regex::new(&format!(r#"\(name "{}""#, esc_regex(o))).unwrap();
renamed_pins = re.replace_all(&renamed_pins, format!("(name \"{}\"", n).as_str()).into_owned();
}
}
// (renamed_graphics is not mutated after this point; bind to silence warning)
let _ = &mut renamed_graphics;
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));
if header.hide_pin_names {
lines.push(" (pin_numbers (hide yes))".to_string());
lines.push(format!(" (pin_names (offset {}) (hide yes))", header.pin_name_offset));
} else {
lines.push(format!(" (pin_names (offset {}))", header.pin_name_offset));
}
lines.push(" (exclude_from_sim no)".to_string());
lines.push(" (in_bom yes)".to_string());
lines.push(" (on_board yes)".to_string());
lines.push(format!(" (property \"Reference\" \"{}\"", ref_prefix));
lines.push(format!(" (at {})", ref_at));
lines.push(format!(" {}", ref_effects));
lines.push(" )".to_string());
lines.push(format!(" (property \"Value\" \"{}\"", prop_value));
lines.push(format!(" (at {})", val_at));
lines.push(format!(" {}", val_effects));
lines.push(" )".to_string());
lines.push(format!(" (property \"Footprint\" \"{}\"", prop_footprint));
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());
lines.push(format!(" (property \"Datasheet\" \"{}\"", prop_datasheet));
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());
lines.push(format!(" (property \"Description\" \"{}\"", prop_description));
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());
if !prop_manufacturer.is_empty() {
lines.push(format!(" (property \"Manufacturer\" \"{}\"", prop_manufacturer));
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());
}
// Keep the full MPN discoverable (BOM / adom-lbr) even though the on-canvas
// Value is now the short human value.
lines.push(format!(" (property \"MPN\" \"{}\"", input.symbol_name));
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());
// Bake the small offset 3D-chip outline into the _0_1 graphics sub-symbol as
// real thin polyline geometry (survives to KiCad + adom-lbr — not an <image>).
if !input.outline_polylines.is_empty() {
renamed_graphics = inject_outline(&renamed_graphics, &input.outline_polylines);
}
lines.push(renamed_graphics);
lines.push(renamed_pins);
lines.push(" )".to_string());
lines.push(")".to_string());
lines.push(String::new());
Ok(lines.join("\n"))
}
/// Insert the outline polylines into the `(symbol "..._0_1" …)` graphics block,
/// just before its closing paren, so KiCad renders them alongside the shape.
fn inject_outline(graphics: &str, polys: &[Vec<(f64, f64)>]) -> String {
let mut inject = String::new();
for pl in polys {
if pl.len() < 2 {
continue;
}
let pts: String = pl
.iter()
.map(|(x, y)| format!("(xy {:.3} {:.3})", x, y))
.collect::<Vec<_>>()
.join(" ");
inject.push_str(&format!(
"\n (polyline (pts {}) (stroke (width 0.12) (type default)) (fill (type none)))",
pts
));
}
if inject.is_empty() {
return graphics.to_string();
}
match graphics.rfind(')') {
Some(idx) => format!("{}{}\n )", graphics[..idx].trim_end(), inject),
None => graphics.to_string(),
}
}
fn first_nonempty(opts: &[Option<String>]) -> String {
for o in opts {
if let Some(s) = o {
if !s.is_empty() {
return s.clone();
}
}
}
String::new()
}
/// Extract a top-level `(symbol "NAME" …)` block by exact-quote match + paren balance.
fn extract_top_symbol(lib: &str, name: &str) -> Option<String> {
let marker = format!("(symbol \"{}\"", name);
let start = lib.find(&marker)?;
let bytes = lib.as_bytes();
let mut depth = 0i32;
let mut end = start;
for i in start..lib.len() {
match bytes[i] {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
end = i;
break;
}
}
_ => {}
}
}
Some(lib[start..=end].to_string())
}
/// Extract a `(symbol "NAME<suffix>" …)` sub-block, preserving the leading
/// indentation of its line (matches Node `extractSubSymbol`).
fn extract_sub_symbol(content: &str, name: &str, suffix: &str) -> String {
let target = format!("(symbol \"{}{}\"", name, suffix);
let start = match content.find(&target) {
Some(s) => s,
None => return String::new(),
};
let bytes = content.as_bytes();
let mut depth = 0i32;
let mut end = start;
for i in start..content.len() {
match bytes[i] {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
end = i;
break;
}
}
_ => {}
}
}
let line_start = content[..start].rfind('\n').map(|p| p + 1).unwrap_or(0);
content[line_start..=end].to_string()
}
fn extract_header(content: &str) -> Header {
let hide_pin_names = Regex::new(r"(?s)\(pin_names.*?\(hide\s+yes\)").unwrap().is_match(content);
let offset = Regex::new(r"\(pin_names\s*\n?\s*\(offset\s+([\d.]+)\)")
.unwrap()
.captures(content)
.map(|c| c[1].to_string())
.unwrap_or_else(|| "1.016".to_string());
Header { hide_pin_names, pin_name_offset: offset }
}
fn extract_property(content: &str, prop: &str) -> Option<String> {
let re = Regex::new(&format!(r#"\(property "{}" "([^"]*)""#, esc_regex(prop))).unwrap();
re.captures(content).map(|c| c[1].to_string())
}
fn extract_property_at(content: &str, prop: &str) -> Option<String> {
let re = Regex::new(&format!(r#"\(property "{}" "[^"]*"\s*\n\s*\(at\s+([^)]+)\)"#, esc_regex(prop))).unwrap();
re.captures(content).map(|c| c[1].trim().to_string())
}
fn extract_property_effects(content: &str, prop: &str) -> Option<String> {
let prop_start = content.find(&format!("(property \"{}\"", prop))?;
let effects_rel = content[prop_start..].find("(effects")?;
let effects_start = prop_start + effects_rel;
let prop_end = find_matching_paren(content, prop_start);
if effects_start > prop_end {
return None;
}
let effects_end = find_matching_paren(content, effects_start);
Some(content[effects_start..=effects_end].to_string())
}
fn find_matching_paren(content: &str, start: usize) -> usize {
let bytes = content.as_bytes();
let mut depth = 0i32;
for i in start..content.len() {
match bytes[i] {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
return i;
}
}
_ => {}
}
}
content.len().saturating_sub(1)
}
fn esc_regex(s: &str) -> String {
regex::escape(s)
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_fetch(_lib: &str) -> Result<String> {
Ok(include_str!("../tests/golden/Device.kicad_sym").to_string())
}
#[test]
fn discrete_r_is_a_real_resistor_not_feedthrough() {
let input = DiscreteInput {
symbol_name: "R-TEST-10K".into(),
manufacturer: "Yageo".into(),
package: "R_0402_1005Metric".into(),
description: "10k 1% 0402".into(),
datasheet_url: "https://yageo/r.pdf".into(),
reference_prefix: Some("R".into()),
baseline_library: "Device".into(),
baseline_symbol: "R".into(),
pin_overrides: vec![],
value: String::new(),
outline_polylines: vec![],
};
let out = generate_discrete_sym(&input, &fixture_fetch).unwrap();
// The bug fix: a resistor has exactly 2 pins (the broken Node path gave 3 = C_Feedthrough).
assert_eq!(out.matches("(pin ").count(), 2, "resistor must have 2 pins, not C_Feedthrough's 3");
assert!(out.contains("(symbol \"R-TEST-10K_0_1\""), "renamed graphics sub-symbol");
assert!(out.contains("(symbol \"R-TEST-10K_1_1\""), "renamed pins sub-symbol");
assert!(out.contains("(property \"Reference\" \"R\""), "reference prefix R");
// value empty → falls back to the symbol name (MPN)
assert!(out.contains("(property \"Value\" \"R-TEST-10K\""));
assert!(out.contains("(property \"Footprint\" \"R_0402_1005Metric\""));
assert!(!out.contains("C_Feedthrough"), "must not leak the C_Feedthrough baseline");
assert!(out.starts_with("(kicad_symbol_lib"));
}
#[test]
fn short_value_and_outline_are_applied() {
let input = DiscreteInput {
symbol_name: "0603WAF330JT5E".into(),
manufacturer: "UNI-ROYAL".into(),
package: "R_0603_1608Metric".into(),
description: "33Ω 1% 0603".into(),
datasheet_url: "".into(),
reference_prefix: Some("R".into()),
baseline_library: "Device".into(),
baseline_symbol: "R".into(),
pin_overrides: vec![],
value: "33Ω".into(),
outline_polylines: vec![vec![(-4.0, 1.0), (-3.0, 1.0), (-3.0, -1.0)]],
};
let out = generate_discrete_sym(&input, &fixture_fetch).unwrap();
// short human value on canvas, MPN preserved in a hidden property
assert!(out.contains("(property \"Value\" \"33Ω\""), "on-canvas value is short");
assert!(!out.contains("(property \"Value\" \"0603WAF330JT5E\""), "MPN not the value");
assert!(out.contains("(property \"MPN\" \"0603WAF330JT5E\""), "MPN preserved hidden");
// the offset 3D-chip outline is baked as real polyline geometry
assert!(out.contains("(polyline (pts (xy -4.000 1.000)"), "outline baked into graphics");
}
}