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 #316: kicad-lane: 1.0.70, build_schematic, bundle planOnly (#106), engine 0.2.1
6b2471f
13d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
//! DXF of one board layer, written from the board model (mm, y up) for a bridge whose tool has no headless DXF exporter
//! (Altium's runs only through a dialog). One file text per layer, R12 entities: Edge.Cuts as lines and arcs or one
//! closed polyline (`contours`), copper as tracks and arcs with their width, pads, vias and pours, any other layer's
//! lines and arcs by the standard's name or the tool's own id (L<id>). No I/O: the bridge writes the text to files.
use serde_json::{json, Value};
/// Altium layer ids for the standard's non-copper layer names (the copper ones come through the stack).
fn named_layer(name: &str) -> Option<i64> {
Some(match name {
"F.SilkS" | "F.Silkscreen" => 33,
"B.SilkS" | "B.Silkscreen" => 34,
"F.Paste" => 35,
"B.Paste" => 36,
"F.Mask" => 37,
"B.Mask" => 38,
_ => {
// Mechanical<n> / M<n> / Altium's own L<id>
if let Some(n) = name.strip_prefix("Mechanical").or_else(|| name.strip_prefix('M')).and_then(|n| n.parse::<i64>().ok()) {
return (1..=32).contains(&n).then_some(56 + n);
}
return name.strip_prefix('L').and_then(|n| n.parse().ok());
}
})
}
pub(super) fn is_copper(name: &str) -> bool {
name == "F.Cu" || name == "B.Cu" || (name.starts_with("In") && name.ends_with(".Cu"))
}
pub(super) fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }
/// DXF R12 text, one entity list. `scale` converts mm to the file's unit.
struct Dxf { body: String, n: usize, scale: f64 }
impl Dxf {
fn new(scale: f64) -> Self { Dxf { body: String::new(), n: 0, scale } }
fn num(&self, v: f64) -> String { format!("{:.6}", v * self.scale) }
fn line(&mut self, layer: &str, a: (f64, f64), b: (f64, f64)) {
self.body += &format!("0\nLINE\n8\n{layer}\n10\n{}\n20\n{}\n11\n{}\n21\n{}\n", self.num(a.0), self.num(a.1), self.num(b.0), self.num(b.1));
self.n += 1;
}
fn circle(&mut self, layer: &str, c: (f64, f64), r: f64) {
self.body += &format!("0\nCIRCLE\n8\n{layer}\n10\n{}\n20\n{}\n40\n{}\n", self.num(c.0), self.num(c.1), self.num(r));
self.n += 1;
}
/// counter-clockwise from a1 to a2, degrees
fn arc(&mut self, layer: &str, c: (f64, f64), r: f64, a1: f64, a2: f64) {
self.body += &format!("0\nARC\n8\n{layer}\n10\n{}\n20\n{}\n40\n{}\n50\n{a1:.6}\n51\n{a2:.6}\n", self.num(c.0), self.num(c.1), self.num(r));
self.n += 1;
}
/// A polyline of (x, y, bulge) vertices; width for a track's copper.
fn polyline(&mut self, layer: &str, pts: &[(f64, f64, f64)], closed: bool, width: f64) {
self.body += &format!("0\nPOLYLINE\n8\n{layer}\n66\n1\n70\n{}\n40\n{}\n41\n{}\n", if closed { 1 } else { 0 }, self.num(width), self.num(width));
for (x, y, b) in pts {
self.body += &format!("0\nVERTEX\n8\n{layer}\n10\n{}\n20\n{}\n", self.num(*x), self.num(*y));
if *b != 0.0 { self.body += &format!("42\n{b:.6}\n"); }
}
self.body += "0\nSEQEND\n";
self.n += 1;
}
fn text(&self, insunits: i64) -> String {
format!("0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1009\n9\n$INSUNITS\n70\n{insunits}\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n{}0\nENDSEC\n0\nEOF\n", self.body)
}
}
/// The bulge of an arc drawn from `start` (counter-clockwise from a1 to a2 when start is at a1, else clockwise).
pub(super) fn bulge(e: &Value) -> f64 {
let (a1, a2) = (f(&e["angle1"]), f(&e["angle2"]));
let mut sweep = (a2 - a1).rem_euclid(360.0);
if sweep == 0.0 { sweep = 360.0; }
let c = (f(&e["center"][0]), f(&e["center"][1]));
let s = (f(&e["start"][0]), f(&e["start"][1]));
let at_a1 = ((s.1 - c.1).atan2(s.0 - c.0).to_degrees() - a1).rem_euclid(360.0);
let ccw = at_a1 < 1.0 || at_a1 > 359.0;
let b = (sweep.to_radians() / 4.0).tan();
if ccw { b } else { -b }
}
/// A pad's outline on its layer: round as a circle (or an obround), everything else as its rotated rectangle.
fn pad(d: &mut Dxf, layer: &str, p: &Value) {
let (x, y, w, h) = (f(&p["x"]), f(&p["y"]), f(&p["sizeX"]), f(&p["sizeY"]));
let rot = f(&p["rotation"]).to_radians();
let shape = p["shape"].as_str().unwrap_or("rect");
if shape == "round" && (w - h).abs() < 1e-6 {
d.circle(layer, (x, y), w / 2.0);
return;
}
let r = |dx: f64, dy: f64| (x + dx * rot.cos() - dy * rot.sin(), y + dx * rot.sin() + dy * rot.cos());
if shape == "round" {
// an obround: two straight sides and two half circles
let (l, rad) = if w > h { ((w - h) / 2.0, h / 2.0) } else { ((h - w) / 2.0, w / 2.0) };
let pts: Vec<(f64, f64, f64)> = if w > h {
vec![(-l, -rad, 0.0), (l, -rad, 1.0), (l, rad, 0.0), (-l, rad, 1.0)]
} else {
vec![(rad, -l, 0.0), (rad, l, 1.0), (-rad, l, 0.0), (-rad, -l, 1.0)]
}.into_iter().map(|(a, b, bu)| { let (px, py) = r(a, b); (px, py, bu) }).collect();
d.polyline(layer, &pts, true, 0.0);
return;
}
let pts: Vec<(f64, f64, f64)> = [(-w / 2.0, -h / 2.0), (w / 2.0, -h / 2.0), (w / 2.0, h / 2.0), (-w / 2.0, h / 2.0)]
.iter().map(|(a, b)| { let (px, py) = r(*a, *b); (px, py, 0.0) }).collect();
d.polyline(layer, &pts, true, 0.0);
}
/// One layer of the board as DXF text, with how many entities it holds. `layer` is a standard name (Edge.Cuts, F.Cu,
/// In1.Cu, B.Cu, F.SilkS, F.Mask, Mechanical1 ...) or Altium's own L<id>. None: a name this writer does not know.
/// Pours are drawn as drawn (`polygons`); `layer_dxf_with` draws the copper actually poured instead.
pub fn layer_dxf(state: &Value, layer: &str, contours: bool, inches: bool) -> Option<(String, usize)> {
layer_dxf_with(state, &[], layer, contours, inches)
}
/// `layer_dxf` with pour_state's `zones`: on a copper layer that has poured fills, each island is drawn with its holes
/// (the real copper, clearances and thermal reliefs included) in place of the pour as drawn.
pub fn layer_dxf_with(state: &Value, zones: &[Value], layer: &str, contours: bool, inches: bool) -> Option<(String, usize)> {
let mut d = Dxf::new(if inches { 1.0 / 25.4 } else { 1.0 });
let name = layer.replace('.', "_");
let arr = |k: &str| state[k].as_array().cloned().unwrap_or_default();
if layer == "Edge.Cuts" {
let edges = arr("outlineEdges");
if contours && !edges.is_empty() {
let pts: Vec<(f64, f64, f64)> = edges.iter().map(|e| (f(&e["start"][0]), f(&e["start"][1]), if e["kind"] == "arc" { bulge(e) } else { 0.0 })).collect();
d.polyline(&name, &pts, true, 0.0);
} else {
for e in &edges {
if e["kind"] == "arc" {
d.arc(&name, (f(&e["center"][0]), f(&e["center"][1])), f(&e["radius"]), f(&e["angle1"]), f(&e["angle2"]));
} else {
d.line(&name, (f(&e["start"][0]), f(&e["start"][1])), (f(&e["end"][0]), f(&e["end"][1])));
}
}
}
return Some((d.text(if inches { 1 } else { 4 }), d.n));
}
let copper = is_copper(layer);
let id = if copper { None } else { named_layer(layer) };
if !copper && id.is_none() && !state["tracks"].as_array().into_iter().flatten().chain(state["arcs"].as_array().into_iter().flatten()).any(|t| t["layer"].as_str() == Some(layer)) {
return None;
}
// a layer by its name, or (a tool that names non-copper layers by id) by the id the standard name maps to
let on = |v: &Value| v["layer"].as_str() == Some(layer) || (!copper && id.is_some() && v["layerId"].as_i64() == id);
for t in arr("tracks").iter().filter(|t| on(t)) {
let w = f(&t["width"]);
d.polyline(&name, &[(f(&t["start"]["x"]), f(&t["start"]["y"]), 0.0), (f(&t["end"]["x"]), f(&t["end"]["y"]), 0.0)], false, w);
}
for a in arr("arcs").iter().filter(|a| on(a)) {
let (c, r) = ((f(&a["center"]["x"]), f(&a["center"]["y"])), f(&a["radius"]));
let (a1, a2) = (f(&a["startAngle"]), f(&a["endAngle"]));
let mut sweep = (a2 - a1).rem_euclid(360.0);
if sweep == 0.0 { sweep = 360.0; }
let p = |deg: f64| (c.0 + r * deg.to_radians().cos(), c.1 + r * deg.to_radians().sin());
let (s, e) = (p(a1), p(a1 + sweep));
d.polyline(&name, &[(s.0, s.1, (sweep.to_radians() / 4.0).tan()), (e.0, e.1, 0.0)], false, f(&a["width"]));
}
if copper {
for p in arr("pads").iter().filter(|p| p["layer"].as_str() == Some(layer) || p["layer"].as_str() == Some("*.Cu")) {
pad(&mut d, &name, p);
}
for v in arr("vias") {
let ls: Vec<&str> = v["layers"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect()).unwrap_or_default();
// a through via is on every copper layer; a blind or buried one on its ends (the model names only those)
let through = ls.first() == Some(&"F.Cu") && ls.last() == Some(&"B.Cu");
if through || ls.contains(&layer) {
d.circle(&name, (f(&v["position"]["x"]), f(&v["position"]["y"])), f(&v["size"]) / 2.0);
}
}
let fills: Vec<Value> = zones.iter().filter(|z| z["layer"].as_str() == Some(layer))
.flat_map(|z| z["fills"].as_array().cloned().unwrap_or_default()).collect();
let ring = |v: &Value| -> Vec<(f64, f64, f64)> { v.as_array().map(|ps| ps.iter().map(|p| (f(&p[0]), f(&p[1]), 0.0)).collect()).unwrap_or_default() };
if !fills.is_empty() {
// the copper actually poured: each island's outline and its holes (a fractured fill is one ring: same copper)
for fl in &fills {
for r in std::iter::once(ring(&fl["outline"])).chain(fl["holes"].as_array().cloned().unwrap_or_default().iter().map(ring)) {
if r.len() > 2 { d.polyline(&name, &r, true, 0.0); }
}
}
} else {
for g in arr("polygons").iter().filter(|g| g["layer"].as_str() == Some(layer)) {
let pts = ring(&g["points"]);
if pts.len() > 2 { d.polyline(&name, &pts, true, 0.0); }
}
}
}
Some((d.text(if inches { 1 } else { 4 }), d.n))
}
/// writers::dxf_layers: `{board, pours?, args: {layers, contours?, units?: mm|in}}` -> one DXF text per layer. With
/// `pours` (pour_state) a copper layer shows the copper actually poured; without, the pours as drawn.
pub fn dxf_layers(input: &Value) -> Value {
let board = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };
let zones = crate::input::pours(input);
let args = input.get("args").cloned().unwrap_or(json!({}));
let layers: Vec<String> = args.get("layers").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
if layers.is_empty() { return crate::input::refuse("bad_args", "args.layers is required: [\"Edge.Cuts\", \"F.Cu\", ...]"); }
let inches = args.get("units").and_then(|v| v.as_str()) == Some("in");
let contours = args.get("contours").and_then(|v| v.as_bool()) == Some(true);
let (mut files, mut unknown) = (Vec::new(), Vec::new());
for l in &layers {
match layer_dxf_with(&board, &zones, l, contours, inches) {
Some((text, n)) => files.push(json!({"layer": l, "name": format!("{}.dxf", l.replace('.', "_")), "entities": n, "text": text})),
None => unknown.push(l.clone()),
}
}
let poured_layers: Vec<&String> = layers.iter().filter(|l| zones.iter().any(|z| z["layer"].as_str() == Some(l.as_str()))).collect();
let as_drawn: Vec<&String> = layers.iter().filter(|l| !poured_layers.contains(l) && board["polygons"].as_array().map(|a| a.iter().any(|g| g["layer"].as_str() == Some(l.as_str()))).unwrap_or(false)).collect();
json!({"success": unknown.is_empty() && !files.is_empty(), "files": files, "unknownLayers": unknown, "units": if inches { "in" } else { "mm" },
"writtenFrom": "board model", "poursAsDrawn": as_drawn,
"_hint": "Written from the board model, not the tool's own exporter: outline, tracks, arcs, pads, vias and pours (texts are not drawn). Edge.Cuts with contours:true is one closed polyline, what CAM and laser tools want. poursAsDrawn lists layers whose pours are the outlines as drawn, not the copper poured: pass pours (pour_state) for the real copper."})
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// The Altium lane board from ADOM_EDA_FIXTURES (altium-lane's fixtures), None when it is not set.
pub(crate) fn lane() -> Option<Value> {
let p = std::path::PathBuf::from(std::env::var_os("ADOM_EDA_FIXTURES")?).join("altium").join("lane.routing_state.json");
let v: Value = serde_json::from_str(&std::fs::read_to_string(p).ok()?).unwrap();
crate::input::board(&json!({"board": v.get("reply").cloned().unwrap_or(v)})).ok()
}
#[test]
fn the_outline_is_eight_edges_or_one_closed_polyline() {
let Some(st) = lane() else { return };
let (t, n) = layer_dxf(&st, "Edge.Cuts", false, false).unwrap();
assert_eq!(n, 8);
assert_eq!(t.matches("\nARC\n").count(), 4);
let (t, n) = layer_dxf(&st, "Edge.Cuts", true, false).unwrap();
assert_eq!(n, 1);
assert_eq!(t.matches("\nVERTEX\n").count(), 8);
// a corner arc turns a quarter: bulge tan(90/4)
assert!(t.contains("42\n0.414214\n"), "{t}");
assert!(t.ends_with("0\nEOF\n"));
}
#[test]
fn copper_holds_its_tracks_pads_and_through_vias() {
let Some(st) = lane() else { return };
let (_, top) = layer_dxf(&st, "F.Cu", false, false).unwrap();
let tracks = st["tracks"].as_array().unwrap().iter().filter(|t| t["layer"] == "F.Cu").count();
let pads = st["pads"].as_array().unwrap().iter().filter(|p| p["layer"] == "F.Cu" || p["layer"] == "*.Cu").count();
let vias = st["vias"].as_array().unwrap().len();
let arcs = st["arcs"].as_array().unwrap().iter().filter(|a| a["layer"] == "F.Cu").count();
assert_eq!(top, tracks + arcs + pads + vias);
assert!(layer_dxf(&st, "Mechanical1", false, false).unwrap().1 > 0, "the L57 lines");
assert!(layer_dxf(&st, "Dwgs.User", false, false).is_none());
}
#[test]
fn inches_scale_every_coordinate() {
let Some(st) = lane() else { return };
let (t, _) = layer_dxf(&st, "Edge.Cuts", false, true).unwrap();
assert!(t.contains("$INSUNITS\n70\n1\n"));
assert!(t.contains(&format!("{:.6}", 40.0 / 25.4)));
}
#[test]
fn a_layer_by_its_standard_name_and_the_writer_entry() {
let board = json!({"outlineEdges": [{"kind": "line", "start": [0.0, 0.0], "end": [10.0, 0.0]}, {"kind": "line", "start": [10.0, 0.0], "end": [10.0, 5.0]},
{"kind": "line", "start": [10.0, 5.0], "end": [0.0, 5.0]}, {"kind": "line", "start": [0.0, 5.0], "end": [0.0, 0.0]}],
"tracks": [{"layer": "F.Cu", "net": "A", "width": 0.2, "start": {"x": 1.0, "y": 1.0}, "end": {"x": 9.0, "y": 1.0}},
{"layer": "F.SilkS", "net": "", "width": 0.15, "start": {"x": 1.0, "y": 4.0}, "end": {"x": 9.0, "y": 4.0}}],
"arcs": [], "pads": [], "vias": [], "polygons": []});
let r = dxf_layers(&json!({"board": board, "args": {"layers": ["Edge.Cuts", "F.Cu", "F.SilkS", "Dwgs.User"], "contours": true}}));
assert_eq!(r["files"].as_array().unwrap().len(), 3);
assert_eq!(r["unknownLayers"], json!(["Dwgs.User"]));
assert_eq!(r["writtenFrom"], "board model");
assert_eq!(r["files"][2]["entities"], 1, "the silkscreen line, found by its name");
assert!(r["files"][0]["text"].as_str().unwrap().contains("\nPOLYLINE\n"));
assert_eq!(dxf_layers(&json!({"board": {}, "args": {}}))["errorCode"], "bad_args");
}
#[test]
fn poured_fills_replace_the_pour_as_drawn() {
let st = json!({"outline": [[0, 0], [10, 0], [10, 10], [0, 10]], "tracks": [], "arcs": [], "pads": [], "vias": [],
"polygons": [{"net": "GND", "layer": "B.Cu", "points": [[0, 0], [10, 0], [10, 10], [0, 10]]}]});
let zones = vec![json!({"net": "GND", "layer": "B.Cu", "fills": [{"outline": [[1, 1], [9, 1], [9, 9], [1, 9]], "holes": [[[4, 4], [6, 4], [6, 6], [4, 6]]]}]})];
// as drawn: one outline; poured: the island and its hole
assert_eq!(layer_dxf(&st, "B.Cu", false, false).unwrap().1, 1);
assert_eq!(layer_dxf_with(&st, &zones, "B.Cu", false, false).unwrap().1, 2);
let r = dxf_layers(&json!({"board": st, "args": {"layers": ["B.Cu"]}}));
assert_eq!(r["poursAsDrawn"], json!(["B.Cu"]));
let r = dxf_layers(&json!({"board": st, "pours": {"zones": zones}, "args": {"layers": ["B.Cu"]}}));
assert_eq!(r["poursAsDrawn"], json!([]));
}
}