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
}