main
Colby Knox 1.1.0: every molecule gets large machine pins on the 32 mm grid; gallery rebuilt; corrected notes on kicad-to-molecule c794385 4d ago
//! SVG rendering of the preview model.
//!
//! `render` draws a dark top view, optionally with the molecule plan on top (wings,
//! the original outline dashed, MP1-MP4). `artwork` draws one side as transparent
//! artwork registered exactly to a bbox, for step2glb's top/bottom overlay.

use crate::model::{self, Board, Pad, PadShape, Seg, Shape, Side};
use crate::plan::{BBox, Plan};
use std::fmt::Write;

struct View {
    xmin: f64,
    ymax: f64,
    margin: f64,
}

impl View {
    fn p(&self, x: f64, y: f64) -> (f64, f64) {
        (x - self.xmin + self.margin, self.ymax - y + self.margin)
    }
}

fn n(v: f64) -> String {
    let s = format!("{v:.3}");
    let s = s.trim_end_matches('0').trim_end_matches('.');
    if s == "-0" { "0".into() } else { s.into() }
}

/// Sample an arc start-mid-end into points (start excluded).
fn arc_points(s: &Seg) -> Vec<(f64, f64)> {
    let Some(m) = s.mid else { return vec![(s.x2, s.y2)] };
    let Some((cx, cy, r)) = model::circle_from_3pts((s.x1, s.y1), m, (s.x2, s.y2)) else { return vec![m, (s.x2, s.y2)] };
    let tau = std::f64::consts::TAU;
    let a1 = (s.y1 - cy).atan2(s.x1 - cx);
    let am = (m.1 - cy).atan2(m.0 - cx);
    let a2 = (s.y2 - cy).atan2(s.x2 - cx);
    let ccw = |a: f64, b: f64| (b - a).rem_euclid(tau);
    let sweep = if ccw(a1, am) <= ccw(a1, a2) { ccw(a1, a2) } else { -ccw(a2, a1) };
    let k = ((sweep.abs() / tau * 64.0).ceil() as usize).max(2);
    (1..=k).map(|i| {
        let a = a1 + sweep * i as f64 / k as f64;
        (cx + r * a.cos(), cy + r * a.sin())
    }).collect()
}

/// Chain outline segments into closed loops; circles and polygons are loops already.
fn outline_loops(shapes: &[Shape]) -> Vec<Vec<(f64, f64)>> {
    let mut loops = vec![];
    let mut segs: Vec<Vec<(f64, f64)>> = vec![];
    for s in shapes {
        match s {
            Shape::Seg(g) => {
                let mut p = vec![(g.x1, g.y1)];
                p.extend(arc_points(g));
                segs.push(p);
            }
            Shape::Circle(c) => loops.push((0..64).map(|i| {
                let a = i as f64 / 64.0 * std::f64::consts::TAU;
                (c.cx + c.r * a.cos(), c.cy + c.r * a.sin())
            }).collect()),
            Shape::Poly(p) => loops.push(p.clone()),
        }
    }
    let close = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 0.05 && (a.1 - b.1).abs() < 0.05;
    while let Some(mut cur) = segs.pop() {
        loop {
            let end = *cur.last().unwrap();
            if cur.len() > 2 && close(end, cur[0]) {
                break;
            }
            let Some(i) = segs.iter().position(|s| close(s[0], end) || close(*s.last().unwrap(), end)) else { break };
            let mut s = segs.swap_remove(i);
            if !close(s[0], end) {
                s.reverse();
            }
            cur.extend(s.into_iter().skip(1));
        }
        loops.push(cur);
    }
    loops
}

fn path_d(v: &View, loops: &[Vec<(f64, f64)>]) -> String {
    let mut d = String::new();
    for l in loops {
        for (i, &(x, y)) in l.iter().enumerate() {
            let (px, py) = v.p(x, y);
            let _ = write!(d, "{}{} {} ", if i == 0 { "M" } else { "L" }, n(px), n(py));
        }
        d.push_str("Z ");
    }
    d
}

fn seg_d(v: &View, s: &Seg) -> String {
    let (x1, y1) = v.p(s.x1, s.y1);
    let mut d = format!("M{} {}", n(x1), n(y1));
    for (x, y) in arc_points(s) {
        let (px, py) = v.p(x, y);
        let _ = write!(d, " L{} {}", n(px), n(py));
    }
    d
}

fn shapes_svg(v: &View, shapes: &[Shape], color: &str, out: &mut String) {
    let mut d = String::new();
    for s in shapes {
        match s {
            Shape::Seg(g) => {
                let _ = write!(out, "<path d=\"{}\" stroke=\"{color}\" stroke-width=\"{}\" fill=\"none\" stroke-linecap=\"round\"/>", seg_d(v, g), n(g.width.max(0.08)));
            }
            Shape::Circle(c) => {
                let (x, y) = v.p(c.cx, c.cy);
                if c.fill {
                    let _ = write!(out, "<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{color}\"/>", n(x), n(y), n(c.r));
                } else {
                    let _ = write!(out, "<circle cx=\"{}\" cy=\"{}\" r=\"{}\" stroke=\"{color}\" stroke-width=\"{}\" fill=\"none\"/>", n(x), n(y), n(c.r), n(c.width.max(0.08)));
                }
            }
            Shape::Poly(p) => d.push_str(&path_d(v, &[p.clone()])),
        }
    }
    if !d.is_empty() {
        let _ = write!(out, "<path d=\"{d}\" fill=\"{color}\"/>");
    }
}

fn pad_svg(v: &View, p: &Pad, color: &str, out: &mut String) {
    let (x, y) = v.p(p.x, p.y);
    let rx = match p.shape {
        PadShape::Circle | PadShape::Oval => p.w.min(p.h) / 2.0,
        PadShape::RoundRect => p.w.min(p.h) * 0.25,
        PadShape::Rect => 0.0,
    };
    if p.shape == PadShape::Circle && (p.w - p.h).abs() < 1e-6 {
        let _ = write!(out, "<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{color}\"/>", n(x), n(y), n(p.w / 2.0));
    } else {
        let _ = write!(
            out,
            "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" rx=\"{}\" fill=\"{color}\"{}/>",
            n(x - p.w / 2.0), n(y - p.h / 2.0), n(p.w), n(p.h), n(rx),
            if p.rot.rem_euclid(360.0) != 0.0 { format!(" transform=\"rotate({} {} {})\"", n(-p.rot), n(x), n(y)) } else { String::new() }
        );
    }
    if p.drill > 0.0 {
        let _ = write!(out, "<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"#05070a\"/>", n(x), n(y), n(p.drill / 2.0));
    }
}

fn copper(b: &Board, side: Side, v: &View, pour: &str, track: &str, pad: &str, out: &mut String) {
    let (pours, tracks) = match side {
        Side::Top => (&b.pours_top, &b.tracks_top),
        Side::Bottom => (&b.pours_bottom, &b.tracks_bottom),
    };
    if !pours.is_empty() {
        let _ = write!(out, "<path d=\"{}\" fill=\"{pour}\" fill-rule=\"evenodd\"/>", path_d(v, pours));
    }
    for t in tracks {
        let _ = write!(out, "<path d=\"{}\" stroke=\"{track}\" stroke-width=\"{}\" fill=\"none\" stroke-linecap=\"round\"/>", seg_d(v, t), n(t.width));
    }
    for p in b.all_pads().filter(|p| p.drill > 0.0 || p.side == side) {
        pad_svg(v, p, pad, out);
    }
    for via in &b.vias {
        let (x, y) = v.p(via.x, via.y);
        let _ = write!(out, "<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{pad}\"/><circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"#05070a\"/>", n(x), n(y), n(via.dia / 2.0), n(x), n(y), n(via.drill / 2.0));
    }
}

/// Dark top view; with a plan, the molecule overlay.
pub fn render(b: &Board, plan: Option<&Plan>) -> String {
    let ob = b.outline_bbox().unwrap_or(BBox { xmin: 0.0, ymin: 0.0, xmax: 10.0, ymax: 10.0 });
    let frame = plan.map(|p| p.new).unwrap_or(ob);
    let margin = (frame.w().max(frame.h()) * 0.06).max(2.0);
    let v = View { xmin: frame.xmin, ymax: frame.ymax, margin };
    let (w, h) = (frame.w() + 2.0 * margin, frame.h() + 2.0 * margin);
    let mut s = format!(
        "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {} {}\" width=\"{}mm\" height=\"{}mm\" font-family=\"Inter, system-ui, sans-serif\">",
        n(w), n(h), n(w), n(h)
    );
    let _ = write!(s, "<rect width=\"100%\" height=\"100%\" fill=\"#0b0f14\"/>");
    let loops = outline_loops(&b.outline);
    if let Some(p) = plan {
        let (x, y) = v.p(p.new.xmin, p.new.ymax);
        let _ = write!(s, "<defs><pattern id=\"wing\" width=\"1.6\" height=\"1.6\" patternUnits=\"userSpaceOnUse\" patternTransform=\"rotate(45)\"><rect width=\"1.6\" height=\"1.6\" fill=\"#0e3b36\"/><rect width=\"0.5\" height=\"1.6\" fill=\"#13524b\"/></pattern></defs>");
        let _ = write!(s, "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"url(#wing)\" stroke=\"#2dd4bf\" stroke-width=\"{}\"/>", n(x), n(y), n(p.new.w()), n(p.new.h()), n(margin * 0.06));
    }
    let _ = write!(s, "<path d=\"{}\" fill=\"#123d2a\" fill-rule=\"evenodd\"/>", path_d(&v, &loops));
    copper(b, Side::Bottom, &v, "#17402f", "#1d4a37", "#6b5a2a", &mut s);
    copper(b, Side::Top, &v, "#1c5a3d", "#2a7a52", "#d9a93a", &mut s);
    shapes_svg(&v, &b.silk_top, "#e8eef4", &mut s);
    if let Some(p) = plan {
        let sw = margin * 0.05;
        let _ = write!(s, "<path d=\"{}\" fill=\"none\" stroke=\"#f59e0b\" stroke-width=\"{}\" stroke-dasharray=\"{} {}\"/>", path_d(&v, &loops), n(sw), n(sw * 4.0), n(sw * 3.0));
        for l in p.vcut_lines().iter().filter(|_| p.vcut) {
            let (a, c) = (v.p(l[0], l[1]), v.p(l[2], l[3]));
            let _ = write!(s, "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#f97316\" stroke-width=\"{}\" stroke-dasharray=\"{} {}\"/>", n(a.0), n(a.1), n(c.0), n(c.1), n(sw), n(sw * 2.0), n(sw * 2.0));
        }
        let sp = p.spec();
        let fs = sp.pad_mm * 0.45;
        for pin in &p.pins {
            let (x, y) = v.p(pin.x, pin.y);
            let _ = write!(s, "<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"#f5c542\" stroke=\"#fff3c4\" stroke-width=\"{}\"/><circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"#05070a\"/>",
                n(x), n(y), n(sp.pad_mm / 2.0), n(sp.pad_mm * 0.06), n(x), n(y), n(sp.drill_mm / 2.0));
            // Label beside the pin, toward the board's middle.
            let left = pin.role.ends_with("left");
            let dx = if left { sp.pad_mm / 2.0 + fs * 0.35 } else { -(sp.pad_mm / 2.0 + fs * 0.35) };
            let anchor = if left { "start" } else { "end" };
            let _ = write!(s, "<text x=\"{}\" y=\"{}\" font-size=\"{}\" fill=\"#f5c542\" font-weight=\"600\" text-anchor=\"{anchor}\">{}</text>", n(x + dx), n(y + fs * 0.35), n(fs), pin.r#ref);
        }
    }
    s.push_str("</svg>");
    s
}

/// One side as transparent artwork covering exactly `bbox` at `px_per_mm`.
/// Copper shows gold where it is exposed (pads, vias), traces faintly, silk white.
pub fn artwork(b: &Board, side: Side, bbox: &BBox, px_per_mm: f64) -> String {
    let v = View { xmin: bbox.xmin, ymax: bbox.ymax, margin: 0.0 };
    // The bottom is seen from below: mirror X so it reads correctly from that side.
    let mirror = if side == Side::Bottom { format!(" transform=\"translate({} 0) scale(-1 1)\"", n(bbox.w())) } else { String::new() };
    let mut s = format!(
        "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {} {}\" width=\"{}\" height=\"{}\"><g{mirror}>",
        n(bbox.w()), n(bbox.h()), (bbox.w() * px_per_mm).round(), (bbox.h() * px_per_mm).round()
    );
    copper(b, side, &v, "rgba(80,170,120,0.28)", "rgba(110,200,150,0.55)", "#e3b04b", &mut s);
    let silk = if side == Side::Top { &b.silk_top } else { &b.silk_bottom };
    shapes_svg(&v, silk, "#ffffff", &mut s);
    s.push_str("</g></svg>");
    s
}