app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
Colby Knox
Merge PR #324: kicad-lane: 1.0.74, generate_body and plan_route from engine 0.6.0
e14cd83
13d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
//! generate_body (EDA Bridge Standard #20, from the Altium bridge's body3d.rs): a part's 3D body from its package family and datasheet dimensions, to IPC-7351 proportions, written
//! as a STEP file (AP214 boundary representation, mm, coloured body and leads). Pure Rust: no CAD tool is needed.
//! Bodies are made of prisms (a planar outline swept along an axis), which is all these packages need; a round can or
//! ball is a many-sided prism. The package sits in its own frame: IPC-7351 zero orientation, seated on z = 0. No I/O:
//! the reply carries the STEP text, and the bridge writes the file.
use serde_json::{json, Map, Value};
type P3 = [f64; 3];
/// A solid as its faces, each a planar polygon listed counter-clockwise seen from outside.
#[derive(Clone)]
pub struct Solid { pub faces: Vec<Vec<P3>>, pub lead: bool, pub mark: bool }
/// A prism: `outline` (counter-clockwise in its own u,v plane) swept from w0 to w1, placed by `map(u, v, w)`.
fn prism(outline: &[(f64, f64)], w0: f64, w1: f64, lead: bool, map: impl Fn(f64, f64, f64) -> P3) -> Solid {
let n = outline.len();
let bot: Vec<P3> = outline.iter().rev().map(|&(u, v)| map(u, v, w0)).collect();
let top: Vec<P3> = outline.iter().map(|&(u, v)| map(u, v, w1)).collect();
let mut faces = vec![bot, top];
for i in 0..n {
let (a, b) = (outline[i], outline[(i + 1) % n]);
faces.push(vec![map(a.0, a.1, w0), map(b.0, b.1, w0), map(b.0, b.1, w1), map(a.0, a.1, w1)]);
}
let mut s = Solid { faces, lead, mark: false };
// a map that mirrors (an odd number of axis flips) turns every face inside out: put them right
if signed_volume(&s) < 0.0 { for f in &mut s.faces { f.reverse(); } }
s
}
fn signed_volume(s: &Solid) -> f64 {
s.faces.iter().map(|f| (1..f.len() - 1).map(|i| { let (a, b, c) = (f[0], f[i], f[i + 1]); a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0]) }).sum::<f64>()).sum::<f64>() / 6.0
}
fn rect(w: f64, h: f64) -> Vec<(f64, f64)> { vec![(-w / 2.0, -h / 2.0), (w / 2.0, -h / 2.0), (w / 2.0, h / 2.0), (-w / 2.0, h / 2.0)] }
fn ngon(r: f64, n: usize) -> Vec<(f64, f64)> { (0..n).map(|i| { let a = 2.0 * std::f64::consts::PI * (i as f64 + 0.5) / n as f64; (r * a.cos(), r * a.sin()) }).collect() }
/// A box from (x0, y0, z0) to (x1, y1, z1).
fn bx(x0: f64, y0: f64, z0: f64, x1: f64, y1: f64, z1: f64, lead: bool) -> Solid {
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
prism(&rect((x1 - x0).abs(), (y1 - y0).abs()), z0, z1, lead, |u, v, w| [cx + u, cy + v, w])
}
/// A gull-wing lead running outward along +x from the body edge at x = x0 to the tip at x = x1, centred on y, width b,
/// thickness t, shoulder at height zs, foot of length l on the seating plane.
fn gull(x0: f64, x1: f64, y: f64, b: f64, t: f64, zs: f64, l: f64, sign: f64) -> Solid {
let knee = (x1 - l).max(x0 + t * 1.5);
let mid = x0 + (knee - x0) * 0.5;
// the profile in (x, z), counter-clockwise: under the shoulder, down the leg, along the foot, and back over
let prof = vec![(x0, zs), (mid, zs), (knee, 0.0), (x1, 0.0), (x1, t), (knee + t * 0.4, t), (mid + t * 0.4, zs + t), (x0, zs + t)];
prism(&prof, y - b / 2.0, y + b / 2.0, true, |u, v, w| [sign * u, w, v])
}
fn d(dims: &Map<String, Value>, k: &str, def: f64) -> f64 { dims.get(k).and_then(|v| v.as_f64()).unwrap_or(def) }
/// The families and their default dimensions (JEDEC letters, mm), from common parts. `pins` is the total pin count in
/// every family that has a choice; D2 and E2 are a QFN or DFN's exposed pad, drawn when both are given: an 0603 chip, a SOIC-8,
/// a QFN-16 4x4, a TQFP-32 7x7, a SOT-23, a DFN-8 3x3, a BGA-16 0.8 mm, a MiniMELF, a 6.3 mm can, a 3.2x2.5 crystal,
/// a 1x4 2.54 mm header.
pub fn families() -> Vec<(&'static str, Value)> {
vec![
("chip", json!({"D": 1.6, "E": 0.8, "A": 0.45, "L": 0.3})),
("soic", json!({"pins": 8, "D": 4.9, "E": 6.0, "E1": 3.9, "A": 1.75, "A1": 0.15, "b": 0.42, "e": 1.27, "L": 0.6, "c": 0.2})),
("qfn", json!({"pins": 16, "D": 4.0, "E": 4.0, "A": 0.9, "b": 0.3, "e": 0.65, "L": 0.4, "D2": 2.1, "E2": 2.1})),
("qfp", json!({"pins": 32, "D": 9.0, "E": 9.0, "D1": 7.0, "E1": 7.0, "A": 1.2, "A1": 0.1, "b": 0.37, "e": 0.8, "L": 0.6, "c": 0.15})),
("sot23", json!({"pins": 3, "D": 2.9, "E": 2.4, "E1": 1.3, "A": 1.0, "A1": 0.05, "b": 0.4, "e": 0.95, "L": 0.4, "c": 0.12})),
("dfn", json!({"pins": 8, "D": 3.0, "E": 3.0, "A": 0.85, "b": 0.3, "e": 0.65, "L": 0.4, "D2": 2.4, "E2": 1.6})),
("bga", json!({"rows": 4, "cols": 4, "D": 4.0, "E": 4.0, "A": 1.2, "A1": 0.25, "b": 0.4, "e": 0.8})),
("melf", json!({"D": 3.5, "E": 1.4, "L": 0.45})),
("ecap", json!({"D": 6.3, "A": 5.4, "E": 6.6})),
("crystal", json!({"D": 3.2, "E": 2.5, "A": 0.8, "b": 1.2, "L": 1.0})),
("header", json!({"pins": 4, "rows": 1, "e": 2.54, "A": 2.5, "L": 3.0, "L1": 6.0, "b": 0.64})),
]
}
/// The solids of a package: its body (and leads) in the IPC-7351 zero orientation, pin 1 top-left.
pub fn package(family: &str, dims: &Map<String, Value>) -> Option<Vec<Solid>> {
let mut s = Vec::new();
let pins = |def: f64| d(dims, "pins", def).round().max(1.0) as usize;
match family {
"chip" => {
let (len, w, h, l) = (d(dims, "D", 1.6), d(dims, "E", 0.8), d(dims, "A", 0.45), d(dims, "L", 0.3));
s.push(bx(-len / 2.0 + l, -w / 2.0, 0.0, len / 2.0 - l, w / 2.0, h, false));
s.push(bx(-len / 2.0, -w / 2.0, 0.0, -len / 2.0 + l, w / 2.0, h, true));
s.push(bx(len / 2.0 - l, -w / 2.0, 0.0, len / 2.0, w / 2.0, h, true));
}
"soic" | "sot23" | "qfp" => {
let n = pins(if family == "sot23" { 3.0 } else { 8.0 });
let (a, a1, b, e, l, c) = (d(dims, "A", 1.75), d(dims, "A1", 0.1), d(dims, "b", 0.42), d(dims, "e", 1.27), d(dims, "L", 0.6), d(dims, "c", 0.2));
let zs = a1 + (a - a1) * 0.4;
if family == "qfp" {
let (dd, ee, d1, e1) = (d(dims, "D", 9.0), d(dims, "E", 9.0), d(dims, "D1", 7.0), d(dims, "E1", 7.0));
s.push(bx(-e1 / 2.0, -d1 / 2.0, a1, e1 / 2.0, d1 / 2.0, a, false));
let per = (n / 4).max(1);
let span = (per - 1) as f64 * e;
for i in 0..per {
let off = span / 2.0 - i as f64 * e;
// left (pins 1..), bottom, right, top: counter-clockwise from pin 1 top-left
s.push(gull(e1 / 2.0, ee / 2.0, off, b, c, zs, l, -1.0));
let mut bt = gull(d1 / 2.0, dd / 2.0, -off, b, c, zs, l, 1.0);
for f in &mut bt.faces { for p in f.iter_mut() { *p = [-p[1], -p[0], p[2]]; } f.reverse(); }
s.push(bt);
s.push(gull(e1 / 2.0, ee / 2.0, -off, b, c, zs, l, 1.0));
let mut tp = gull(d1 / 2.0, dd / 2.0, off, b, c, zs, l, 1.0);
for f in &mut tp.faces { for p in f.iter_mut() { *p = [p[1], p[0], p[2]]; } f.reverse(); }
s.push(tp);
}
} else {
let (dd, ee, e1) = (d(dims, "D", 4.9), d(dims, "E", 6.0), d(dims, "E1", 3.9));
s.push(bx(-e1 / 2.0, -dd / 2.0, a1, e1 / 2.0, dd / 2.0, a, false));
if family == "sot23" && n == 3 {
s.push(gull(e1 / 2.0, ee / 2.0, e, b, c, zs, l, -1.0));
s.push(gull(e1 / 2.0, ee / 2.0, -e, b, c, zs, l, -1.0));
s.push(gull(e1 / 2.0, ee / 2.0, 0.0, b, c, zs, l, 1.0));
} else {
let per = n.div_ceil(2);
let span = (per - 1) as f64 * e;
for i in 0..per { s.push(gull(e1 / 2.0, ee / 2.0, span / 2.0 - i as f64 * e, b, c, zs, l, -1.0)); }
for i in 0..(n - per) { s.push(gull(e1 / 2.0, ee / 2.0, -span / 2.0 + i as f64 * e, b, c, zs, l, 1.0)); }
}
}
}
"qfn" | "dfn" => {
let n = pins(if family == "dfn" { 8.0 } else { 16.0 });
let (dd, ee, a, b, e, l) = (d(dims, "D", 4.0), d(dims, "E", 4.0), d(dims, "A", 0.9), d(dims, "b", 0.3), d(dims, "e", 0.65), d(dims, "L", 0.4));
let t = 0.02;
s.push(bx(-ee / 2.0, -dd / 2.0, t, ee / 2.0, dd / 2.0, a, false));
let sides = if family == "dfn" { 2 } else { 4 };
let per = (n / sides).max(1);
let span = (per - 1) as f64 * e;
for i in 0..per {
let off = span / 2.0 - i as f64 * e;
s.push(bx(-ee / 2.0, off - b / 2.0, 0.0, -ee / 2.0 + l, off + b / 2.0, t, true));
s.push(bx(ee / 2.0 - l, -off - b / 2.0, 0.0, ee / 2.0, -off + b / 2.0, t, true));
if sides == 4 {
s.push(bx(-off - b / 2.0, -dd / 2.0, 0.0, -off + b / 2.0, -dd / 2.0 + l, t, true));
s.push(bx(off - b / 2.0, dd / 2.0 - l, 0.0, off + b / 2.0, dd / 2.0, t, true));
}
}
let (d2, e2) = (d(dims, "D2", 0.0), d(dims, "E2", 0.0));
if d2 > 0.0 && e2 > 0.0 { s.push(bx(-e2 / 2.0, -d2 / 2.0, 0.0, e2 / 2.0, d2 / 2.0, t, true)); }
}
"bga" => {
// rows x cols, or `pins` (the total, as every family takes it) as the smallest square grid that holds them
let side = dims.get("pins").and_then(|v| v.as_f64()).map(|n| n.max(1.0).sqrt().ceil()).unwrap_or(4.0);
let (rows, cols) = (d(dims, "rows", side).round().max(1.0) as usize, d(dims, "cols", side).round().max(1.0) as usize);
let (dd, ee, a, a1, b, e) = (d(dims, "D", 4.0), d(dims, "E", 4.0), d(dims, "A", 1.2), d(dims, "A1", 0.25), d(dims, "b", 0.4), d(dims, "e", 0.8));
s.push(bx(-ee / 2.0, -dd / 2.0, a1, ee / 2.0, dd / 2.0, a, false));
for r in 0..rows {
for c in 0..cols {
let (x, y) = ((c as f64 - (cols - 1) as f64 / 2.0) * e, ((rows - 1) as f64 / 2.0 - r as f64) * e);
s.push(prism(&ngon(b / 2.0, 12), 0.0, a1, true, |u, v, w| [x + u, y + v, w]));
}
}
}
"melf" => {
let (len, dia, l) = (d(dims, "D", 3.5), d(dims, "E", 1.4), d(dims, "L", 0.45));
let r = dia / 2.0;
// a cylinder lying along x, resting on z = 0
s.push(prism(&ngon(r * 0.97, 24), -len / 2.0 + l, len / 2.0 - l, false, |u, v, w| [w, u, r + v]));
s.push(prism(&ngon(r, 24), -len / 2.0, -len / 2.0 + l, true, |u, v, w| [w, u, r + v]));
s.push(prism(&ngon(r, 24), len / 2.0 - l, len / 2.0, true, |u, v, w| [w, u, r + v]));
}
"ecap" => {
let (dia, h, base) = (d(dims, "D", 6.3), d(dims, "A", 5.4), d(dims, "E", 6.6));
s.push(bx(-base / 2.0, -base / 2.0, 0.0, base / 2.0, base / 2.0, 0.6, false));
s.push(prism(&ngon(dia / 2.0, 32), 0.6, h, true, |u, v, w| [u, v, w]));
}
"crystal" => {
let (dd, ee, a, b, l) = (d(dims, "D", 3.2), d(dims, "E", 2.5), d(dims, "A", 0.8), d(dims, "b", 1.2), d(dims, "L", 1.0));
s.push(bx(-dd / 2.0, -ee / 2.0, 0.05, dd / 2.0, ee / 2.0, a, false));
for (sx, sy) in [(-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)] {
let (x, y) = (sx * (dd / 2.0 - l / 2.0), sy * (ee / 2.0 - b / 2.0));
s.push(bx(x - l / 2.0, y - b / 2.0, 0.0, x + l / 2.0, y + b / 2.0, 0.05, true));
}
}
"header" => {
let (n, rows, e, a, l, l1, b) = (pins(4.0), d(dims, "rows", 1.0).round().max(1.0) as usize, d(dims, "e", 2.54), d(dims, "A", 2.5), d(dims, "L", 3.0), d(dims, "L1", 6.0), d(dims, "b", 0.64));
let per = n.div_ceil(rows);
let (w, h) = (rows as f64 * e, per as f64 * e);
// pin 1 at the origin, the rest down the column (and across the rows)
s.push(bx(-e / 2.0, -h + e / 2.0, 0.0, w - e / 2.0, e / 2.0, a, false));
for i in 0..n {
let (x, y) = ((i / per) as f64 * e, -((i % per) as f64) * e);
s.push(bx(x - b / 2.0, y - b / 2.0, -l, x + b / 2.0, y + b / 2.0, a + l1, true));
}
}
_ => return None,
}
Some(s)
}
/// STEP AP214 text of the solids: one product, every solid a MANIFOLD_SOLID_BREP of planar faces, coloured.
pub fn step(solids: &[Solid], name: &str) -> String {
let mut out = Vec::<String>::new();
let mut id = 0usize;
let mut add = |out: &mut Vec<String>, s: String| -> usize { id += 1; out.push(format!("#{id}={s};")); id };
let r = |v: f64| { let s = format!("{:.6}", if v.abs() < 5e-7 { 0.0 } else { v }); s };
let app = add(&mut out, "APPLICATION_CONTEXT('core data for automotive mechanical design processes')".into());
add(&mut out, format!("APPLICATION_PROTOCOL_DEFINITION('international standard','automotive_design',2000,#{app})"));
let pc = add(&mut out, format!("PRODUCT_CONTEXT('',#{app},'mechanical')"));
let prod = add(&mut out, format!("PRODUCT('{name}','{name}','',(#{pc}))"));
let pdf = add(&mut out, format!("PRODUCT_DEFINITION_FORMATION('','',#{prod})"));
let pdc = add(&mut out, format!("PRODUCT_DEFINITION_CONTEXT('part definition',#{app},'design')"));
let pd = add(&mut out, format!("PRODUCT_DEFINITION('design','',#{pdf},#{pdc})"));
let pds = add(&mut out, format!("PRODUCT_DEFINITION_SHAPE('','',#{pd})"));
add(&mut out, format!("PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#{prod}))"));
let lu = add(&mut out, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))".into());
let au = add(&mut out, "(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))".into());
let su = add(&mut out, "(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())".into());
let unc = add(&mut out, format!("UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-06),#{lu},'distance_accuracy_value','confusion accuracy')"));
let ctx = add(&mut out, format!("(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{unc}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{lu},#{au},#{su}))REPRESENTATION_CONTEXT('Context #1','3D Context with UNIT and UNCERTAINTY'))"));
let o = add(&mut out, "CARTESIAN_POINT('',(0.,0.,0.))".into());
let z = add(&mut out, "DIRECTION('',(0.,0.,1.))".into());
let x = add(&mut out, "DIRECTION('',(1.,0.,0.))".into());
let ax = add(&mut out, format!("AXIS2_PLACEMENT_3D('',#{o},#{z},#{x})"));
let mut breps = Vec::new();
let mut styled = Vec::new();
// colours: a dark moulded body, bright leads
let mut style = |out: &mut Vec<String>, rgb: (f64, f64, f64)| -> usize {
let c = add(out, format!("COLOUR_RGB('',{},{},{})", r(rgb.0), r(rgb.1), r(rgb.2)));
let fc = add(out, format!("FILL_AREA_STYLE_COLOUR('',#{c})"));
let fa = add(out, format!("FILL_AREA_STYLE('',(#{fc}))"));
let sf = add(out, format!("SURFACE_STYLE_FILL_AREA(#{fa})"));
let ss = add(out, format!("SURFACE_SIDE_STYLE('',(#{sf}))"));
let su = add(out, format!("SURFACE_STYLE_USAGE(.BOTH.,#{ss})"));
add(out, format!("PRESENTATION_STYLE_ASSIGNMENT((#{su}))"))
};
let body_style = style(&mut out, (0.15, 0.15, 0.16));
let lead_style = style(&mut out, (0.80, 0.80, 0.82));
let mark_style = style(&mut out, (0.92, 0.92, 0.90));
for sol in solids {
// shared vertices and edges, so the shell is closed
let mut verts: Vec<(P3, usize)> = Vec::new();
let mut edges: Vec<((usize, usize), usize)> = Vec::new();
let key = |p: &P3| [(p[0] * 1e5).round() as i64, (p[1] * 1e5).round() as i64, (p[2] * 1e5).round() as i64];
let mut faces = Vec::new();
for f in &sol.faces {
let mut vids = Vec::new();
for p in f {
let k = key(p);
let vid = match verts.iter().find(|(q, _)| key(q) == k) {
Some((_, v)) => *v,
None => {
let cp = add(&mut out, format!("CARTESIAN_POINT('',({},{},{}))", r(p[0]), r(p[1]), r(p[2])));
let v = add(&mut out, format!("VERTEX_POINT('',#{cp})"));
verts.push((*p, v));
v
}
};
vids.push((vid, *p));
}
let mut oes = Vec::new();
for i in 0..vids.len() {
let (a, b) = (vids[i], vids[(i + 1) % vids.len()]);
let (lo, hi) = if a.0 < b.0 { (a.0, b.0) } else { (b.0, a.0) };
let eid = match edges.iter().find(|(k, _)| *k == (lo, hi)) {
Some((_, e)) => *e,
None => {
// the edge runs from the lower vertex id to the higher
let (pa, pb) = if a.0 == lo { (a.1, b.1) } else { (b.1, a.1) };
let len = ((pb[0] - pa[0]).powi(2) + (pb[1] - pa[1]).powi(2) + (pb[2] - pa[2]).powi(2)).sqrt().max(1e-12);
let cp = add(&mut out, format!("CARTESIAN_POINT('',({},{},{}))", r(pa[0]), r(pa[1]), r(pa[2])));
let dir = add(&mut out, format!("DIRECTION('',({},{},{}))", r((pb[0] - pa[0]) / len), r((pb[1] - pa[1]) / len), r((pb[2] - pa[2]) / len)));
let vec = add(&mut out, format!("VECTOR('',#{dir},{})", r(len)));
let ln = add(&mut out, format!("LINE('',#{cp},#{vec})"));
let e = add(&mut out, format!("EDGE_CURVE('',#{lo},#{hi},#{ln},.T.)"));
edges.push(((lo, hi), e));
e
}
};
oes.push(add(&mut out, format!("ORIENTED_EDGE('',*,*,#{eid},{})", if a.0 == lo { ".T." } else { ".F." })));
}
let loop_ = add(&mut out, format!("EDGE_LOOP('',({}))", oes.iter().map(|e| format!("#{e}")).collect::<Vec<_>>().join(",")));
let bound = add(&mut out, format!("FACE_OUTER_BOUND('',#{loop_},.T.)"));
// the face's plane: Newell's normal, a reference direction along its first edge
let mut nrm = [0.0f64; 3];
for i in 0..f.len() { let (a, b) = (f[i], f[(i + 1) % f.len()]); nrm[0] += (a[1] - b[1]) * (a[2] + b[2]); nrm[1] += (a[2] - b[2]) * (a[0] + b[0]); nrm[2] += (a[0] - b[0]) * (a[1] + b[1]); }
let nl = (nrm[0] * nrm[0] + nrm[1] * nrm[1] + nrm[2] * nrm[2]).sqrt().max(1e-12);
let nn = [nrm[0] / nl, nrm[1] / nl, nrm[2] / nl];
let e0 = [f[1][0] - f[0][0], f[1][1] - f[0][1], f[1][2] - f[0][2]];
let el = (e0[0] * e0[0] + e0[1] * e0[1] + e0[2] * e0[2]).sqrt().max(1e-12);
let cp = add(&mut out, format!("CARTESIAN_POINT('',({},{},{}))", r(f[0][0]), r(f[0][1]), r(f[0][2])));
let dn = add(&mut out, format!("DIRECTION('',({},{},{}))", r(nn[0]), r(nn[1]), r(nn[2])));
let dr = add(&mut out, format!("DIRECTION('',({},{},{}))", r(e0[0] / el), r(e0[1] / el), r(e0[2] / el)));
let pl_ax = add(&mut out, format!("AXIS2_PLACEMENT_3D('',#{cp},#{dn},#{dr})"));
let plane = add(&mut out, format!("PLANE('',#{pl_ax})"));
faces.push(add(&mut out, format!("ADVANCED_FACE('',(#{bound}),#{plane},.T.)")));
}
let shell = add(&mut out, format!("CLOSED_SHELL('',({}))", faces.iter().map(|f| format!("#{f}")).collect::<Vec<_>>().join(",")));
let brep = add(&mut out, format!("MANIFOLD_SOLID_BREP('{}',#{shell})", if sol.mark { "marking" } else if sol.lead { "lead" } else { "body" }));
breps.push(brep);
styled.push(add(&mut out, format!("STYLED_ITEM('color',(#{}),#{brep})", if sol.mark { mark_style } else if sol.lead { lead_style } else { body_style })));
}
let rep = add(&mut out, format!("ADVANCED_BREP_SHAPE_REPRESENTATION('{name}',(#{ax},{}),#{ctx})", breps.iter().map(|b| format!("#{b}")).collect::<Vec<_>>().join(",")));
add(&mut out, format!("SHAPE_DEFINITION_REPRESENTATION(#{pds},#{rep})"));
add(&mut out, format!("MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION('',({}),#{ctx})", styled.iter().map(|s| format!("#{s}")).collect::<Vec<_>>().join(",")));
format!("ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('altium-bridge generate_body'),'2;1');\nFILE_NAME('{name}.step','2026-01-01T00:00:00',(''),(''),'altium-bridge','altium-bridge','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN {{ 1 0 10303 214 1 1 1 1 }}'));\nENDSEC;\nDATA;\n{}\nENDSEC;\nEND-ISO-10303-21;\n", out.join("\n"))
}
/// A segment font for markings: each character is strokes between the points of a 2 x 2 cell (x 0..1, y 0..2), as
/// a 16-segment display draws them plus two diagonals for V. Part markings use capitals, digits and a few signs.
fn glyph(c: char) -> Option<&'static [((f64, f64), (f64, f64))]> {
const TL: (f64, f64) = (0.0, 2.0); const TM: (f64, f64) = (0.5, 2.0); const TR: (f64, f64) = (1.0, 2.0);
const ML: (f64, f64) = (0.0, 1.0); const MM: (f64, f64) = (0.5, 1.0); const MR: (f64, f64) = (1.0, 1.0);
const BL: (f64, f64) = (0.0, 0.0); const BM: (f64, f64) = (0.5, 0.0); const BR: (f64, f64) = (1.0, 0.0);
const A: ((f64, f64), (f64, f64)) = (TL, TR); const B: ((f64, f64), (f64, f64)) = (TR, MR); const C: ((f64, f64), (f64, f64)) = (MR, BR);
const D: ((f64, f64), (f64, f64)) = (BL, BR); const E: ((f64, f64), (f64, f64)) = (ML, BL); const F: ((f64, f64), (f64, f64)) = (TL, ML);
const G1: ((f64, f64), (f64, f64)) = (ML, MM); const G2: ((f64, f64), (f64, f64)) = (MM, MR); const G: ((f64, f64), (f64, f64)) = (ML, MR);
const VT: ((f64, f64), (f64, f64)) = (TM, MM); const VB: ((f64, f64), (f64, f64)) = (MM, BM); const V: ((f64, f64), (f64, f64)) = (TM, BM);
const DTL: ((f64, f64), (f64, f64)) = (TL, MM); const DTR: ((f64, f64), (f64, f64)) = (TR, MM);
const DBL: ((f64, f64), (f64, f64)) = (MM, BL); const DBR: ((f64, f64), (f64, f64)) = (MM, BR);
const LB: ((f64, f64), (f64, f64)) = (ML, BM); const RB: ((f64, f64), (f64, f64)) = (MR, BM);
const DOT: ((f64, f64), (f64, f64)) = ((0.4, 0.0), (0.6, 0.0));
// D and B: the left side full and the right corners cut, so D does not read as I and O side by side (it was a
// box with a centre bar: "ADOM" read "AIOM" on a QFN in Altium, 2026-10-28) and B is not an 8
const KT: (f64, f64) = (0.75, 2.0); const KM: (f64, f64) = (0.75, 1.0); const KB: (f64, f64) = (0.75, 0.0);
const RT: (f64, f64) = (1.0, 1.75); const RU: (f64, f64) = (1.0, 1.25); const RL: (f64, f64) = (1.0, 0.75); const RB0: (f64, f64) = (1.0, 0.25);
const DG: &[((f64, f64), (f64, f64))] = &[F, E, (TL, KT), (KT, RT), (RT, RB0), (RB0, KB), (BL, KB)];
const BG: &[((f64, f64), (f64, f64))] = &[F, E, (TL, KT), (KT, RT), (RT, RU), (RU, KM), (ML, KM), (KM, RL), (RL, RB0), (RB0, KB), (BL, KB)];
Some(match c.to_ascii_uppercase() {
'0' => &[A, B, C, D, E, F, DTR, DBL], '1' => &[V], '2' => &[A, B, G, E, D], '3' => &[A, B, G2, C, D],
'4' => &[F, G, B, C], '5' => &[A, F, G, C, D], '6' => &[A, F, E, D, C, G], '7' => &[A, B, C], '8' => &[A, B, C, D, E, F, G],
'9' => &[A, B, C, D, F, G], 'A' => &[E, F, A, B, C, G], 'B' => BG, 'C' => &[A, F, E, D], 'D' => DG,
'E' => &[A, F, E, D, G1], 'F' => &[A, F, E, G1], 'G' => &[A, F, E, D, C, G2], 'H' => &[F, E, B, C, G], 'I' => &[A, V, D],
'J' => &[B, C, D, E], 'K' => &[F, E, G1, DTR, DBR], 'L' => &[F, E, D], 'M' => &[E, F, B, C, DTL, DTR], 'N' => &[E, F, B, C, DTL, DBR],
'O' => &[A, B, C, D, E, F], 'P' => &[A, B, F, E, G], 'Q' => &[A, B, C, D, E, F, DBR], 'R' => &[A, B, F, E, G, DBR],
'S' => &[A, F, G, C, D], 'T' => &[A, V], 'U' => &[F, E, D, C, B], 'V' => &[F, B, LB, RB], 'W' => &[E, F, B, C, DBL, DBR],
'X' => &[DTL, DTR, DBL, DBR], 'Y' => &[DTL, DTR, VB], 'Z' => &[A, DTR, DBL, D], '-' => &[G], '+' => &[G, VT, VB],
'/' => &[DTR, DBL], '.' => &[DOT], ' ' => &[],
_ => return None,
})
}
/// The marking as thin bars on the top face of `top` (the body's top), centred and sized to fit it, lines stacked.
/// Raised stands 30 um proud; engraved is a 5 um layer flush on the top (the body is not cut). Returns the solids and
/// the characters the font does not draw.
fn marking(text: &str, top: &Solid, engraved: bool) -> (Vec<Solid>, Vec<char>) {
let b = bbox(std::slice::from_ref(top));
let (w, d, z) = (b[3] - b[0], b[4] - b[1], b[5]);
let (cx, cy) = ((b[0] + b[3]) / 2.0, (b[1] + b[4]) / 2.0);
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
let mut skipped = Vec::new();
if lines.is_empty() { return (Vec::new(), skipped); }
// a character cell is 0.6 h wide with 0.25 h between characters; lines are 1.5 h apart
let longest = lines.iter().map(|l| l.chars().count()).max().unwrap_or(1) as f64;
let h = (0.85 * w / (longest * 0.85 - 0.25).max(0.6)).min(0.8 * d / (lines.len() as f64 * 1.5 - 0.5)).min(1.5);
let stroke = (h * 0.12).max(0.03);
let (z0, z1) = if engraved { (z, z + 0.005) } else { (z, z + 0.03) };
let mut out = Vec::new();
for (li, line) in lines.iter().enumerate() {
let n = line.chars().count() as f64;
let x0 = cx - (n * 0.85 - 0.25) * h / 2.0;
let y0 = cy + (lines.len() as f64 * 1.5 - 0.5) * h / 2.0 - h - li as f64 * 1.5 * h;
for (ci, ch) in line.chars().enumerate() {
let Some(strokes) = glyph(ch) else { skipped.push(ch); continue };
let ox = x0 + ci as f64 * 0.85 * h;
for ((u0, v0), (u1, v1)) in strokes {
// a stroke of the 0..1 x 0..2 cell, scaled to 0.6 h x h, as a bar `stroke` wide
let (ax, ay, bx2, by) = (ox + u0 * 0.6 * h, y0 + v0 * 0.5 * h, ox + u1 * 0.6 * h, y0 + v1 * 0.5 * h);
let len = (bx2 - ax).hypot(by - ay).max(1e-9);
let (nx, ny) = (-(by - ay) / len * stroke / 2.0, (bx2 - ax) / len * stroke / 2.0);
let (ex, ey) = ((bx2 - ax) / len * stroke / 2.0, (by - ay) / len * stroke / 2.0);
let quad = vec![(ax - ex - nx, ay - ey - ny), (bx2 + ex - nx, by + ey - ny), (bx2 + ex + nx, by + ey + ny), (ax - ex + nx, ay - ey + ny)];
let mut s = prism(&quad, z0, z1, false, |u, v, w| [u, v, w]);
s.mark = true;
out.push(s);
}
}
}
(out, skipped)
}
fn bbox(solids: &[Solid]) -> [f64; 6] {
solids.iter().flat_map(|s| s.faces.iter().flatten()).fold([f64::MAX, f64::MAX, f64::MAX, f64::MIN, f64::MIN, f64::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].min(p[2]), b[3].max(p[0]), b[4].max(p[1]), b[5].max(p[2])])
}
/// generate_body: input `{args: {family, dimensionsMm?, marking?, markingStyle?, name?}}`. The package's body as STEP
/// text (`text`) and its box in the bodies companion's form (`bodies`, one box for the whole body; a raised marking's
/// 30 um is not part of it). No I/O: the bridge writes the file and attaches it (add_body).
pub fn generate_body(input: &Value) -> Value {
let args = &input["args"];
let fams = families();
let family = args.get("family").and_then(|v| v.as_str()).unwrap_or("").to_lowercase().replace(['-', '_', ' '], "");
let family = match family.as_str() { "sot23" | "sot233" => "sot23".to_string(), "tqfp" | "lqfp" => "qfp".into(), "son" | "wson" => "dfn".into(), "minimelf" => "melf".into(), "electrolytic" | "can" => "ecap".into(), f => f.to_string() };
let Some((name, defaults)) = fams.iter().find(|(n, _)| *n == family) else {
return json!({"success": false, "errorCode": "unknown_family", "error": format!("family {:?} is not one the engine makes", args.get("family")),
"families": fams.iter().map(|(n, _)| *n).collect::<Vec<_>>()});
};
let mut dims = defaults.as_object().cloned().unwrap_or_default();
if let Some(given) = args.get("dimensionsMm").and_then(|v| v.as_object()) {
for (k, v) in given { if v.is_number() { dims.insert(k.clone(), v.clone()); } }
}
let Some(mut solids) = package(name, &dims) else { return json!({"success": false, "errorCode": "unknown_family"}) };
// the marking sits on the body's top (an electrolytic can's top for ecap)
let marking_text = args.get("marking").and_then(|v| v.as_str()).map(|t| t.replace("\\n", "\n")).filter(|t| !t.trim().is_empty());
let engraved = args.get("markingStyle").and_then(|v| v.as_str()) == Some("engraved");
let mut unmarked: Vec<char> = Vec::new();
if let Some(t) = &marking_text {
let top = solids[if *name == "ecap" { 1 } else { 0 }].clone();
let (marks, skipped) = marking(t, &top, engraved);
unmarked = skipped;
solids.extend(marks);
}
let stem = args.get("name").and_then(|v| v.as_str()).unwrap_or(name).replace('\'', "");
let r3 = |v: f64| (v * 1000.0).round() / 1000.0;
// the box is the package's: a raised marking's 30 um is not part of the body's height
let body: Vec<Solid> = solids.iter().filter(|s| !s.mark).cloned().collect();
let b = bbox(&body);
json!({"success": true, "text": step(&solids, &stem), "family": name, "dimensionsMm": dims, "solids": solids.len(),
"bboxMm": {"x": r3(b[3] - b[0]), "y": r3(b[4] - b[1]), "z": r3(b[5] - b[2])},
"bodies": [{"x0": r3(b[0]), "y0": r3(b[1]), "x1": r3(b[3]), "y1": r3(b[4]), "z0": r3(b[2]), "z1": r3(b[5])}],
"marking": marking_text,
"markingStyle": if marking_text.is_some() { json!(if engraved { "engraved (a 5 um layer flush on the top; the body is not cut)" } else { "raised 30 um" }) } else { Value::Null },
"notApplied": if unmarked.is_empty() { json!([]) } else { json!([format!("marking characters the font does not draw: {}", unmarked.iter().collect::<String>())]) },
"writtenFrom": "package dimensions",
"_hint": "The body is in the package's own frame (IPC-7351 zero orientation, pin 1 top-left, seated on z = 0). Write text to a .step file and attach it (add_body). Compare bboxMm with the datasheet's body size before attaching it."})
}
#[cfg(test)]
mod tests {
use super::*;
fn closed(s: &Solid) -> bool {
// every edge is used once each way: a closed, consistently oriented shell
let key = |p: &P3| [(p[0] * 1e5).round() as i64, (p[1] * 1e5).round() as i64, (p[2] * 1e5).round() as i64];
let mut e: Vec<([i64; 3], [i64; 3])> = Vec::new();
for f in &s.faces { for i in 0..f.len() { e.push((key(&f[i]), key(&f[(i + 1) % f.len()]))); } }
e.iter().all(|(a, b)| e.iter().filter(|x| x == &&(*b, *a)).count() == 1)
}
#[test]
fn every_family_is_closed_solids_with_outward_faces() {
for (name, def) in families() {
let solids = package(name, def.as_object().unwrap()).unwrap();
for s in &solids {
assert!(closed(s), "{name}: a shell is open");
assert!(signed_volume(s) > 0.0, "{name}: a solid is inside out");
}
}
}
#[test]
fn a_soic8_has_its_lead_span_and_height() {
let dims = families().into_iter().find(|f| f.0 == "soic").unwrap().1;
let s = package("soic", dims.as_object().unwrap()).unwrap();
assert_eq!(s.len(), 9);
let b = bbox(&s);
assert!((b[3] - b[0] - 6.0).abs() < 1e-9 && (b[5] - 1.75).abs() < 1e-9 && b[2].abs() < 1e-9, "{b:?}");
// pin 1 is the top-left lead
let lead1 = bbox(&s[1..2]);
assert!(lead1[0] < 0.0 && lead1[4] > 1.9, "{lead1:?}");
}
/// STEP_DUMP=<dir>: write every family's default body there, for a STEP reader to check
#[test]
fn dump_for_a_reader() {
let Ok(dir) = std::env::var("STEP_DUMP") else { return };
for (name, def) in families() {
std::fs::write(format!("{dir}/{name}.step"), step(&package(name, def.as_object().unwrap()).unwrap(), name)).unwrap();
}
for (name, text, idx) in [("soic", "NE555\nTI 2X4", 0), ("qfn", "STM32\nG031", 0), ("ecap", "100UF\n25V", 1)] {
let def = families().into_iter().find(|f| f.0 == name).unwrap().1;
let mut s = package(name, def.as_object().unwrap()).unwrap();
let top = s[idx].clone();
s.extend(marking(text, &top, false).0);
std::fs::write(format!("{dir}/{name}-marked.step"), step(&s, name)).unwrap();
}
}
#[test]
fn the_step_text_is_well_formed() {
let s = package("chip", families()[0].1.as_object().unwrap()).unwrap();
let t = step(&s, "R0603");
assert!(t.starts_with("ISO-10303-21;") && t.trim_end().ends_with("END-ISO-10303-21;"));
assert_eq!(t.matches("MANIFOLD_SOLID_BREP").count(), 3);
assert_eq!(t.matches("ADVANCED_FACE").count(), 18);
// every referenced entity exists
let ids: Vec<usize> = t.lines().filter_map(|l| l.strip_prefix('#').and_then(|r| r.split('=').next()).and_then(|n| n.parse().ok())).collect();
let max = *ids.iter().max().unwrap();
for cap in t.split('#').skip(1) { let n: String = cap.chars().take_while(|c| c.is_ascii_digit()).collect(); if let Ok(n) = n.parse::<usize>() { assert!(n <= max); } }
}
#[test]
fn a_marking_sits_on_the_body_top_and_fits_it() {
let dims = families().into_iter().find(|f| f.0 == "soic").unwrap().1;
let s = package("soic", dims.as_object().unwrap()).unwrap();
let (m, skipped) = marking("NE555\nTI 2X", &s[0], false);
assert!(skipped.is_empty());
assert!(!m.is_empty());
let (body, mb) = (bbox(&s[..1]), bbox(&m));
assert!((mb[2] - 1.75).abs() < 1e-9 && (mb[5] - 1.78).abs() < 1e-9, "{mb:?}");
assert!(mb[0] > body[0] && mb[3] < body[3] && mb[1] > body[1] && mb[4] < body[4], "inside the top: {mb:?} {body:?}");
for x in &m { assert!(closed(x) && signed_volume(x) > 0.0); }
assert_eq!(marking("A~B", &s[0], true).1, vec!['~']);
}
#[test]
fn the_writer_answers_step_text_and_the_bodys_box() {
let r = generate_body(&json!({"args": {"family": "SOIC", "marking": "NE555", "name": "U1"}}));
assert_eq!(r["success"], json!(true));
assert!(r["text"].as_str().unwrap().starts_with("ISO-10303-21;"));
assert_eq!(r["bodies"][0]["z1"], json!(1.75), "the marking is not in the box");
assert_eq!(r["bboxMm"]["x"], json!(6.0));
assert_eq!(r["writtenFrom"], "package dimensions");
assert_eq!(generate_body(&json!({"args": {"family": "nope"}}))["errorCode"], "unknown_family");
}
#[test]
fn fusion_lanes_reference_parts_match() {
// fusion_generate_package 1.13.3's 0603, SOIC-8, QFN-32 (3.3 mm exposed pad) and QFP-48 (#20): the same box and
// the same number of solids
let cases = [
("chip", json!({"D": 1.6, "E": 0.8, "A": 0.45, "L": 0.3}), [1.6, 0.8, 0.45], 3),
("soic", json!({"pins": 8, "D": 4.9, "E": 6.0, "E1": 3.9, "A": 1.75, "A1": 0.1, "b": 0.41, "e": 1.27, "L": 0.84}), [6.0, 4.9, 1.75], 9),
("qfn", json!({"pins": 32, "D": 5.0, "E": 5.0, "A": 0.9, "b": 0.25, "e": 0.5, "L": 0.4, "D2": 3.3, "E2": 3.3}), [5.0, 5.0, 0.9], 34),
("qfp", json!({"pins": 48, "D": 9.0, "E": 9.0, "D1": 7.0, "E1": 7.0, "A": 1.6, "A1": 0.1, "b": 0.22, "e": 0.5, "L": 0.6}), [9.0, 9.0, 1.6], 49),
];
for (family, dims, size, solids) in cases {
let r = generate_body(&json!({"args": {"family": family, "dimensionsMm": dims}}));
assert_eq!(r["solids"], json!(solids), "{family}");
assert_eq!([r["bboxMm"]["x"].as_f64().unwrap(), r["bboxMm"]["y"].as_f64().unwrap(), r["bboxMm"]["z"].as_f64().unwrap()], size, "{family}");
}
// the exposed pad is drawn only when given
let mut no_ep = json!({"pins": 32, "D": 5.0, "E": 5.0});
no_ep["D2"] = json!(0.0);
assert_eq!(generate_body(&json!({"args": {"family": "qfn", "dimensionsMm": no_ep}}))["solids"], json!(33));
// pins is the total in a BGA too: 64 balls are an 8 x 8 grid
assert_eq!(package("bga", json!({"pins": 64}).as_object().unwrap()).unwrap().len(), 65);
}
#[test]
fn d_and_b_are_not_i_o_or_8() {
// D was a box with a centre bar and read as "IO"; B was an 8 with a bar
assert_ne!(glyph('D'), glyph('O'));
assert_ne!(glyph('B'), glyph('8'));
for c in ['D', 'B'] {
let g = glyph(c).unwrap();
assert!(g.iter().all(|s| s.0 .0 != 0.5 || s.1 .0 != 0.5), "{c} has no centre bar");
assert!(!g.iter().any(|s| (s.0 == (1.0, 2.0)) || (s.1 == (1.0, 2.0))), "{c}'s top-right corner is cut");
}
}
}