app
Adom DS2SF - Datasheet to Symbol and Footprint
Public Made by Adomby adom
Datasheet to symbol + footprint + provenance JSON — pins and pads, extracted from the PDF.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022
//! Footprint SVG generator — produces an authoritative top-down rendering
//! of the chip's PCB footprint that downstream 3D / 2D tooling (chipsmith,
//! adom-tsci, sym_create previews, wiki page hero, etc.) can use as a
//! visual ground-truth reference.
//!
//! Two paths:
//! - **service-kicad-stdlib**: when ds2sf's normalize step matched a KiCad
//! standard-library footprint, fetch the canonical SVG from
//! `service-kicad/fp/export/svg/<library>/<name>`. Same SVG every other
//! Adom tool would render — this is the authoritative form.
//! - **ds2sf-synthesized**: when there's no stdlib match (custom OLGA,
//! SMD modules, vendor-specific outline codes), generate a minimal SVG
//! from the extracted body + lead dimensions and pad descriptions. The
//! layout heuristic picks a pad arrangement based on the package family
//! (QFN/SOIC/SOT/BGA/LGA/DIP/VSSOP/TSSOP/module). Always stamped as
//! "synthesized" so consumers know this is a best-effort reference, not
//! a manufacturer-authoritative drawing.
use anyhow::{Context as _, Result};
use serde::Serialize;
use std::fs;
use std::io::Read;
#[allow(unused_imports)]
use std::path::Path;
use crate::context::Context;
use crate::extract::FootprintPass;
use crate::normalize::Resolved;
// ── per-pad position data (consumed by chipsmith cross-validation) ───────
#[derive(Debug, Clone, Serialize)]
pub struct ComputedPad {
pub number: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub function: Option<String>,
#[serde(rename = "type")]
pub pad_type: &'static str,
pub shape: &'static str,
pub at: [f64; 2],
pub size: [f64; 2],
#[serde(skip_serializing_if = "Option::is_none")]
pub corner_radius: Option<f64>,
pub layers: &'static [&'static str],
#[serde(skip_serializing_if = "Option::is_none")]
pub drill: Option<DrillInfo>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub is_ep: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub annular_ring: Option<f64>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub flags: Vec<&'static str>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DrillInfo {
pub shape: &'static str,
pub diameter: f64,
}
const SMD_LAYERS: &[&str] = &["F.Cu", "F.Paste", "F.Mask"];
const TH_LAYERS: &[&str] = &["*.Cu", "*.Paste", "*.Mask"];
/// Compute per-pad positions from the extracted footprint dimensions.
/// Coordinate origin at footprint center, Y-down (KiCad convention).
/// Shapes: "rect", "circle", "oval", "roundrect".
pub fn compute_pads(fp: &FootprintPass) -> Vec<ComputedPad> {
let family = detect_family(&fp.package_name);
let body_x = fp.body_dimensions.x.max(1.0);
let body_y = fp.body_dimensions.y.max(1.0);
let pad_count = fp.pad_count as usize;
let raw = match family {
Family::Qfn => layout_qfn(pad_count, body_x, body_y, &fp.lead_dimensions),
Family::DualSide => layout_dual_side(pad_count, body_x, body_y, &fp.lead_dimensions),
Family::Sot => layout_sot(pad_count, body_x, body_y),
Family::Bga => layout_bga_lga(&fp.pad_descriptions, body_x, body_y, &fp.lead_dimensions, true),
Family::Lga => {
let grid = layout_bga_lga(&fp.pad_descriptions, body_x, body_y, &fp.lead_dimensions, false);
if !grid.is_empty() { grid } else { layout_qfn(pad_count, body_x, body_y, &fp.lead_dimensions) }
}
Family::Dip => layout_dip(pad_count, body_x, body_y, &fp.lead_dimensions),
Family::Discrete => layout_discrete(pad_count, body_x, body_y),
Family::Connector => layout_connector(pad_count, body_x, body_y, &fp.lead_dimensions),
Family::Module => layout_module(&fp.pad_descriptions, body_x, body_y),
Family::Unknown => Vec::new(),
};
let is_th = matches!(family, Family::Dip | Family::Connector);
let base_shape: &str = match family {
Family::Bga => "circle",
Family::Dip => "oval",
Family::Connector => "circle",
_ => "rect",
};
// IPC-7351 recommends rounded corners (roundrect) for SMD pads at
// fine pitch (<= 0.65mm) to improve paste release and reduce bridging.
// At coarser pitches, sharp rect is traditional but roundrect is still
// preferable for copper adhesion to the FR4 substrate.
let pitch = lead_pitch(family, &fp.lead_dimensions);
let recommend_roundrect = !is_th && base_shape == "rect" && pitch > 0.0;
raw.into_iter().map(|(num, x, y, w, h)| {
let ep = !is_th && is_exposed_pad(&num, pad_count, family);
let shape = if ep {
"rect"
} else if recommend_roundrect && !ep {
"roundrect"
} else {
base_shape
};
// Corner radius: 25% of the smaller pad dimension (IPC-7351 guideline).
let cr = if shape == "roundrect" {
Some(round3(w.min(h) * 0.25))
} else {
None
};
let drill_dia = if is_th { round3(w.min(h) * 0.55) } else { 0.0 };
let annular = if is_th { Some(round3((w.min(h) - drill_dia) / 2.0)) } else { None };
// Look up pin name + function from padDescriptions / symbolPinMap.
let pin_name = fp.symbol_pin_map
.get(&num)
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let pin_func = fp.pad_descriptions
.get(&num)
.and_then(|v| v.as_str())
.map(|s| {
if s.len() > 120 { format!("{}...", &s[..117]) } else { s.to_string() }
});
let mut flags: Vec<&'static str> = Vec::new();
if ep { flags.push("exposed_pad"); }
if is_th { flags.push("plated_through_hole"); }
if shape == "roundrect" && pitch <= 0.5 {
flags.push("fine_pitch");
}
ComputedPad {
number: num,
name: pin_name,
function: pin_func,
pad_type: if is_th { "thru_hole" } else { "smd" },
shape,
at: [round3(x), round3(y)],
size: [round3(w), round3(h)],
corner_radius: cr,
layers: if is_th { TH_LAYERS } else { SMD_LAYERS },
drill: if is_th {
Some(DrillInfo { shape: "circle", diameter: drill_dia })
} else {
None
},
is_ep: ep,
annular_ring: annular,
flags,
}
}).collect()
}
fn is_exposed_pad(num: &str, pad_count: usize, family: Family) -> bool {
match family {
Family::Qfn => {
// QFN periphery uses pads_per_side * 4; anything beyond is EP.
if let Ok(n) = num.parse::<usize>() {
let pps = pad_count / 4;
n > pps * 4
} else {
false
}
}
_ => false,
}
}
fn round3(v: f64) -> f64 {
(v * 1000.0).round() / 1000.0
}
// ── lead annotations for service-step2glb /create-chip ──────────────────
#[derive(Debug, Clone, Serialize)]
pub struct LeadAnnotation {
pub number: String,
pub name: String,
pub function: String,
pub side: &'static str,
}
/// Emit lead_annotations[] ordered to match chipsmith's lead creation order.
/// Dual-side: right top→bottom, then left top→bottom.
/// Quad (QFN/QFP): right top→bottom, left top→bottom, top left→right, bottom left→right.
/// BGA/LGA: row-major (A1, A2, ..., B1, B2, ...).
pub fn compute_lead_annotations(fp: &FootprintPass) -> Vec<LeadAnnotation> {
let family = detect_family(&fp.package_name);
let body_x = fp.body_dimensions.x.max(1.0);
let body_y = fp.body_dimensions.y.max(1.0);
let pad_count = fp.pad_count as usize;
let raw = match family {
Family::Qfn => layout_qfn(pad_count, body_x, body_y, &fp.lead_dimensions),
Family::DualSide => layout_dual_side(pad_count, body_x, body_y, &fp.lead_dimensions),
Family::Sot => layout_sot(pad_count, body_x, body_y),
Family::Bga => layout_bga_lga(&fp.pad_descriptions, body_x, body_y, &fp.lead_dimensions, true),
Family::Lga => {
let grid = layout_bga_lga(&fp.pad_descriptions, body_x, body_y, &fp.lead_dimensions, false);
if !grid.is_empty() { grid } else { layout_qfn(pad_count, body_x, body_y, &fp.lead_dimensions) }
}
Family::Dip => layout_dip(pad_count, body_x, body_y, &fp.lead_dimensions),
Family::Discrete => layout_discrete(pad_count, body_x, body_y),
Family::Connector => layout_connector(pad_count, body_x, body_y, &fp.lead_dimensions),
Family::Module => layout_module(&fp.pad_descriptions, body_x, body_y),
Family::Unknown => Vec::new(),
};
// Classify each pad by side based on its position relative to body center.
let mut annotated: Vec<(String, f64, f64, &'static str)> = raw.iter().map(|(num, x, y, _w, _h)| {
let side = classify_side(*x, *y, body_x, body_y, family);
(num.clone(), *x, *y, side)
}).collect();
// Sort into chipsmith's creation order: right→left→top→bottom.
// Within each side, sort by the axis parallel to the edge.
let side_order = |s: &str| -> u8 {
match s { "right" => 0, "left" => 1, "top" => 2, "bottom" => 3, _ => 4 }
};
annotated.sort_by(|a, b| {
let sa = side_order(a.3);
let sb = side_order(b.3);
if sa != sb { return sa.cmp(&sb); }
match a.3 {
"right" | "left" => a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal),
"top" | "bottom" => a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal),
_ => std::cmp::Ordering::Equal,
}
});
annotated.into_iter().map(|(num, _x, _y, side)| {
let name = fp.symbol_pin_map
.get(&num)
.and_then(|v| v.as_str())
.unwrap_or(&num)
.to_string();
let function = fp.pad_descriptions
.get(&num)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
LeadAnnotation { number: num, name, function, side }
}).collect()
}
fn classify_side(x: f64, y: f64, body_x: f64, body_y: f64, family: Family) -> &'static str {
match family {
Family::DualSide | Family::Sot | Family::Dip => {
if x < 0.0 { "left" } else { "right" }
}
Family::Qfn => {
let hx = body_x / 2.0;
let hy = body_y / 2.0;
// EP sits at center
if x.abs() < hx * 0.5 && y.abs() < hy * 0.5 { return "bottom"; }
let dx = x.abs() - hx;
let dy = y.abs() - hy;
if dx > dy {
if x < 0.0 { "left" } else { "right" }
} else {
if y < 0.0 { "top" } else { "bottom" }
}
}
Family::Bga | Family::Lga => "bottom",
Family::Connector => "bottom",
Family::Module => {
if y.abs() > body_y / 2.0 * 0.8 {
if y < 0.0 { "top" } else { "bottom" }
} else if x > 0.0 { "right" } else { "left" }
}
Family::Discrete => {
if x < 0.0 { "left" } else { "right" }
}
Family::Unknown => "bottom",
}
}
// ── 3D chip geometry for OCCT construction ──────────────────────────────
#[derive(Debug, Clone, Serialize)]
pub struct Chip3D {
pub body_width_mm: f64,
pub body_depth_mm: f64,
pub body_height_mm: f64,
pub package_family: &'static str,
pub structural_type: &'static str,
pub lead_shape: &'static str,
pub lead_count: u32,
pub lead_pitch_mm: f64,
pub lead_width_mm: f64,
pub lead_length_mm: f64,
pub lead_span_mm: f64,
pub standoff_height_mm: f64,
pub has_ep: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub ep_width_mm: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ep_depth_mm: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ball_diameter_mm: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pin_diameter_mm: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pin_length_below_body_mm: Option<f64>,
pub pin1_indicator: &'static str,
pub corner_chamfer_mm: f64,
pub body_color: &'static str,
pub lead_color: &'static str,
}
pub fn compute_chip_3d(fp: &FootprintPass) -> Chip3D {
let family = detect_family(&fp.package_name);
let lead = &fp.lead_dimensions;
let pitch = lead_pitch(family, lead);
let pad_w = lead.get("pad_w").and_then(|v| v.as_f64()).unwrap_or(0.3);
let pad_h = lead.get("pad_h").and_then(|v| v.as_f64()).unwrap_or(0.6);
// Prefer JEDEC dimensions from the mechanical section when available;
// fall back to bodyDimensions + heuristics.
let jd = fp.mechanical.as_ref()
.and_then(|m| m.get("jedec_dims"));
let jedec_val = |key: &str| -> Option<f64> {
jd.and_then(|j| j.get(key))
.and_then(|v| v.get("value"))
.and_then(|v| v.as_f64())
};
let body_w = jedec_val("D").unwrap_or(fp.body_dimensions.x.max(0.5));
let body_d = jedec_val("E1").or(jedec_val("E")).unwrap_or(fp.body_dimensions.y.max(0.5));
let body_h = jedec_val("A2")
.or(jedec_val("A"))
.or(fp.body_dimensions.thickness_max)
.unwrap_or_else(|| default_height(family));
let mech_lead_shape = fp.mechanical.as_ref()
.and_then(|m| m.get("lead_shape"))
.and_then(|v| v.as_str());
let mech_pin1 = fp.mechanical.as_ref()
.and_then(|m| m.get("pin1_indicator"))
.and_then(|v| v.as_str());
let tp = lead.get("thermal_pad").and_then(|v| if v.is_null() { None } else { Some(v) });
let has_ep = tp.is_some() || (matches!(family, Family::Qfn) && fp.pad_count as usize > (fp.pad_count as usize / 4) * 4);
let (pkg_family, struct_type, lead_shape, standoff, _lead_len) = match family {
Family::Qfn => ("qfn", "smd_flat_pad", "flat", 0.02, pad_h),
Family::DualSide => {
let span_candidate = lead.get("pitch_y").and_then(|v| v.as_f64())
.or_else(|| lead.get("pitch_x").and_then(|v| v.as_f64()))
.unwrap_or(0.0);
let span = if span_candidate > body_w.max(body_d) { span_candidate } else { body_w + pad_h * 2.0 + 0.2 };
let _ = span;
("dual_side", "smd_gull_wing", "gull_wing", 0.1, pad_h)
}
Family::Sot => ("sot", "smd_gull_wing", "gull_wing", 0.1, pad_h),
Family::Bga => ("bga", "smd_ball", "ball", pad_w * 0.4, 0.0),
Family::Lga => ("lga", "smd_flat_pad", "flat", 0.02, pad_h),
Family::Dip => ("dip", "thru_hole_pin", "straight_pin", 0.0, 0.0),
Family::Module => ("module", "smd_castellated", "castellated", 0.0, pad_h),
Family::Discrete => ("discrete", "smd_flat_pad", "flat", 0.02, pad_h),
Family::Connector => ("connector", "thru_hole_pin", "straight_pin", 0.0, 0.0),
Family::Unknown => ("unknown", "unknown", "unknown", 0.05, pad_h),
};
let lead_span = match family {
Family::DualSide | Family::Sot => {
let px = lead.get("pitch_x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let py = lead.get("pitch_y").and_then(|v| v.as_f64()).unwrap_or(0.0);
let span = px.max(py);
if span > body_w.min(body_d) { span } else { body_w + pad_h * 2.0 }
}
Family::Qfn | Family::Lga => body_w,
_ => 0.0,
};
Chip3D {
body_width_mm: round3(body_w),
body_depth_mm: round3(body_d),
body_height_mm: round3(body_h),
package_family: pkg_family,
structural_type: struct_type,
lead_shape: match mech_lead_shape {
Some("gull_wing") => "gull_wing",
Some("j_lead") => "j_lead",
Some("flat") => "flat",
Some("ball") => "ball",
Some("straight_pin") => "straight_pin",
Some("castellated") => "castellated",
Some("no_lead") => "no_lead",
_ => lead_shape,
},
lead_count: fp.pad_count,
lead_pitch_mm: round3(pitch),
lead_width_mm: jedec_val("b").map(round3).unwrap_or_else(|| round3(pad_w.min(pad_h))),
lead_length_mm: jedec_val("L").map(round3).unwrap_or_else(|| round3(pad_w.max(pad_h))),
lead_span_mm: round3(lead_span),
standoff_height_mm: jedec_val("A1").map(round3).unwrap_or_else(|| round3(standoff)),
has_ep,
ep_width_mm: tp.and_then(|v| v.get("x").and_then(|x| x.as_f64())).map(round3),
ep_depth_mm: tp.and_then(|v| v.get("y").and_then(|y| y.as_f64())).map(round3),
ball_diameter_mm: if matches!(family, Family::Bga) { Some(round3(pad_w)) } else { None },
pin_diameter_mm: if matches!(family, Family::Dip | Family::Connector) {
Some(round3(pad_w.min(pad_h) * 0.55))
} else { None },
pin_length_below_body_mm: if matches!(family, Family::Dip | Family::Connector) {
Some(3.0)
} else { None },
pin1_indicator: match mech_pin1 {
Some("chamfer") => "chamfer_top_left",
Some("dot") => "dot_top_left",
Some("bar") => "bar_pin1_side",
Some("notch") => "notch_top_center",
Some(other) => {
// leak the string so we get a &'static str — acceptable for
// a small number of distinct values per process lifetime.
let s: &'static str = Box::leak(other.to_string().into_boxed_str());
s
}
None => "chamfer_top_left",
},
corner_chamfer_mm: round3(body_w.min(body_d) * 0.08),
body_color: "#333333",
lead_color: if matches!(family, Family::Bga) { "#c0c0c0" } else { "#d4a84b" },
}
}
fn default_height(family: Family) -> f64 {
match family {
Family::Qfn | Family::Lga => 0.85,
Family::DualSide => 1.2,
Family::Sot => 1.1,
Family::Bga => 0.6,
Family::Dip => 5.0,
Family::Module => 3.0,
Family::Discrete => 0.55,
Family::Connector => 8.5,
Family::Unknown => 1.0,
}
}
fn lead_pitch(family: Family, lead: &serde_json::Value) -> f64 {
let px = lead.get("pitch_x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let py = lead.get("pitch_y").and_then(|v| v.as_f64()).unwrap_or(0.0);
let p = lead.get("pitch").and_then(|v| v.as_f64()).unwrap_or(0.0);
match family {
Family::DualSide | Family::Sot => {
// Smaller of pitch_x/pitch_y is pin pitch (larger is row span).
if px > 0.01 && py > 0.01 { px.min(py) }
else if px > 0.01 { px }
else if py > 0.01 { py }
else { p }
}
_ => {
if px > 0.01 { px }
else if py > 0.01 { py }
else { p }
}
}
}
const KICAD_BASE: &str = "https://kicad-rk5ue5pcfemi.adom.cloud";
/// Result of SVG generation — ds2sf now produces TWO SVGs:
/// - `<MPN>-footprint.ds2sf.svg` — synthesized purely from datasheet dimensions
/// - `<MPN>-footprint.pcbnew.svg` — fetched from service-kicad (when stdlib match exists)
///
/// The ds2sf SVG is the manufacturer's voice; the pcbnew SVG is KiCad's
/// interpretation. chipsmith diffs them visually. Neither replaces the other.
#[derive(Debug, Clone)]
pub struct SvgResult {
pub ds2sf_svg: Option<std::path::PathBuf>,
pub pcbnew_svg: Option<std::path::PathBuf>,
}
/// Generate both footprint SVGs.
pub fn generate(ctx: &Context, fp: &FootprintPass, norm: Option<&Resolved>) -> SvgResult {
let mut result = SvgResult { ds2sf_svg: None, pcbnew_svg: None };
// Always synthesize from datasheet-extracted dimensions — this is ds2sf's
// independent voice and the whole point of the tool.
let ds2sf_path = ctx.out_dir.join(format!("{}-footprint.ds2sf.svg", ctx.mpn));
let svg = synthesize_svg(ctx, fp);
if fs::write(&ds2sf_path, svg).is_ok() {
result.ds2sf_svg = Some(ds2sf_path);
}
// Also write to the legacy .authoritative.svg path for backwards compat.
let legacy_path = ctx.footprint_svg_path();
if let Some(ref p) = result.ds2sf_svg {
let _ = fs::copy(p, &legacy_path);
}
// Optionally fetch from service-kicad for visual comparison in chipsmith.
if let Some(n) = norm {
if let Some(b) = n.kicad_baseline.as_ref() {
let pcbnew_path = ctx.out_dir.join(format!("{}-footprint.pcbnew.svg", ctx.mpn));
if let Ok(svg) = fetch_stdlib_svg(&b.library, &b.name) {
if fs::write(&pcbnew_path, &svg).is_ok() {
result.pcbnew_svg = Some(pcbnew_path);
}
}
}
}
result
}
fn fetch_stdlib_svg(library: &str, name: &str) -> Result<Vec<u8>> {
let url = format!("{KICAD_BASE}/fp/export/svg/{library}/{name}");
let resp = ureq::get(&url)
.timeout(std::time::Duration::from_secs(10))
.call()
.with_context(|| format!("GET {url}"))?;
if resp.status() != 200 {
anyhow::bail!("service-kicad returned status {}", resp.status());
}
let mut bytes = Vec::new();
resp.into_reader().read_to_end(&mut bytes)?;
if bytes.len() < 100 {
anyhow::bail!("service-kicad returned suspiciously small SVG ({} bytes)", bytes.len());
}
Ok(bytes)
}
// ── synthesis ────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy)]
enum Family {
Qfn, // 4-sided periphery, square-ish body, optional EP
DualSide, // 2-sided periphery (SOIC, VSSOP, TSSOP, SSOP, MSOP)
Sot, // small SOT-23 family (3, 5, 6 pins)
Bga, // 2D ball grid (DSBGA, BGA)
Lga, // 2D pad grid (LGA, OLGA)
Dip, // through-hole DIP, 2 columns
Module, // module / castellated edge — minimal placeholder
Discrete, // 2-pin SMD passive (LED, resistor, cap)
Connector, // single-row through-hole/SMD connector (JST PH/XH, headers, terminal blocks)
Unknown, // bounding-box-only fallback
}
fn detect_family(pkg: &str) -> Family {
let u = pkg.to_uppercase();
if u.contains("VQFN") || u.contains("QFN") || u.contains("DFN") || u.contains("WSON") { return Family::Qfn; }
if u.contains("LQFP") || u.contains("TQFP") || u.contains("QFP") { return Family::Qfn; }
// Analog Devices' "Lead Frame Chip Scale Package" — same body+EP+periphery
// pads as a QFN; ADI just brands it differently. Same for TI's HVQFN /
// MicroLeadFrame variants.
if u.contains("LFCSP") || u.contains("MICROLEADFRAME") || u.contains("HVQFN") { return Family::Qfn; }
if u.contains("SOIC") || u.contains("VSSOP") || u.contains("TSSOP") || u.contains("SSOP") || u.contains("MSOP") || u.contains("SOIJ") { return Family::DualSide; }
if u.contains("DSBGA") || u.contains("WLCSP") || u.contains("BGA") || u.contains("CSP") { return Family::Bga; }
if u.contains("OLGA") || u.contains("LGA") { return Family::Lga; }
if u.contains("SOT") || u.contains("SC-70") || u.contains("SC70") { return Family::Sot; }
if u.contains("DIP") || u.contains("PDIP") { return Family::Dip; }
if u.contains("WROOM") || u.contains("WROVER") || u.contains("MODULE") || u.contains("CASTELLATED") { return Family::Module; }
if u.contains("0402") || u.contains("0603") || u.contains("0805") || u.contains("1206") || u.contains("2010") || u.contains("2512") || u.contains("CHIPLED") { return Family::Discrete; }
// Connectors: single-row pin headers, JST PH/XH/SH, Molex, terminal blocks.
// Caught by B3B-PH-K-S (2026-05-07): JST PH connectors fell through to
// Unknown and rendered as body-only SVG.
if u.contains("JST") || u.contains("HEADER") || u.contains("CONNECTOR")
|| u.contains("MOLEX") || u.contains("RECEPTACLE") || u.contains("TERMINAL")
|| u.contains("WAGO") || u.contains("PIN STRIP") || u.contains("PINHEADER")
{ return Family::Connector; }
Family::Unknown
}
/// Build a top-down SVG of the footprint. Coordinates in millimeters; we
/// scale into the SVG viewBox at the end. Adom brand:
/// - background: #0d1117
/// - body fab: #2c5f2d (PCB green)
/// - copper pad: #d68830
/// - silk: #ffffff
/// - pin-1 dot: #ff5555
/// - text: #c9d1d9
fn synthesize_svg(ctx: &crate::context::Context, fp: &FootprintPass) -> Vec<u8> {
let family = detect_family(&fp.package_name);
let body_x = fp.body_dimensions.x.max(1.0);
let body_y = fp.body_dimensions.y.max(1.0);
let pad_count = fp.pad_count as usize;
// Compute pads list: (label, x_mm, y_mm, w_mm, h_mm)
let pads = match family {
Family::Qfn => layout_qfn(pad_count, body_x, body_y, &fp.lead_dimensions),
Family::DualSide => layout_dual_side(pad_count, body_x, body_y, &fp.lead_dimensions),
Family::Sot => layout_sot(pad_count, body_x, body_y),
Family::Bga => layout_bga_lga(&fp.pad_descriptions, body_x, body_y, &fp.lead_dimensions, true),
Family::Lga => {
// True LGA grids (OLGA, true LGA) use alphanumeric pin keys (A1, B7).
// Peripheral LGAs (Bosch BME/BMI MEMS LGA-8/14) use numeric keys
// (1..N) with pads only on the body edges — fall back to QFN-style
// 4-side periphery, no EP.
let grid = layout_bga_lga(&fp.pad_descriptions, body_x, body_y, &fp.lead_dimensions, false);
if !grid.is_empty() {
grid
} else {
layout_qfn(pad_count, body_x, body_y, &fp.lead_dimensions)
}
}
Family::Dip => layout_dip(pad_count, body_x, body_y, &fp.lead_dimensions),
Family::Discrete => layout_discrete(pad_count, body_x, body_y),
Family::Module => layout_module(&fp.pad_descriptions, body_x, body_y),
Family::Connector => layout_connector(pad_count, body_x, body_y, &fp.lead_dimensions),
Family::Unknown => Vec::new(),
};
// Bounding region — body + 1 mm courtyard.
let courtyard = 1.0_f64;
let half_x = (body_x / 2.0) + courtyard;
let half_y = (body_y / 2.0) + courtyard;
let view_w = half_x * 2.0;
let view_h = half_y * 2.0;
let scale = 80.0_f64; // SVG units per mm
let svg_w = (view_w * scale) as i32;
let svg_h = (view_h * scale) as i32 + 60; // +60 px for caption at bottom
let mut buf = String::new();
buf.push_str(&format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="{svg_w}" height="{svg_h}" viewBox="0 0 {svg_w} {svg_h}">
<style>
.bg {{ fill: #0d1117; }}
.body {{ fill: #2c5f2d; stroke: #ffffff; stroke-width: 0.6; }}
.courtyard {{ fill: none; stroke: #4a5560; stroke-width: 0.5; stroke-dasharray: 4 3; }}
.pad {{ fill: #d68830; stroke: #5a3a14; stroke-width: 0.4; }}
.pin-num {{ fill: #ffffff; font: 600 9px 'Liberation Sans', sans-serif; text-anchor: middle; dominant-baseline: central; }}
.pin1 {{ fill: #ff5555; }}
.pin1-pad {{ fill: #e8a040; stroke: #ff5555; stroke-width: 1.0; }}
.drill {{ fill: #0d1117; stroke: #333; stroke-width: 0.3; }}
.ep {{ fill: #c0a050; stroke: #5a3a14; stroke-width: 0.4; opacity: 0.85; }}
.caption {{ fill: #c9d1d9; font: 11px 'Liberation Sans', sans-serif; }}
.stamp {{ fill: #f0883e; font: 600 10px 'Liberation Sans', sans-serif; }}
</style>
<rect class="bg" width="{svg_w}" height="{svg_h}"/>
"#));
// Coordinate transform: mm → SVG px, with body centered.
let mm_to_px_x = |x: f64| (x + half_x) * scale;
let mm_to_px_y = |y: f64| (y + half_y) * scale;
// Courtyard.
buf.push_str(&format!(
r#" <rect class="courtyard" x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}"/>
"#,
mm_to_px_x(-half_x), mm_to_px_y(-half_y),
view_w * scale, view_h * scale,
));
// Body.
buf.push_str(&format!(
r#" <rect class="body" x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="2"/>
"#,
mm_to_px_x(-body_x / 2.0), mm_to_px_y(-body_y / 2.0),
body_x * scale, body_y * scale,
));
// Pin-1 indicator dot — top-left of body, slightly inside.
let dot_x = -body_x / 2.0 + 0.4;
let dot_y = -body_y / 2.0 + 0.4;
buf.push_str(&format!(
r#" <circle class="pin1" cx="{:.1}" cy="{:.1}" r="6"/>
"#,
mm_to_px_x(dot_x), mm_to_px_y(dot_y),
));
// Pads — shape-aware rendering.
let pitch = lead_pitch(family, &fp.lead_dimensions);
let use_roundrect = !matches!(family, Family::Dip | Family::Connector) && pitch > 0.0;
for (label, x, y, w, h) in &pads {
let is_pin1 = *label == "1" || label.eq_ignore_ascii_case("a1");
let extra = if is_pin1 { " pin1-pad" } else { "" };
let is_bga = matches!(family, Family::Bga);
let is_th = matches!(family, Family::Dip | Family::Connector);
let ep = is_exposed_pad(label, pad_count, family);
let cx = mm_to_px_x(*x);
let cy = mm_to_px_y(*y);
let pw = *w * scale;
let ph = *h * scale;
if is_bga {
// Circle pad for BGA balls.
let r = (pw.min(ph)) / 2.0;
buf.push_str(&format!(
r#" <circle class="pad{extra}" cx="{cx:.1}" cy="{cy:.1}" r="{r:.1}"/>
"#));
} else if is_th {
// Oval / circle pad with drill hole for through-hole.
let r = (pw.min(ph)) / 2.0;
let drill_r = r * 0.55;
buf.push_str(&format!(
r#" <circle class="pad{extra}" cx="{cx:.1}" cy="{cy:.1}" r="{r:.1}"/>
<circle class="drill" cx="{cx:.1}" cy="{cy:.1}" r="{drill_r:.1}"/>
"#));
} else {
// Rect or roundrect pad.
let rx = if use_roundrect && !ep {
pw.min(ph) * 0.25
} else {
0.0
};
let rx_attr = if rx > 0.5 { format!(r#" rx="{rx:.1}""#) } else { String::new() };
buf.push_str(&format!(
r#" <rect class="pad{extra}" x="{:.1}" y="{:.1}" width="{pw:.1}" height="{ph:.1}"{rx_attr}/>
"#,
cx - pw / 2.0, cy - ph / 2.0,
));
}
// Pin number label.
buf.push_str(&format!(
r#" <text class="pin-num" x="{cx:.1}" y="{cy:.1}">{label}</text>
"#));
}
// Caption — branded as "ds2sf DATASHEET-DERIVED" (not "synthesized").
let caption = format!(
"{} — {} ({} pads, {:.2}×{:.2} mm)",
ctx.mpn, fp.package_name, fp.pad_count, body_x, body_y,
);
let stamp = "ds2sf — derived from manufacturer datasheet mechanical drawing";
buf.push_str(&format!(
r#" <text class="caption" x="20" y="{}">{caption}</text>
<text class="stamp" x="20" y="{}">{stamp}</text>
</svg>
"#,
svg_h - 30, svg_h - 12,
));
buf.into_bytes()
}
// ── per-family pad-layout heuristics ─────────────────────────────────────
type Pad = (String, f64, f64, f64, f64);
fn layout_qfn(pad_count: usize, body_x: f64, body_y: f64, lead: &serde_json::Value) -> Vec<Pad> {
// Distribute pad_count evenly across 4 sides.
let pads_per_side = pad_count / 4;
let pitch = lead.get("pitch_x").and_then(|v| v.as_f64())
.or_else(|| lead.get("pitch_y").and_then(|v| v.as_f64()))
.or_else(|| lead.get("pitch").and_then(|v| v.as_f64()))
.unwrap_or(0.5);
let pad_w = lead.get("pad_w").and_then(|v| v.as_f64()).unwrap_or(0.25);
let pad_h = lead.get("pad_h").and_then(|v| v.as_f64()).unwrap_or(0.65);
let mut pads = Vec::new();
let half_x = body_x / 2.0;
let half_y = body_y / 2.0;
let edge_offset = 0.2; // pad center 0.2 mm outside body edge
// QFN convention: pin 1 at top-left, going CCW.
let mut pin = 1usize;
// Left side: top → bottom
for i in 0..pads_per_side {
let y = -((pads_per_side as f64 - 1.0) / 2.0 - i as f64) * pitch;
pads.push((pin.to_string(), -(half_x + edge_offset), y, pad_h, pad_w));
pin += 1;
}
// Bottom side: left → right
for i in 0..pads_per_side {
let x = -((pads_per_side as f64 - 1.0) / 2.0 - i as f64) * pitch;
pads.push((pin.to_string(), x, half_y + edge_offset, pad_w, pad_h));
pin += 1;
}
// Right side: bottom → top
for i in 0..pads_per_side {
let y = ((pads_per_side as f64 - 1.0) / 2.0 - i as f64) * pitch;
pads.push((pin.to_string(), half_x + edge_offset, y, pad_h, pad_w));
pin += 1;
}
// Top side: right → left
for i in 0..pads_per_side {
let x = ((pads_per_side as f64 - 1.0) / 2.0 - i as f64) * pitch;
pads.push((pin.to_string(), x, -(half_y + edge_offset), pad_w, pad_h));
pin += 1;
}
// Center EP if pad_count > pads_per_side * 4.
if pad_count > pads_per_side * 4 {
let ep_size = (body_x.min(body_y) * 0.6).max(1.0);
pads.push((pin.to_string(), 0.0, 0.0, ep_size, ep_size));
}
pads
}
fn layout_dual_side(pad_count: usize, _body_x: f64, body_y: f64, lead: &serde_json::Value) -> Vec<Pad> {
let half = pad_count / 2;
// Claude reports pitch_x as pin-to-pin pitch within a row and pitch_y
// as the row-to-row span (or vice versa). The pin pitch is always the
// smaller value when both are present.
let pitch = {
let px = lead.get("pitch_x").and_then(|v| v.as_f64());
let py = lead.get("pitch_y").and_then(|v| v.as_f64());
let p = lead.get("pitch").and_then(|v| v.as_f64());
match (px, py, p) {
(Some(x), Some(y), _) => x.min(y),
(Some(x), None, _) => x,
(None, Some(y), _) => y,
(_, _, Some(p)) => p,
_ => 1.27,
}
};
let pad_w = lead.get("pad_w").and_then(|v| v.as_f64()).unwrap_or(0.6);
let pad_h = lead.get("pad_h").and_then(|v| v.as_f64()).unwrap_or(1.55);
let mut pads = Vec::new();
let half_y = body_y / 2.0;
let edge_offset = pad_h / 2.0 + 0.1;
// SOIC convention: pin 1 top-left, going CCW (down-left, then up-right).
for i in 0..half {
let y = -((half as f64 - 1.0) / 2.0 - i as f64) * pitch;
pads.push(((i + 1).to_string(), -(half_y + edge_offset), y, pad_h, pad_w));
}
for i in 0..half {
let y = ((half as f64 - 1.0) / 2.0 - i as f64) * pitch;
pads.push(((half + i + 1).to_string(), half_y + edge_offset, y, pad_h, pad_w));
}
pads
}
fn layout_sot(pad_count: usize, body_x: f64, body_y: f64) -> Vec<Pad> {
let pad_w = 0.6;
let pad_h = 0.5;
let pitch = 0.95;
let mut pads = Vec::new();
match pad_count {
3 => {
pads.push(("1".into(), -(body_x / 2.0 + 0.3), -pitch / 2.0, pad_h, pad_w));
pads.push(("2".into(), -(body_x / 2.0 + 0.3), pitch / 2.0, pad_h, pad_w));
pads.push(("3".into(), body_x / 2.0 + 0.3, 0.0, pad_h, pad_w));
}
5 => {
pads.push(("1".into(), -(body_x / 2.0 + 0.3), -pitch, pad_h, pad_w));
pads.push(("2".into(), -(body_x / 2.0 + 0.3), 0.0, pad_h, pad_w));
pads.push(("3".into(), -(body_x / 2.0 + 0.3), pitch, pad_h, pad_w));
pads.push(("4".into(), body_x / 2.0 + 0.3, pitch, pad_h, pad_w));
pads.push(("5".into(), body_x / 2.0 + 0.3, -pitch, pad_h, pad_w));
}
6 => {
for i in 0..3 {
let y = -pitch + (i as f64) * pitch;
pads.push(((i + 1).to_string(), -(body_x / 2.0 + 0.3), y, pad_h, pad_w));
}
for i in 0..3 {
let y = pitch - (i as f64) * pitch;
pads.push(((i + 4).to_string(), body_x / 2.0 + 0.3, y, pad_h, pad_w));
}
}
_ => return layout_dual_side(pad_count, body_x, body_y, &serde_json::Value::Null),
}
pads
}
fn layout_bga_lga(
pad_descriptions: &serde_json::Map<String, serde_json::Value>,
body_x: f64,
body_y: f64,
lead: &serde_json::Value,
is_bga: bool,
) -> Vec<Pad> {
// Parse A1, A2, B1 etc keys to determine grid dimensions.
let pitch = lead.get("pitch_x").and_then(|v| v.as_f64())
.or_else(|| lead.get("pitch").and_then(|v| v.as_f64()))
.unwrap_or(0.5);
let pad_size = lead.get("pad_w").and_then(|v| v.as_f64()).unwrap_or(0.25);
let mut max_row = 0u32;
let mut max_col = 0u32;
for k in pad_descriptions.keys() {
if let Some((row, col)) = parse_alpha_numeric_key(k) {
max_row = max_row.max(row);
max_col = max_col.max(col);
}
}
if max_row == 0 || max_col == 0 {
return Vec::new();
}
let mut pads = Vec::new();
let center_offset_x = ((max_col as f64 - 1.0) / 2.0) * pitch;
let center_offset_y = ((max_row as f64 - 1.0) / 2.0) * pitch;
for k in pad_descriptions.keys() {
if let Some((row, col)) = parse_alpha_numeric_key(k) {
let x = (col as f64 - 1.0) * pitch - center_offset_x;
let y = (row as f64 - 1.0) * pitch - center_offset_y;
pads.push((k.clone(), x, y, pad_size, pad_size));
}
}
// If the body is too small for the grid, the caller will scale up.
let _ = (body_x, body_y, is_bga);
pads
}
fn parse_alpha_numeric_key(k: &str) -> Option<(u32, u32)> {
// "A1" → (1, 1), "B7" → (2, 7), "C12" → (3, 12)
let mut chars = k.chars();
let row_char = chars.next()?;
if !row_char.is_ascii_alphabetic() { return None; }
let row = (row_char.to_ascii_uppercase() as u32) - ('A' as u32) + 1;
let col_str: String = chars.collect();
let col: u32 = col_str.parse().ok()?;
Some((row, col))
}
fn layout_dip(pad_count: usize, body_x: f64, _body_y: f64, lead: &serde_json::Value) -> Vec<Pad> {
let half = pad_count / 2;
let pitch = lead.get("pitch_y").and_then(|v| v.as_f64())
.or_else(|| lead.get("pitch").and_then(|v| v.as_f64()))
.unwrap_or(2.54);
let pad_size = 1.6;
let mut pads = Vec::new();
let half_x = body_x / 2.0 + 1.0;
for i in 0..half {
let y = -((half as f64 - 1.0) / 2.0 - i as f64) * pitch;
pads.push(((i + 1).to_string(), -half_x, y, pad_size, pad_size));
}
for i in 0..half {
let y = ((half as f64 - 1.0) / 2.0 - i as f64) * pitch;
pads.push(((half + i + 1).to_string(), half_x, y, pad_size, pad_size));
}
pads
}
fn layout_discrete(pad_count: usize, body_x: f64, _body_y: f64) -> Vec<Pad> {
let mut pads = Vec::new();
if pad_count == 2 {
let pad_w = body_x * 0.25;
let pad_h = 0.6;
pads.push(("1".into(), -body_x / 2.0 - pad_w / 2.0, 0.0, pad_w, pad_h));
pads.push(("2".into(), body_x / 2.0 + pad_w / 2.0, 0.0, pad_w, pad_h));
} else if pad_count == 4 {
// Kelvin-sense 4-terminal — render as dual-side with 2 pads each.
let pad_w = body_x * 0.2;
let pad_h = 0.5;
let py = 0.6;
pads.push(("1".into(), -body_x / 2.0 - pad_w / 2.0, -py, pad_w, pad_h));
pads.push(("2".into(), -body_x / 2.0 - pad_w / 2.0, py, pad_w, pad_h));
pads.push(("3".into(), body_x / 2.0 + pad_w / 2.0, py, pad_w, pad_h));
pads.push(("4".into(), body_x / 2.0 + pad_w / 2.0, -py, pad_w, pad_h));
}
pads
}
fn layout_connector(pad_count: usize, body_x: f64, body_y: f64, lead: &serde_json::Value) -> Vec<Pad> {
// Single-row connector: pads spaced at `pitch` along whichever axis has
// the larger pitch. Body's longer axis is the row direction when the
// pitch isn't reported. Pads numbered 1..N left-to-right (or top-to-bottom).
let pitch_x = lead.get("pitch_x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let pitch_y = lead.get("pitch_y").and_then(|v| v.as_f64()).unwrap_or(0.0);
let along_x = if pitch_x > 0.01 || pitch_y > 0.01 {
pitch_x >= pitch_y
} else {
body_x >= body_y
};
let pitch = if along_x && pitch_x > 0.01 {
pitch_x
} else if !along_x && pitch_y > 0.01 {
pitch_y
} else {
2.54
};
let pad_size = (pitch * 0.55).clamp(0.5, 1.6);
let n = pad_count as f64;
let mut pads = Vec::new();
for i in 0..pad_count {
let offset = ((i as f64) - (n - 1.0) / 2.0) * pitch;
let (x, y) = if along_x { (offset, 0.0) } else { (0.0, offset) };
pads.push(((i + 1).to_string(), x, y, pad_size, pad_size));
}
pads
}
fn layout_module(
pad_descriptions: &serde_json::Map<String, serde_json::Value>,
body_x: f64,
body_y: f64,
) -> Vec<Pad> {
// Heuristic: distribute pads as castellations along 3 sides (top, left,
// right). The fourth side (bottom) usually holds the antenna or RF
// shield. Pad numbers 1..N go around the perimeter starting from the
// top-left corner.
let n = pad_descriptions.len();
if n == 0 { return Vec::new(); }
let per_side = n / 3;
let pitch_top = body_x * 0.9 / (per_side as f64).max(1.0);
let pitch_lr = body_y * 0.9 / (per_side as f64).max(1.0);
let pad_w = pitch_top * 0.6;
let pad_h = 0.6;
let mut pads = Vec::new();
let mut keys: Vec<&String> = pad_descriptions.keys().collect();
keys.sort_by_key(|k| k.parse::<u32>().unwrap_or(u32::MAX));
for (i, key) in keys.iter().enumerate() {
let side = i / per_side.max(1);
let idx = (i % per_side.max(1)) as f64;
let (x, y, pw, ph) = match side {
0 => {
// Top side, left → right
let x = -body_x / 2.0 + idx * pitch_top + pitch_top / 2.0;
(x, -body_y / 2.0, pad_w, pad_h)
}
1 => {
// Right side, top → bottom
let y = -body_y / 2.0 + idx * pitch_lr + pitch_lr / 2.0;
(body_x / 2.0, y, pad_h, pad_w)
}
_ => {
// Bottom or left, depending on count
let x = body_x / 2.0 - idx * pitch_top - pitch_top / 2.0;
(x, body_y / 2.0, pad_w, pad_h)
}
};
pads.push(((*key).clone(), x, y, pw, ph));
}
pads
}