app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
//! Parse a `.kicad_sym` for the interactive viewer: pin list (name/number/type/
//! position/angle/length), group labels, body rectangle, and the KiCad→SVG
//! coordinate offset (matched against the rendered SVG's pin-wire paths so the
//! browser can overlay hit-zones exactly on the rendered symbol).
//!
//! Faithful port of gallia `viewer-gen.js` `parseKicadSym` +
//! `computeKicadToSvgOffset`. No gallia, no Node.
use regex::Regex;
use serde::Serialize;
#[derive(Serialize, Clone, Default)]
pub struct Pin {
pub name: String,
pub number: String,
/// human label, e.g. "Power Input"
pub r#type: String,
/// raw enum, e.g. "power_in"
pub etype: String,
/// hex color for the type badge
pub color: String,
pub x: f64,
pub y: f64,
pub angle: i64,
pub length: f64,
pub desc: String,
}
#[derive(Serialize, Clone, Default)]
pub struct Group {
pub name: String,
pub desc: String,
}
#[derive(Serialize, Clone, Default)]
pub struct BodyRect {
pub x1: f64,
pub y1: f64,
pub x2: f64,
pub y2: f64,
}
#[derive(Serialize, Clone, Default)]
pub struct SymbolMeta {
pub name: String,
pub reference: String,
pub value: String,
pub footprint: String,
pub datasheet: String,
pub description: String,
pub manufacturer: String,
pub pins: Vec<Pin>,
pub groups: Vec<Group>,
pub hide_pin_names: bool,
pub body_rect: Option<BodyRect>,
}
fn type_label(t: &str) -> String {
match t {
"power_in" => "Power Input",
"power_out" => "Power Output",
"input" => "Input",
"output" => "Output",
"bidirectional" => "Bidirectional",
"passive" => "Passive",
"tri_state" => "Tri-State",
"unspecified" => "Unspecified",
"open_collector" => "Open Collector",
"open_emitter" => "Open Emitter",
"unconnected" => "Unconnected",
"free" => "Free",
other => other,
}
.to_string()
}
fn type_color(t: &str) -> String {
match t {
"power_in" | "power_out" => "#ef5350",
"input" => "#66bb6a",
"output" => "#42a5f5",
"bidirectional" => "#ab47bc",
"passive" => "#78909c",
_ => "#90a4ae",
}
.to_string()
}
/// Parse the `.kicad_sym`. `symbol_name` is the symbol block to read (must exist).
pub fn parse_kicad_sym(content: &str, symbol_name: &str) -> SymbolMeta {
let mut meta = SymbolMeta { name: symbol_name.to_string(), ..Default::default() };
let hide_names = Regex::new(r"(?m)\(pin_names[^\n]*\(hide\s+yes\)").unwrap();
if hide_names.is_match(content) {
meta.hide_pin_names = true;
}
// The requested symbol's block to end of file (single-symbol files).
let esc = regex::escape(symbol_name);
let sym_re = Regex::new(&format!(r#"(?s)\(symbol "{esc}".*"#)).unwrap();
let mut sym_block = match sym_re.find(content) {
Some(m) => m.as_str().to_string(),
None => return meta,
};
// Properties from the requested block.
let prop_re = Regex::new(r#"\(property "(\w+)" "([^"]*)""#).unwrap();
for c in prop_re.captures_iter(&sym_block) {
let key = c[1].to_lowercase();
let val = c[2].to_string();
match key.as_str() {
"reference" => meta.reference = val,
"value" => meta.value = val,
"footprint" => meta.footprint = val,
"datasheet" => meta.datasheet = val,
"description" => meta.description = val,
"manufacturer" => meta.manufacturer = val,
_ => {}
}
}
if meta.name.is_empty() {
meta.name = if !meta.value.is_empty() { meta.value.clone() } else { symbol_name.to_string() };
}
// Follow extends for pin/geometry (child only overrides properties).
let ext_re = Regex::new(r#"\(extends\s+"([^"]+)"\)"#).unwrap();
if let Some(c) = ext_re.captures(&sym_block) {
let parent = regex::escape(&c[1]);
let parent_re = Regex::new(&format!(r#"(?s)\(symbol "{parent}".*"#)).unwrap();
if let Some(pm) = parent_re.find(content) {
sym_block = pm.as_str().to_string();
if hide_names.is_match(&sym_block) {
meta.hide_pin_names = true;
}
}
}
// Pins — compact and multi-line.
// Angle may be integer (gallia-generated) or decimal "180.0"
// (manufacturer/SnapEDA files). `\)\s+\(name` requires only whitespace
// before (name, which naturally skips `hide` pins (not drawn in the SVG,
// so they'd get floating hit-zones).
let pin_re = Regex::new(
r#"(?s)\(pin\s+(\w+)\s+(\w+)\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)\s+\(length\s+([-\d.]+)\)\s+\(name\s+"([^"]*)".*?\(number\s+"([^"]*)""#,
)
.unwrap();
for c in pin_re.captures_iter(&sym_block) {
let etype = c[1].to_string();
let name_raw = c[7].to_string();
let number = c[8].to_string();
let name = if name_raw == "~" { number.clone() } else { name_raw };
meta.pins.push(Pin {
color: type_color(&etype),
r#type: type_label(&etype),
etype,
x: c[3].parse().unwrap_or(0.0),
y: c[4].parse().unwrap_or(0.0),
angle: c[5].parse::<f64>().unwrap_or(0.0).round() as i64,
length: c[6].parse().unwrap_or(2.54),
name,
number,
desc: String::new(),
});
}
// Group labels — (text "LABEL" (at ..) \n (effects (font (size 0.762 0.762) (color 132 0 0 1))
let grp_re = Regex::new(
r#"(?s)\(text "([^"]+)"\s+\(at\s+[-\d.]+\s+[-\d.]+\s+\d+\)\s*\(effects\s+\(font\s+\(size\s+0\.762\s+0\.762\)\s+\(color\s+132\s+0\s+0\s+1\)\)"#,
)
.unwrap();
for c in grp_re.captures_iter(&sym_block) {
meta.groups.push(Group { name: c[1].to_string(), desc: String::new() });
}
// Body rectangle.
let rect_re = Regex::new(
r#"\(rectangle\s+\(start\s+([-\d.]+)\s+([-\d.]+)\)\s+\(end\s+([-\d.]+)\s+([-\d.]+)\)"#,
)
.unwrap();
if let Some(c) = rect_re.captures(&sym_block) {
meta.body_rect = Some(BodyRect {
x1: c[1].parse().unwrap_or(0.0),
y1: c[2].parse().unwrap_or(0.0),
x2: c[3].parse().unwrap_or(0.0),
y2: c[4].parse().unwrap_or(0.0),
});
}
meta
}
/// Recover the KiCad→SVG offset by matching pin-wire paths in the rendered SVG
/// against pin coords. Mapping: svgX = ox + kicadX, svgY = oy - kicadY.
///
/// Every pin wire is the same length, so a single pin×wire match is ambiguous.
/// Instead we compute a candidate offset for EVERY plausible (pin, wire) pair
/// and take the **consensus** — the offset the most pins agree on. Correctly
/// matched pins all vote for the true global translation; mismatches scatter.
pub fn compute_offset(svg: &str, pins: &[Pin]) -> Option<(f64, f64)> {
if pins.is_empty() {
return None;
}
let line_re = Regex::new(r#"d="M(-?[\d.]+)\s+(-?[\d.]+)\s+L(-?[\d.]+)\s+(-?[\d.]+)\s*""#).unwrap();
let lines: Vec<(f64, f64, f64, f64)> = line_re
.captures_iter(svg)
.filter_map(|c| Some((c[1].parse().ok()?, c[2].parse().ok()?, c[3].parse().ok()?, c[4].parse().ok()?)))
.collect();
if lines.is_empty() {
return None;
}
use std::collections::HashMap;
// Bucket candidate offsets to 0.01mm; vote.
let mut votes: HashMap<(i64, i64), (u32, f64, f64)> = HashMap::new();
for pin in pins {
let expected = if pin.length > 0.0 { pin.length } else { 2.54 };
let is_horiz = pin.angle == 0 || pin.angle == 180;
for &(x1, y1, x2, y2) in &lines {
let dx = (x2 - x1).abs();
let dy = (y2 - y1).abs();
let len = (dx * dx + dy * dy).sqrt();
if (len - expected).abs() > 0.05 {
continue;
}
if is_horiz && dy > 0.01 {
continue;
}
if !is_horiz && dx > 0.01 {
continue;
}
let (tip_x, tip_y) = match pin.angle {
0 => (x1.min(x2), (y1 + y2) / 2.0),
180 => (x1.max(x2), (y1 + y2) / 2.0),
90 => ((x1 + x2) / 2.0, y1.max(y2)),
270 => ((x1 + x2) / 2.0, y1.min(y2)),
_ => continue,
};
let ox = tip_x - pin.x;
let oy = tip_y + pin.y;
let key = ((ox * 100.0).round() as i64, (oy * 100.0).round() as i64);
let e = votes.entry(key).or_insert((0, 0.0, 0.0));
e.0 += 1;
e.1 += ox;
e.2 += oy;
}
}
// Most-voted offset wins; average the exact values in that bucket.
votes
.into_values()
.max_by_key(|&(n, _, _)| n)
.map(|(n, sx, sy)| (sx / n as f64, sy / n as f64))
}