//! Self-render a `.kicad_mod` to a themed footprint SVG — every layer in its own
//! toggleable `<g>`, in the ORIGINAL adom-footprint palette (ported faithfully
//! from gallia `kicad-footprint-viewer.js`). No service-kicad dependency, so
//! silkscreen, drill holes and pad numbers come back and the colours are exact:
//!   Courtyard #FF26E2 (magenta, dashed) · Fab #6e7681 (grey) · Silk #00cccc
//!   (cyan) · Copper pads #C83434/#ef5350 (red) · Drill dark w/ #666 ring · white
//!   pad numbers. Paste apertures + the InstaPCB dots layer on top of all of it.

use regex::Regex;
use std::fmt::Write as _;

#[derive(Clone)]
pub struct Pad {
    pub number: String,
    pub mount: String, // smd | thru_hole | np_thru_hole
    pub shape: String, // rect | circle | oval | roundrect | custom | trapezoid
    pub x: f64,
    pub y: f64,
    pub rot: f64,
    pub w: f64,
    pub h: f64,
    pub rratio: f64,
    pub drill: f64,
}
struct Seg { x1: f64, y1: f64, x2: f64, y2: f64 }
struct Rect { x1: f64, y1: f64, x2: f64, y2: f64 }
struct Poly { pts: Vec<(f64, f64)> }
struct Circle { cx: f64, cy: f64, r: f64, filled: bool }

pub struct Footprint {
    pub pads: Vec<Pad>,
    courtyard_lines: Vec<Seg>,
    courtyard_rects: Vec<Rect>,
    courtyard_arcs: Vec<Poly>,
    courtyard_circles: Vec<Circle>,
    fab_lines: Vec<Seg>,
    fab_rects: Vec<Rect>,
    fab_polys: Vec<Poly>,
    fab_arcs: Vec<Poly>,
    fab_circles: Vec<Circle>,
    silk_lines: Vec<Seg>,
    silk_polys: Vec<Poly>,
    silk_arcs: Vec<Poly>,
    silk_circles: Vec<Circle>,
}

/// Sample a KiCad *legacy* arc — `(start=CENTER) (end=ARC-START-POINT) (angle …)` —
/// into a polyline. Robust regardless of stroke flags; 1 segment per ~10°.
fn arc_from_legacy(cx: f64, cy: f64, sx: f64, sy: f64, angle_deg: f64) -> Vec<(f64, f64)> {
    let r = ((sx - cx).powi(2) + (sy - cy).powi(2)).sqrt();
    let a0 = (sy - cy).atan2(sx - cx);
    let sweep = angle_deg.to_radians();
    let n = ((angle_deg.abs() / 10.0).ceil() as usize).clamp(2, 64);
    (0..=n).map(|i| { let a = a0 + sweep * (i as f64 / n as f64); (cx + r * a.cos(), cy + r * a.sin()) }).collect()
}

/// Sample a KiCad *modern* three-point arc — `(start) (mid) (end)` — into a
/// polyline. Finds the circumcircle, then sweeps start→end through mid.
fn arc_from_3pt(sx: f64, sy: f64, mx: f64, my: f64, ex: f64, ey: f64) -> Vec<(f64, f64)> {
    use std::f64::consts::PI;
    let d = 2.0 * (sx * (my - ey) + mx * (ey - sy) + ex * (sy - my));
    if d.abs() < 1e-9 { return vec![(sx, sy), (ex, ey)]; } // collinear → straight
    let (s2, m2, e2) = (sx * sx + sy * sy, mx * mx + my * my, ex * ex + ey * ey);
    let cx = (s2 * (my - ey) + m2 * (ey - sy) + e2 * (sy - my)) / d;
    let cy = (s2 * (ex - mx) + m2 * (sx - ex) + e2 * (mx - sx)) / d;
    let r = ((sx - cx).powi(2) + (sy - cy).powi(2)).sqrt();
    let a0 = (sy - cy).atan2(sx - cx);
    let am = (my - cy).atan2(mx - cx);
    let a1 = (ey - cy).atan2(ex - cx);
    let n2pi = |a: f64| { let mut a = a % (2.0 * PI); if a < 0.0 { a += 2.0 * PI; } a };
    let sweep_ccw = n2pi(a1 - a0);
    let mid_ccw = n2pi(am - a0);
    let sweep = if mid_ccw <= sweep_ccw { sweep_ccw } else { sweep_ccw - 2.0 * PI };
    let n = ((sweep.abs().to_degrees() / 10.0).ceil() as usize).clamp(2, 64);
    (0..=n).map(|i| { let a = a0 + sweep * (i as f64 / n as f64); (cx + r * a.cos(), cy + r * a.sin()) }).collect()
}

/// Balanced-paren slice of every `(<tag> …)` block.
fn blocks<'a>(s: &'a str, tag: &str) -> Vec<&'a str> {
    let bytes = s.as_bytes();
    let pat = format!("({} ", tag);
    let mut out = Vec::new();
    let mut search = 0;
    while let Some(rel) = s[search..].find(&pat) {
        let start = search + rel;
        let mut depth = 0i32;
        let mut end = start;
        for i in start..s.len() {
            match bytes[i] {
                b'(' => depth += 1,
                b')' => { depth -= 1; if depth == 0 { end = i; break; } }
                _ => {}
            }
        }
        out.push(&s[start..=end.min(s.len() - 1)]);
        search = end + 1;
    }
    out
}

fn layer_of(block: &str, re: &Regex) -> String {
    re.captures(block).map(|c| c[1].to_string()).unwrap_or_default()
}

pub fn parse(content: &str) -> Footprint {
    let at_re = Regex::new(r"\(at\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s+(-?[\d.]+))?\)").unwrap();
    let size_re = Regex::new(r"\(size\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
    let drill_re = Regex::new(r"\(drill\s+(-?[\d.]+)").unwrap();
    let rr_re = Regex::new(r"\(roundrect_rratio\s+([\d.]+)\)").unwrap();
    // Accept BOTH quoted `(layer "F.SilkS")` and bare `(layer F.SilkS)` — vendor /
    // manufacturer footprints (and older KiCad) write the layer name unquoted, and
    // requiring quotes silently dropped all their silk/courtyard/fab geometry.
    let layer_re = Regex::new(r##"\(layer\s+"?([^"()\s]+)"?\s*\)"##).unwrap();
    let se_re = Regex::new(r"\(start\s+(-?[\d.]+)\s+(-?[\d.]+)\)\s*\(end\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
    // Pad number may be quoted ("13") or bare (13, older KiCad) — accept both.
    let pad_head = Regex::new(r##"\(pad\s+"?([^"\s]+)"?\s+(\w+)\s+(\w+)"##).unwrap();
    let xy_re = Regex::new(r"\(xy\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
    let start_re = Regex::new(r"\(start\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
    let end_re = Regex::new(r"\(end\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
    let mid_re = Regex::new(r"\(mid\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
    let angle_re = Regex::new(r"\(angle\s+(-?[\d.]+)\)").unwrap();
    let center_re = Regex::new(r"\(center\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();

    let mut fp = Footprint {
        pads: vec![], courtyard_lines: vec![], courtyard_rects: vec![],
        courtyard_arcs: vec![], courtyard_circles: vec![],
        fab_lines: vec![], fab_rects: vec![], fab_polys: vec![],
        fab_arcs: vec![], fab_circles: vec![],
        silk_lines: vec![], silk_polys: vec![],
        silk_arcs: vec![], silk_circles: vec![],
    };

    // ── Pads ──
    for b in blocks(content, "pad") {
        let head = match pad_head.captures(b) { Some(h) => h, None => continue };
        let number = head[1].to_string();
        if number.is_empty() { continue; }
        let (at, size) = match (at_re.captures(b), size_re.captures(b)) { (Some(a), Some(s)) => (a, s), _ => continue };
        fp.pads.push(Pad {
            number,
            mount: head[2].to_string(),
            shape: head[3].to_string(),
            x: at[1].parse().unwrap_or(0.0),
            y: at[2].parse().unwrap_or(0.0),
            rot: at.get(3).and_then(|m| m.as_str().parse().ok()).unwrap_or(0.0),
            w: size[1].parse().unwrap_or(0.0),
            h: size[2].parse().unwrap_or(0.0),
            rratio: rr_re.captures(b).and_then(|c| c[1].parse().ok()).unwrap_or(0.25),
            drill: drill_re.captures(b).and_then(|c| c[1].parse().ok()).unwrap_or(0.0),
        });
    }
    // ── fp_line by layer ──
    for b in blocks(content, "fp_line") {
        let cap = match se_re.captures(b) { Some(c) => c, None => continue };
        let seg = Seg { x1: cap[1].parse().unwrap_or(0.0), y1: cap[2].parse().unwrap_or(0.0), x2: cap[3].parse().unwrap_or(0.0), y2: cap[4].parse().unwrap_or(0.0) };
        match layer_of(b, &layer_re).as_str() {
            "F.CrtYd" => fp.courtyard_lines.push(seg),
            "F.Fab" => fp.fab_lines.push(seg),
            "F.SilkS" => fp.silk_lines.push(seg),
            _ => {}
        }
    }
    // ── fp_rect by layer ──
    for b in blocks(content, "fp_rect") {
        let cap = match se_re.captures(b) { Some(c) => c, None => continue };
        let r = Rect { x1: cap[1].parse().unwrap_or(0.0), y1: cap[2].parse().unwrap_or(0.0), x2: cap[3].parse().unwrap_or(0.0), y2: cap[4].parse().unwrap_or(0.0) };
        match layer_of(b, &layer_re).as_str() {
            "F.CrtYd" => fp.courtyard_rects.push(r),
            "F.Fab" => fp.fab_rects.push(r),
            _ => {}
        }
    }
    // ── fp_poly by layer ──
    for b in blocks(content, "fp_poly") {
        let pts: Vec<(f64, f64)> = xy_re.captures_iter(b).map(|c| (c[1].parse().unwrap_or(0.0), c[2].parse().unwrap_or(0.0))).collect();
        if pts.is_empty() { continue; }
        match layer_of(b, &layer_re).as_str() {
            "F.Fab" => fp.fab_polys.push(Poly { pts }),
            "F.SilkS" => fp.silk_polys.push(Poly { pts }),
            _ => {}
        }
    }
    // ── fp_arc by layer (sampled to a polyline) ──
    for b in blocks(content, "fp_arc") {
        let (s, e) = match (start_re.captures(b), end_re.captures(b)) { (Some(s), Some(e)) => (s, e), _ => continue };
        let (sx, sy) = (s[1].parse().unwrap_or(0.0), s[2].parse().unwrap_or(0.0));
        let (ex, ey) = (e[1].parse().unwrap_or(0.0), e[2].parse().unwrap_or(0.0));
        let pts = if let Some(m) = mid_re.captures(b) {
            arc_from_3pt(sx, sy, m[1].parse().unwrap_or(0.0), m[2].parse().unwrap_or(0.0), ex, ey)
        } else if let Some(a) = angle_re.captures(b) {
            // legacy: start = centre, end = a point on the arc
            arc_from_legacy(sx, sy, ex, ey, a[1].parse().unwrap_or(0.0))
        } else { vec![(sx, sy), (ex, ey)] };
        match layer_of(b, &layer_re).as_str() {
            "F.CrtYd" => fp.courtyard_arcs.push(Poly { pts }),
            "F.Fab" => fp.fab_arcs.push(Poly { pts }),
            "F.SilkS" => fp.silk_arcs.push(Poly { pts }),
            _ => {}
        }
    }
    // ── fp_circle by layer (center + a point on the circle) ──
    for b in blocks(content, "fp_circle") {
        let (c, e) = match (center_re.captures(b), end_re.captures(b)) { (Some(c), Some(e)) => (c, e), _ => continue };
        let (cx, cy): (f64, f64) = (c[1].parse().unwrap_or(0.0), c[2].parse().unwrap_or(0.0));
        let (ex, ey): (f64, f64) = (e[1].parse().unwrap_or(0.0), e[2].parse().unwrap_or(0.0));
        let r = ((ex - cx).powi(2) + (ey - cy).powi(2)).sqrt();
        let filled = b.contains("(fill solid)") || b.contains("(fill yes)");
        let circ = Circle { cx, cy, r, filled };
        match layer_of(b, &layer_re).as_str() {
            "F.CrtYd" => fp.courtyard_circles.push(circ),
            "F.Fab" => fp.fab_circles.push(circ),
            "F.SilkS" => fp.silk_circles.push(circ),
            _ => {}
        }
    }
    fp
}

fn esc(s: &str) -> String { s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;") }

/// Render the footprint to a themed SVG with per-layer `<g class="adom-layer-…">`
/// groups. `ox,oy` is unused here (geometry is in footprint-local mm, the viewBox
/// is computed from it); kept for signature parity with the paste overlay.
pub fn render(fp: &Footprint) -> (String, f64, f64) {
    // bounds over everything
    let mut pts: Vec<(f64, f64)> = vec![];
    for p in &fp.pads { pts.push((p.x - p.w / 2.0, p.y - p.h / 2.0)); pts.push((p.x + p.w / 2.0, p.y + p.h / 2.0)); }
    for s in fp.courtyard_lines.iter().chain(fp.fab_lines.iter()).chain(fp.silk_lines.iter()) { pts.push((s.x1, s.y1)); pts.push((s.x2, s.y2)); }
    for r in fp.courtyard_rects.iter().chain(fp.fab_rects.iter()) { pts.push((r.x1, r.y1)); pts.push((r.x2, r.y2)); }
    for poly in fp.fab_polys.iter().chain(fp.silk_polys.iter())
        .chain(fp.courtyard_arcs.iter()).chain(fp.fab_arcs.iter()).chain(fp.silk_arcs.iter()) {
        for &pt in &poly.pts { pts.push(pt); }
    }
    for c in fp.courtyard_circles.iter().chain(fp.fab_circles.iter()).chain(fp.silk_circles.iter()) {
        pts.push((c.cx - c.r, c.cy - c.r)); pts.push((c.cx + c.r, c.cy + c.r));
    }
    if pts.is_empty() { pts.push((-5.0, -5.0)); pts.push((5.0, 5.0)); }
    let margin = 1.0;
    let min_x = pts.iter().map(|p| p.0).fold(f64::INFINITY, f64::min) - margin;
    let min_y = pts.iter().map(|p| p.1).fold(f64::INFINITY, f64::min) - margin;
    let max_x = pts.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max) + margin;
    let max_y = pts.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max) + margin;
    let (w, h) = (max_x - min_x, max_y - min_y);

    let mut s = String::new();
    let _ = write!(s, r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{:.3} {:.3} {:.3} {:.3}" preserveAspectRatio="xMidYMid meet">"##, min_x, min_y, w, h);

    // ── Courtyard (magenta, dashed) ──
    s.push_str(r##"<g class="adom-layer-courtyard">"##);
    for l in &fp.courtyard_lines { let _ = write!(s, r##"<line x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="#FF26E2" stroke-width="0.05" stroke-dasharray="0.2,0.1"/>"##, l.x1, l.y1, l.x2, l.y2); }
    for r in &fp.courtyard_rects { let _ = write!(s, r##"<rect x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="none" stroke="#FF26E2" stroke-width="0.05" stroke-dasharray="0.2,0.1"/>"##, r.x1.min(r.x2), r.y1.min(r.y2), (r.x2 - r.x1).abs(), (r.y2 - r.y1).abs()); }
    for a in &fp.courtyard_arcs { let _ = write!(s, r##"<path d="{}" fill="none" stroke="#FF26E2" stroke-width="0.05" stroke-dasharray="0.2,0.1"/>"##, polyline_d(&a.pts)); }
    for c in &fp.courtyard_circles { let _ = write!(s, r##"<circle cx="{:.3}" cy="{:.3}" r="{:.3}" fill="none" stroke="#FF26E2" stroke-width="0.05" stroke-dasharray="0.2,0.1"/>"##, c.cx, c.cy, c.r); }
    s.push_str("</g>");

    // ── Fab outline (grey) ──
    s.push_str(r##"<g class="adom-layer-fab">"##);
    for l in &fp.fab_lines { let _ = write!(s, r##"<line x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="#6e7681" stroke-width="0.1"/>"##, l.x1, l.y1, l.x2, l.y2); }
    for r in &fp.fab_rects { let _ = write!(s, r##"<rect x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="none" stroke="#6e7681" stroke-width="0.1"/>"##, r.x1.min(r.x2), r.y1.min(r.y2), (r.x2 - r.x1).abs(), (r.y2 - r.y1).abs()); }
    for poly in &fp.fab_polys { let d = poly_d(&poly.pts); let _ = write!(s, r##"<path d="{}" fill="none" stroke="#6e7681" stroke-width="0.1"/>"##, d); }
    for a in &fp.fab_arcs { let _ = write!(s, r##"<path d="{}" fill="none" stroke="#6e7681" stroke-width="0.1"/>"##, polyline_d(&a.pts)); }
    for c in &fp.fab_circles { let _ = write!(s, r##"<circle cx="{:.3}" cy="{:.3}" r="{:.3}" fill="{}" stroke="#6e7681" stroke-width="0.1"/>"##, c.cx, c.cy, c.r, if c.filled { "#6e7681" } else { "none" }); }
    s.push_str("</g>");

    // ── Copper pads (red) ──
    s.push_str(r##"<g class="adom-layer-copper">"##);
    for p in &fp.pads {
        let (hw, hh) = (p.w / 2.0, p.h / 2.0);
        let body = match p.shape.as_str() {
            "circle" => format!(r##"<circle cx="{:.3}" cy="{:.3}" r="{:.3}" fill="#C83434" stroke="#ef5350" stroke-width="0.04"/>"##, p.x, p.y, hw.min(hh)),
            "oval" => format!(r##"<rect x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" rx="{:.3}" fill="#C83434" stroke="#ef5350" stroke-width="0.04"/>"##, p.x - hw, p.y - hh, p.w, p.h, hw.min(hh)),
            "roundrect" => format!(r##"<rect x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" rx="{:.3}" fill="#C83434" stroke="#ef5350" stroke-width="0.04"/>"##, p.x - hw, p.y - hh, p.w, p.h, p.rratio * hw.min(hh) * 2.0),
            _ => format!(r##"<rect x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="#C83434" stroke="#ef5350" stroke-width="0.04"/>"##, p.x - hw, p.y - hh, p.w, p.h),
        };
        if p.rot.abs() > 0.01 {
            let _ = write!(s, r##"<g transform="rotate({:.3} {:.3} {:.3})" data-pad="{}" data-shape="{}" data-size="{:.3}x{:.3}" data-mount="{}">{}</g>"##, -p.rot, p.x, p.y, esc(&p.number), esc(&p.shape), p.w, p.h, esc(&p.mount), body);
        } else {
            let _ = write!(s, r##"<g data-pad="{}" data-shape="{}" data-size="{:.3}x{:.3}" data-mount="{}">{}</g>"##, esc(&p.number), esc(&p.shape), p.w, p.h, esc(&p.mount), body);
        }
    }
    s.push_str("</g>");

    // ── Heatsink / thermal vias (auto-generated under an exposed thermal pad) ──
    // The array an EE would otherwise hand-draw: a copper ring + drilled hole per
    // via, at IPC-typical pitch, tiled across the exposed pad. Rendered on top of
    // the pad copper so it reads as real thermal relief.
    s.push_str(r##"<g class="adom-layer-heatsink">"##);
    for (vx, vy) in heatsink_vias(&fp.pads) {
        let _ = write!(s, r##"<circle cx="{:.3}" cy="{:.3}" r="0.30" fill="#8a5a2b" stroke="#e8a13a" stroke-width="0.05"/><circle cx="{:.3}" cy="{:.3}" r="0.15" fill="#0d1117"/>"##, vx, vy, vx, vy);
    }
    s.push_str("</g>");

    // ── Drill holes (dark with grey ring) ──
    s.push_str(r##"<g class="adom-layer-drill">"##);
    for p in &fp.pads {
        if p.mount == "thru_hole" || p.mount == "np_thru_hole" {
            let r = if p.drill > 0.0 { p.drill / 2.0 } else { (p.w / 2.0).min(p.h / 2.0) * 0.5 };
            let _ = write!(s, r##"<circle cx="{:.3}" cy="{:.3}" r="{:.3}" fill="#0d1117" stroke="#666" stroke-width="0.05"/>"##, p.x, p.y, r);
        }
    }
    s.push_str("</g>");

    // ── Silkscreen (cyan) ──
    s.push_str(r##"<g class="adom-layer-silk">"##);
    for l in &fp.silk_lines { let _ = write!(s, r##"<line x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="#00cccc" stroke-width="0.12" stroke-linecap="round"/>"##, l.x1, l.y1, l.x2, l.y2); }
    for poly in &fp.silk_polys { let d = poly_d(&poly.pts); let _ = write!(s, r##"<path d="{}" fill="#00cccc" stroke="#00cccc" stroke-width="0.1"/>"##, d); }
    for a in &fp.silk_arcs { let _ = write!(s, r##"<path d="{}" fill="none" stroke="#00cccc" stroke-width="0.12" stroke-linecap="round"/>"##, polyline_d(&a.pts)); }
    for c in &fp.silk_circles { let _ = write!(s, r##"<circle cx="{:.3}" cy="{:.3}" r="{:.3}" fill="{}" stroke="#00cccc" stroke-width="0.12"/>"##, c.cx, c.cy, c.r, if c.filled { "#00cccc" } else { "none" }); }
    s.push_str("</g>");

    // ── Pad numbers (white) ──
    s.push_str(r##"<g class="adom-layer-padnum">"##);
    for p in &fp.pads {
        let fs = p.w.min(p.h) * 0.42;
        if fs > 0.12 {
            let _ = write!(s, r##"<text x="{:.3}" y="{:.3}" text-anchor="middle" dominant-baseline="central" font-size="{:.3}" fill="#fff" font-family="sans-serif" style="pointer-events:none">{}</text>"##, p.x, p.y, fs, esc(&p.number));
        }
    }
    s.push_str("</g>");

    s.push_str("</svg>");
    // origin for the paste overlay: footprint-local (ax,ay) maps to (ax,ay) here
    (s, 0.0, 0.0)
}

/// Auto-generate a thermal-via grid under the exposed pad — the "heatsink" an EE
/// would otherwise hand-draw. Finds the exposed pad (the largest ~square SMD pad
/// ≥1.8 mm on a side) and tiles it with vias at IPC-typical 1.2 mm pitch, inset so
/// each 0.6 mm via ring stays inside the pad. Returns via centres in footprint mm;
/// empty when there is no exposed pad (so most parts show no heatsink layer).
fn heatsink_vias(pads: &[Pad]) -> Vec<(f64, f64)> {
    let ep = pads.iter()
        .filter(|p| p.mount == "smd" && p.w.min(p.h) >= 1.8 && {
            let a = p.w / p.h.max(0.001);
            (0.6..=1.67).contains(&a)
        })
        .max_by(|a, b| (a.w * a.h).partial_cmp(&(b.w * b.h)).unwrap_or(std::cmp::Ordering::Equal));
    let ep = match ep { Some(p) => p, None => return Vec::new() };
    let pitch = 1.2_f64;
    let margin = 0.6 / 2.0 + 0.15; // via-ring radius + clearance from the pad edge
    let span = |dim: f64| (dim - 2.0 * margin).max(0.0);
    let nx = ((span(ep.w) / pitch).floor() as i32 + 1).max(1);
    let ny = ((span(ep.h) / pitch).floor() as i32 + 1).max(1);
    let x0 = ep.x - (nx as f64 - 1.0) * pitch / 2.0;
    let y0 = ep.y - (ny as f64 - 1.0) * pitch / 2.0;
    let mut out = Vec::with_capacity((nx * ny) as usize);
    for j in 0..ny {
        for i in 0..nx {
            out.push((x0 + i as f64 * pitch, y0 + j as f64 * pitch));
        }
    }
    out
}

fn poly_d(pts: &[(f64, f64)]) -> String {
    let mut d = String::new();
    for (i, &(x, y)) in pts.iter().enumerate() {
        let _ = write!(d, "{}{:.3},{:.3} ", if i == 0 { "M" } else { "L" }, x, y);
    }
    d.push('Z');
    d
}

/// Like `poly_d` but leaves the path OPEN (no `Z`) — for arcs/polylines.
fn polyline_d(pts: &[(f64, f64)]) -> String {
    let mut d = String::new();
    for (i, &(x, y)) in pts.iter().enumerate() {
        let _ = write!(d, "{}{:.3},{:.3} ", if i == 0 { "M" } else { "L" }, x, y);
    }
    d
}