app
Board to Molecule
Public Made by Adomby adom
Turn any KiCad, Fusion/EAGLE, or Altium board into an Adom molecule: wings plus MP1-MP4 machine pins, written back in the board's own format.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
//! 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
}