app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Vendored engine src/writers/ and the current Cargo.lock (1.13.2 was missing both, so master did not build from source); release.sh now pushes the whole tracked rust/ tree
6 files changed
+1004−8
rust/Cargo.lock+50−6@@ -10,14 +10,16 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-eda-checks"-version = "0.1.4"+version = "0.2.1" dependencies = [+ "roxmltree", "serde_json",+ "svgtypes", ] [[package]] name = "adom-fusion-bridge"-version = "1.13.0"+version = "1.13.2" dependencies = [ "adom-eda-checks", "base64",@@ -77,6 +79,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]]+name = "arrayvec"+version = "0.7.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"+ [[package]] name = "ascii" version = "1.1.0"@@ -233,6 +241,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]]+name = "euclid"+version = "0.22.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"+dependencies = [+ "num-traits",+]+ [[package]] name = "fallible-iterator" version = "0.3.0"@@ -282,7 +299,7 @@ dependencies = [ [[package]] name = "fusion-core"-version = "1.13.0"+version = "1.13.2" dependencies = [ "serde", "serde_json",@@ -293,7 +310,7 @@ dependencies = [ [[package]] name = "fusion-lbr"-version = "1.13.0"+version = "1.13.2" dependencies = [ "regex", "roxmltree",@@ -305,14 +322,14 @@ dependencies = [ [[package]] name = "fusion-os-mac"-version = "1.13.0"+version = "1.13.2" dependencies = [ "fusion-core", ] [[package]] name = "fusion-os-win"-version = "1.13.0"+version = "1.13.2" dependencies = [ "fusion-core", "windows-sys 0.59.0",@@ -548,6 +565,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]]+name = "kurbo"+version = "0.11.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"+dependencies = [+ "arrayvec",+ "euclid",+ "smallvec",+]+ [[package]] name = "libc" version = "0.2.189"@@ -847,6 +875,12 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]]+name = "siphasher"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"+ [[package]] name = "slab" version = "0.4.12"@@ -871,6 +905,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]]+name = "svgtypes"+version = "0.15.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"+dependencies = [+ "kurbo",+ "siphasher",+]+ [[package]] name = "syn" version = "2.0.119"
rust/vendor/adom-eda-checks/src/writers/dxf.rsadded+285@@ -0,0 +1,285 @@+//! 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!([]));+ }+}
rust/vendor/adom-eda-checks/src/writers/gencad.rsadded+285@@ -0,0 +1,285 @@+//! GenCAD 1.4 written from the board model (mm, y up), for a bridge whose tool has no GenCAD exporter (Altium has none).+//! Outline, one PAD and PADSTACK per pad shape and drill, one SHAPE per footprint with pins in its own frame (the part's+//! rotation removed, a bottom part mirrored back to the top view), COMPONENT (bottom parts MIRRORX FLIP), DEVICE,+//! SIGNAL/NODE, TRACK codes by width and ROUTE with LINE/ARC/VIA, in inches. The syntax follows a file KiCad 10 wrote.+//! No I/O: the bridge writes the text.+use serde_json::{json, Value};++use super::dxf::{bulge, f, is_copper};++/// GenCAD's name for a copper layer of the board model.+fn gencad_layer(l: &str) -> String {+ match l {+ "F.Cu" => "TOP".into(),+ "B.Cu" => "BOTTOM".into(),+ _ => l.strip_prefix("In").and_then(|n| n.strip_suffix(".Cu")).map(|n| format!("INNER{n}")).unwrap_or_else(|| "TOP".into()),+ }+}++fn q(s: &str) -> String { format!("\"{}\"", s.replace('"', "'")) }++/// The board as GenCAD 1.4 text, in inches as fixture and assembly tools expect: outline, pads and padstacks, one+/// shape per footprint (pins in the footprint's own frame), components, devices, signals, and the routes with their+/// vias. Returns the text and counts.+pub fn gencad_text(state: &Value, title: &str, unique_pins: bool) -> (String, Value) {+ let inch = |mm: f64| format!("{:.6}", mm / 25.4);+ let arr = |k: &str| state[k].as_array().cloned().unwrap_or_default();+ let mut o = String::new();+ o += &format!("$HEADER\nGENCAD 1.4\nUSER {}\nDRAWING {}\nREVISION {}\nUNITS INCH\nORIGIN 0 0\nINTERTRACK 0\n$ENDHEADER\n",+ q(&format!("Adom EDA engine {}", env!("CARGO_PKG_VERSION"))), q(title), q("1"));+ // the outline+ o += "$BOARD\n";+ for e in arr("outlineEdges") {+ let (s, t) = ((f(&e["start"][0]), f(&e["start"][1])), (f(&e["end"][0]), f(&e["end"][1])));+ if e["kind"] == "arc" {+ // GenCAD arcs run counter-clockwise from the first point to the second+ let (a, b) = if bulge(&e) >= 0.0 { (s, t) } else { (t, s) };+ o += &format!("ARC {} {} {} {} {} {}\n", inch(a.0), inch(a.1), inch(b.0), inch(b.1), inch(f(&e["center"][0])), inch(f(&e["center"][1])));+ } else {+ o += &format!("LINE {} {} {} {}\n", inch(s.0), inch(s.1), inch(t.0), inch(t.1));+ }+ }+ o += "$ENDBOARD\n";+ // pads: one per shape, size and drill, in the footprint's frame+ let comps = arr("components");+ let pads = arr("pads");+ let mut pad_defs: Vec<(String, f64, f64, f64)> = Vec::new(); // (shape, w, h, drill)+ let mut pad_name = |shape: &str, w: f64, h: f64, drill: f64| -> usize {+ let k = (shape.to_string(), (w * 1000.0).round() / 1000.0, (h * 1000.0).round() / 1000.0, (drill * 1000.0).round() / 1000.0);+ match pad_defs.iter().position(|p| *p == k) { Some(i) => i, None => { pad_defs.push(k); pad_defs.len() - 1 } }+ };+ // shapes: one per footprint pattern and pad geometry. Two parts share a shape only when their pins match in the+ // footprint's frame: two different footprints of the same name (from two libraries) get NAME and NAME_2 (#17)+ struct Pin { name: String, pad: usize, x: f64, y: f64, rot: f64, through: bool }+ let mut shapes: Vec<(String, Vec<Pin>, bool)> = Vec::new();+ let mut shape_of: Vec<(String, String)> = Vec::new(); // ref -> shape name+ // the same pins within 5 um: the board model keeps positions to 1 um, so a rotated part's pins come back a micron off+ // (BQ25792's R3 against R5: 2.319 against 2.320 mm), and float noise turns 0 into -1e-16+ let same_pins = |a: &[Pin], b: &[Pin]| a.len() == b.len() && a.iter().zip(b).all(|(p, q)| p.name == q.name && p.pad == q.pad+ && (p.x - q.x).abs() < 0.005 && (p.y - q.y).abs() < 0.005 && (p.rot - q.rot).abs() < 0.05);+ for c in &comps {+ let rf = c["ref"].as_str().unwrap_or("").to_string();+ let pattern = c["pattern"].as_str().unwrap_or("").to_string();+ let base = if pattern.is_empty() { format!("SHAPE_{rf}") } else { pattern.clone() };+ let (cx, cy, th) = (f(&c["x"]), f(&c["y"]), f(&c["rotation"]).to_radians());+ let bottom = c["side"] == "bottom";+ let mut pins = Vec::new();+ let mut seen: Vec<String> = Vec::new();+ for p in pads.iter().filter(|p| p["ref"].as_str() == Some(&rf)) {+ let (dx, dy) = (f(&p["x"]) - cx, f(&p["y"]) - cy);+ let (mut lx, ly) = (dx * th.cos() + dy * th.sin(), -dx * th.sin() + dy * th.cos());+ if bottom { lx = -lx; }+ let mut rot = (f(&p["rotation"]) - f(&c["rotation"])).rem_euclid(360.0);+ if bottom { rot = (360.0 - rot).rem_euclid(360.0); }+ let (mut w, mut h) = (f(&p["sizeX"]), f(&p["sizeY"]));+ // the pad's own size along the footprint's axes+ if (rot - 90.0).abs() < 0.5 || (rot - 270.0).abs() < 0.5 { std::mem::swap(&mut w, &mut h); rot = 0.0; }+ if rot.abs() < 0.5 || (rot - 180.0).abs() < 0.5 { rot = 0.0; }+ let through = f(&p["drill"]) > 0.0;+ let mut nm = p["name"].as_str().unwrap_or("").to_string();+ if unique_pins && seen.contains(&nm) {+ let mut k = 2;+ while seen.contains(&format!("{nm}_{k}")) { k += 1; }+ nm = format!("{nm}_{k}");+ }+ seen.push(nm.clone());+ let id = pad_name(p["shape"].as_str().unwrap_or("rect"), w, h, f(&p["drill"]));+ pins.push(Pin { name: nm, pad: id, x: lx, y: ly, rot, through });+ }+ // the same pattern with the same pins: reuse that shape; the same name with other pins: a new name+ if let Some(existing) = shapes.iter().find(|(n, ps, _)| (*n == base || n.starts_with(&format!("{base}_"))) && same_pins(ps, &pins)) {+ shape_of.push((rf, existing.0.clone()));+ continue;+ }+ let mut name = base.clone();+ let mut k = 2;+ while shapes.iter().any(|x| x.0 == name) { name = format!("{base}_{k}"); k += 1; }+ shape_of.push((rf, name.clone()));+ let th_part = pins.iter().any(|p| p.through);+ shapes.push((name, pins, th_part));+ }+ // vias: one pad per size and drill+ let vias = arr("vias");+ let via_pad: Vec<usize> = vias.iter().map(|v| pad_name("round", f(&v["size"]), f(&v["size"]), f(&v["drill"]))).collect();+ o += "$PADS\n";+ for (i, (shape, w, h, drill)) in pad_defs.iter().enumerate() {+ let d = if *drill > 0.0 { inch(*drill) } else { "0".into() };+ if shape == "round" && (w - h).abs() < 1e-6 {+ o += &format!("PAD P{i} ROUND {d}\nCIRCLE 0 0 {}\n", inch(w / 2.0));+ } else if shape == "round" {+ o += &format!("PAD P{i} FINGER {d}\nRECTANGLE {} {} {} {}\n", inch(-w / 2.0), inch(-h / 2.0), inch(*w), inch(*h));+ } else {+ o += &format!("PAD P{i} RECTANGULAR {d}\nRECTANGLE {} {} {} {}\n", inch(-w / 2.0), inch(-h / 2.0), inch(*w), inch(*h));+ }+ }+ o += "$ENDPADS\n$PADSTACKS\n";+ let coppers: Vec<String> = {+ let mut ls: Vec<String> = arr("tracks").iter().chain(pads.iter()).filter_map(|x| x["layer"].as_str()).filter(|l| is_copper(l)).map(str::to_string).collect();+ ls.push("F.Cu".into()); ls.push("B.Cu".into());+ ls.sort_by_key(|l| match l.as_str() { "F.Cu" => 0, "B.Cu" => 999, x => x.trim_start_matches("In").trim_end_matches(".Cu").parse::<i32>().unwrap_or(500) });+ ls.dedup();+ ls+ };+ for (i, (_, _, _, drill)) in pad_defs.iter().enumerate() {+ let d = if *drill > 0.0 { inch(*drill) } else { "0".into() };+ o += &format!("PADSTACK PS{i} {d}\n");+ if *drill > 0.0 {+ for l in &coppers { o += &format!("PAD P{i} {} 0 0\n", gencad_layer(l)); }+ } else {+ o += &format!("PAD P{i} TOP 0 0\n");+ }+ }+ o += "$ENDPADSTACKS\n$SHAPES\n";+ for (name, pins, th) in &shapes {+ o += &format!("SHAPE {}\nINSERT {}\n", q(name), if *th { "TH" } else { "SMD" });+ for p in pins {+ o += &format!("PIN {} PS{} {} {} TOP {} 0\n", q(&p.name), p.pad, inch(p.x), inch(p.y), p.rot);+ }+ }+ o += "$ENDSHAPES\n$COMPONENTS\n";+ let mut devices: Vec<String> = Vec::new();+ for c in &comps {+ let rf = c["ref"].as_str().unwrap_or("");+ let shape = shape_of.iter().find(|s| s.0 == rf).map(|s| s.1.clone()).unwrap_or_default();+ let bottom = c["side"] == "bottom";+ let dev = format!("DEV_{shape}");+ if !devices.contains(&dev) { devices.push(dev.clone()); }+ o += &format!("COMPONENT {}\nDEVICE {}\nPLACE {} {}\nLAYER {}\nROTATION {}\nSHAPE {} {} {}\n",+ q(rf), q(&dev), inch(f(&c["x"])), inch(f(&c["y"])), if bottom { "BOTTOM" } else { "TOP" }, f(&c["rotation"]), q(&shape),+ if bottom { "MIRRORX" } else { "0" }, if bottom { "FLIP" } else { "0" });+ }+ o += "$ENDCOMPONENTS\n$DEVICES\n";+ for d in &devices { o += &format!("DEVICE {}\nPACKAGE {}\n", q(d), q(d.trim_start_matches("DEV_"))); }+ o += "$ENDDEVICES\n$SIGNALS\n";+ let mut nets: Vec<String> = pads.iter().filter_map(|p| p["net"].as_str()).filter(|n| !n.is_empty()).map(str::to_string).collect();+ nets.sort(); nets.dedup();+ for n in &nets {+ o += &format!("SIGNAL {}\n", q(n));+ for p in pads.iter().filter(|p| p["net"].as_str() == Some(n) && !p["ref"].as_str().unwrap_or("").is_empty()) {+ o += &format!("NODE {} {}\n", q(p["ref"].as_str().unwrap_or("")), q(p["name"].as_str().unwrap_or("")));+ }+ }+ o += "$ENDSIGNALS\n";+ // tracks by width, then each net's copper+ let tracks: Vec<Value> = arr("tracks").into_iter().filter(|t| t["layer"].as_str().map(is_copper).unwrap_or(false)).collect();+ let arcs: Vec<Value> = arr("arcs").into_iter().filter(|t| t["layer"].as_str().map(is_copper).unwrap_or(false)).collect();+ let mut widths: Vec<f64> = tracks.iter().chain(arcs.iter()).map(|t| (f(&t["width"]) * 1000.0).round() / 1000.0).collect();+ widths.sort_by(|a, b| a.partial_cmp(b).unwrap()); widths.dedup();+ o += "$TRACKS\n";+ for (i, w) in widths.iter().enumerate() { o += &format!("TRACK T{i} {}\n", inch(*w)); }+ o += "$ENDTRACKS\n$ROUTES\n";+ let code = |w: f64| widths.iter().position(|x| (*x - (w * 1000.0).round() / 1000.0).abs() < 1e-9).unwrap_or(0);+ let mut route_nets: Vec<String> = tracks.iter().chain(arcs.iter()).filter_map(|t| t["net"].as_str()).chain(vias.iter().filter_map(|v| v["net"].as_str())).filter(|n| !n.is_empty()).map(str::to_string).collect();+ route_nets.sort(); route_nets.dedup();+ let mut unrouted_copper = 0;+ for t in tracks.iter().chain(arcs.iter()) { if t["net"].as_str().unwrap_or("").is_empty() { unrouted_copper += 1; } }+ for n in &route_nets {+ o += &format!("ROUTE {}\n", q(n));+ for t in tracks.iter().filter(|t| t["net"].as_str() == Some(n)) {+ o += &format!("TRACK T{}\nLAYER {}\nLINE {} {} {} {}\n", code(f(&t["width"])), gencad_layer(t["layer"].as_str().unwrap_or("F.Cu")),+ inch(f(&t["start"]["x"])), inch(f(&t["start"]["y"])), inch(f(&t["end"]["x"])), inch(f(&t["end"]["y"])));+ }+ for a in arcs.iter().filter(|a| a["net"].as_str() == Some(n)) {+ let (c, r) = ((f(&a["center"]["x"]), f(&a["center"]["y"])), f(&a["radius"]));+ let p = |deg: f64| (c.0 + r * deg.to_radians().cos(), c.1 + r * deg.to_radians().sin());+ let (s, e) = (p(f(&a["startAngle"])), p(f(&a["endAngle"])));+ o += &format!("TRACK T{}\nLAYER {}\nARC {} {} {} {} {} {}\n", code(f(&a["width"])), gencad_layer(a["layer"].as_str().unwrap_or("F.Cu")),+ inch(s.0), inch(s.1), inch(e.0), inch(e.1), inch(c.0), inch(c.1));+ }+ for (i, v) in vias.iter().enumerate().filter(|(_, v)| v["net"].as_str() == Some(n)) {+ // as KiCad writes it: the padstack, the position, the layers and the drill+ o += &format!("VIA PS{} {} {} ALL {} VIA{i}\n", via_pad[i], inch(f(&v["position"]["x"])), inch(f(&v["position"]["y"])), inch(f(&v["drill"])));+ }+ }+ o += "$ENDROUTES\n";+ let counts = json!({"components": comps.len(), "shapes": shapes.len(), "pads": pad_defs.len(), "signals": nets.len(), "routes": route_nets.len(),+ "vias": vias.len(), "copperWithoutNet": unrouted_copper});+ (o, counts)+}++/// writers::gencad: `{board, args: {title?, uniquePins?}}` -> the GenCAD text and what it holds.+pub fn gencad(input: &Value) -> Value {+ let board = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = input.get("args").cloned().unwrap_or(json!({}));+ let title = args.get("title").and_then(|v| v.as_str()).unwrap_or("board");+ let (text, counts) = gencad_text(&board, title, args.get("uniquePins").and_then(|v| v.as_bool()) == Some(true));+ json!({"success": true, "text": text, "counts": counts, "units": "in", "writtenFrom": "board model",+ "_hint": "GenCAD 1.4 written from the board model, not the tool's own exporter. copperWithoutNet counts tracks on no net, which no route can carry."})+}++#[cfg(test)]+mod tests {+ use super::*;+ use super::super::dxf::tests::lane;++ #[test]+ fn gencad_has_every_section_and_the_lane_parts() {+ let Some(st) = lane() else { return };+ let (t, c) = gencad_text(&st, "lane", false);+ for s in ["HEADER", "BOARD", "PADS", "PADSTACKS", "SHAPES", "COMPONENTS", "DEVICES", "SIGNALS", "TRACKS", "ROUTES"] {+ assert_eq!(t.matches(&format!("${s}\n")).count(), 1, "{s}");+ assert_eq!(t.matches(&format!("$END{s}\n")).count(), 1, "{s}");+ }+ assert_eq!(c["components"], json!(st["components"].as_array().unwrap().len()));+ assert_eq!(t.matches("\nCOMPONENT ").count(), st["components"].as_array().unwrap().len());+ // the outline: four lines and four corner arcs, a 40 x 20 mm board in inches+ assert_eq!(t.split("$ENDBOARD").next().unwrap().matches("\nARC ").count(), 4);+ assert!(t.contains(&format!("{:.6}", 40.0 / 25.4)));+ // every pin of a SOIC-8 sits in its own frame: x is +-2.7 mm whatever the part's rotation+ let shape = t.split("SHAPE \"TST_SOIC8\"\n").nth(1).unwrap().split("SHAPE ").next().unwrap();+ assert_eq!(shape.matches("PIN ").count(), 8);+ for l in shape.lines().filter(|l| l.starts_with("PIN ")) {+ let x: f64 = l.split_whitespace().nth(3).unwrap().parse().unwrap();+ assert!((x.abs() * 25.4 - 2.7).abs() < 0.01, "{l}");+ }+ }++ #[test]+ fn duplicate_pin_names_are_made_unique_on_request() {+ let st = json!({"components": [{"ref": "J1", "pattern": "TAB", "x": 0.0, "y": 0.0, "rotation": 0.0, "side": "top"}],+ "pads": [{"ref": "J1", "name": "1", "x": 0.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "shape": "rect", "drill": 0.0, "rotation": 0.0, "layer": "F.Cu", "net": ""},+ {"ref": "J1", "name": "1", "x": 2.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "shape": "rect", "drill": 0.0, "rotation": 0.0, "layer": "F.Cu", "net": ""}]});+ assert_eq!(gencad_text(&st, "x", false).0.matches("PIN \"1\" ").count(), 2);+ let (t, _) = gencad_text(&st, "x", true);+ assert!(t.contains("PIN \"1\" ") && t.contains("PIN \"1_2\" "));+ }++ #[test]+ fn bottom_parts_and_vias_read_as_kicad_writes_them() {+ let st = json!({"components": [{"ref": "R1", "pattern": "R0603", "x": 10.0, "y": 5.0, "rotation": 180.0, "side": "bottom"}],+ "pads": [{"ref": "R1", "name": "1", "x": 10.825, "y": 5.0, "sizeX": 0.8, "sizeY": 0.95, "shape": "rect", "drill": 0.0, "rotation": 180.0, "layer": "B.Cu", "net": "A"}],+ "vias": [{"net": "A", "drill": 0.3, "size": 0.6, "layers": ["F.Cu", "B.Cu"], "position": {"x": 10.0, "y": 7.0}}]});+ let (t, _) = gencad_text(&st, "x", false);+ assert!(t.contains("LAYER BOTTOM\nROTATION 180\nSHAPE \"R0603\" MIRRORX FLIP\n"), "{t}");+ // pad 1 is 0.825 mm left of the part's centre in its own frame (rotated 180, then mirrored back to the top view)+ assert!(t.contains(&format!("PIN \"1\" PS0 {:.6} 0.000000 TOP", 0.825 / 25.4)) || t.contains(&format!("PIN \"1\" PS0 {:.6} -0.000000 TOP", 0.825 / 25.4)), "{t}");+ assert!(t.contains(&format!("ALL {:.6} VIA0", 0.3 / 25.4)), "{t}");+ }++ #[test]+ fn the_writer_entry_says_where_it_came_from() {+ let r = gencad(&json!({"board": {"components": [], "pads": [], "outlineEdges": []}, "args": {"title": "t"}}));+ assert_eq!(r["success"], true);+ assert_eq!(r["writtenFrom"], "board model");+ assert!(r["text"].as_str().unwrap().starts_with("$HEADER\nGENCAD 1.4\n"));+ }++ #[test]+ fn same_named_footprints_with_other_pads_are_two_shapes() {+ let pad = |r: &str, n: &str, x: f64, w: f64| json!({"ref": r, "name": n, "x": x, "y": 0.0, "sizeX": w, "sizeY": 1.0, "shape": "rect", "drill": 0.0, "rotation": 0.0, "layer": "F.Cu", "net": ""});+ // R1 and R2: the same "R0603" footprint (R2 rotated 180): one shape. R3: another library's "R0603", other pads: its own+ let st = json!({"components": [{"ref": "R1", "pattern": "R0603", "x": 0.0, "y": 0.0, "rotation": 0.0, "side": "top"},+ {"ref": "R2", "pattern": "R0603", "x": 10.0, "y": 0.0, "rotation": 180.0, "side": "top"},+ {"ref": "R3", "pattern": "R0603", "x": 20.0, "y": 0.0, "rotation": 0.0, "side": "top"}],+ "pads": [pad("R1", "1", -0.8, 0.9), pad("R1", "2", 0.8, 0.9), pad("R2", "1", 10.801, 0.9), pad("R2", "2", 9.2, 0.9),+ pad("R3", "1", 19.0, 1.2), pad("R3", "2", 21.0, 1.2)]});+ let (t, _) = gencad_text(&st, "x", false);+ assert_eq!(t.matches("SHAPE \"R0603\"\nINSERT").count(), 1, "R1 and R2 share one shape");+ assert!(t.contains("SHAPE \"R0603_2\"\nINSERT"), "R3's footprint is its own shape");+ assert!(!t.contains("R0603_3"), "R2 reuses R1's shape");+ // each part points at its shape+ let comp = |r: &str| t.split(&format!("COMPONENT \"{r}\"\n")).nth(1).unwrap().lines().find(|l| l.starts_with("SHAPE ")).unwrap().to_string();+ assert!(comp("R1").starts_with("SHAPE \"R0603\"") && comp("R2").starts_with("SHAPE \"R0603\"") && comp("R3").starts_with("SHAPE \"R0603_2\""));+ }+}+
rust/vendor/adom-eda-checks/src/writers/mod.rsadded+26@@ -0,0 +1,26 @@+//! Board-file writers for a bridge whose tool has no native exporter (EDA Bridge Standard #16): GenCAD and DXF from the+//! board model, and SVG artwork as regions for a layer. Like the checks, no I/O and no tool: each returns the file text+//! or the regions as JSON, says it was written from the board model (`writtenFrom`), and the bridge writes the files or+//! places the regions. A bridge whose tool has a native exporter keeps it.+use serde_json::Value;++pub mod dxf;+pub mod gencad;+pub mod svgart;++/// The verbs these writers answer, by standard name.+pub const WRITERS: &[&str] = &["export_dxf", "export_gencad", "import_svg"];++pub use dxf::dxf_layers;+pub use gencad::gencad;+pub use svgart::svg_to_regions;++/// Run one writer by its standard verb name (None for a name that is not a writer).+pub fn run(verb: &str, input: &Value) -> Option<Value> {+ Some(match verb {+ "export_dxf" => dxf_layers(input),+ "export_gencad" => gencad(input),+ "import_svg" => svg_to_regions(input),+ _ => return None,+ })+}
rust/vendor/adom-eda-checks/src/writers/svgart.rsadded+346@@ -0,0 +1,346 @@+//! SVG artwork (a logo, a label) as filled regions for a board layer. The SVG's filled shapes are flattened to polygons+//! (paths with arcs and curves, rects, circles, ellipses, polygons, transforms), a shape inside another becomes its+//! hole, and each shape with its holes is ONE contour that reaches every hole through a zero-width slit: Altium printed a+//! region's separate hole contour filled, the slit form prints open (Develop, 2026-10-28). Text, images and stroke-only+//! shapes are listed as skipped. No I/O: the bridge places the regions.+use serde_json::{json, Value};+use std::str::FromStr;++type Pt = (f64, f64);++#[derive(Clone, Copy)]+struct Tf { a: f64, b: f64, c: f64, d: f64, e: f64, f: f64 }++impl Tf {+ fn id() -> Self { Tf { a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0, f: 0.0 } }+ /// self, then t applied inside it (SVG nesting: the parent's transform is outermost)+ fn then(&self, t: &Tf) -> Tf {+ Tf { a: self.a * t.a + self.c * t.b, b: self.b * t.a + self.d * t.b, c: self.a * t.c + self.c * t.d, d: self.b * t.c + self.d * t.d,+ e: self.a * t.e + self.c * t.f + self.e, f: self.b * t.e + self.d * t.f + self.f }+ }+ fn ap(&self, p: Pt) -> Pt { (self.a * p.0 + self.c * p.1 + self.e, self.b * p.0 + self.d * p.1 + self.f) }+ fn scale(&self) -> f64 { (self.a * self.d - self.b * self.c).abs().sqrt().max(1e-12) }+}++/// One element's style value, from its attribute or its style="" (the style wins).+fn style<'a>(n: &roxmltree::Node<'a, 'a>, key: &str) -> Option<String> {+ if let Some(s) = n.attribute("style") {+ for decl in s.split(';') {+ if let Some((k, v)) = decl.split_once(':') { if k.trim() == key { return Some(v.trim().to_string()); } }+ }+ }+ n.attribute(key).map(|v| v.trim().to_string())+}++/// The inherited value of a style property (fill, display, visibility...).+fn inherited(n: &roxmltree::Node, key: &str) -> Option<String> {+ let mut cur = Some(*n);+ while let Some(x) = cur {+ if x.is_element() { if let Some(v) = style(&x, key) { if v != "inherit" { return Some(v); } } }+ cur = x.parent();+ }+ None+}++fn num(n: &roxmltree::Node, k: &str) -> f64 {+ n.attribute(k).and_then(|v| svgtypes::Length::from_str(v).ok()).map(|l| l.number).unwrap_or(0.0)+}++/// Points along a cubic or quadratic curve, flattened to `tol` in the SVG's own units.+fn flatten_cubic(out: &mut Vec<Pt>, p0: Pt, p1: Pt, p2: Pt, p3: Pt, tol: f64) {+ let d = ((p1.0 - p0.0).hypot(p1.1 - p0.1) + (p2.0 - p1.0).hypot(p2.1 - p1.1) + (p3.0 - p2.0).hypot(p3.1 - p2.1)).max(1e-9);+ let n = ((d / tol).sqrt().ceil() as usize).clamp(2, 200);+ for i in 1..=n {+ let t = i as f64 / n as f64;+ let u = 1.0 - t;+ out.push((u * u * u * p0.0 + 3.0 * u * u * t * p1.0 + 3.0 * u * t * t * p2.0 + t * t * t * p3.0,+ u * u * u * p0.1 + 3.0 * u * u * t * p1.1 + 3.0 * u * t * t * p2.1 + t * t * t * p3.1));+ }+}++/// A path's closed subpaths, flattened. An open subpath is closed (SVG fills it as if it were).+fn path_rings(d: &str, tol: f64) -> Result<Vec<Vec<Pt>>, String> {+ let mut rings = Vec::new();+ let mut cur: Vec<Pt> = Vec::new();+ let mut last = (0.0, 0.0);+ for seg in svgtypes::SimplifyingPathParser::from(d) {+ match seg.map_err(|e| e.to_string())? {+ svgtypes::SimplePathSegment::MoveTo { x, y } => {+ if cur.len() > 2 { rings.push(std::mem::take(&mut cur)); } else { cur.clear(); }+ cur.push((x, y)); last = (x, y);+ }+ svgtypes::SimplePathSegment::LineTo { x, y } => { cur.push((x, y)); last = (x, y); }+ svgtypes::SimplePathSegment::CurveTo { x1, y1, x2, y2, x, y } => { flatten_cubic(&mut cur, last, (x1, y1), (x2, y2), (x, y), tol); last = (x, y); }+ svgtypes::SimplePathSegment::Quadratic { x1, y1, x, y } => {+ let c1 = (last.0 + 2.0 / 3.0 * (x1 - last.0), last.1 + 2.0 / 3.0 * (y1 - last.1));+ let c2 = (x + 2.0 / 3.0 * (x1 - x), y + 2.0 / 3.0 * (y1 - y));+ flatten_cubic(&mut cur, last, c1, c2, (x, y), tol); last = (x, y);+ }+ svgtypes::SimplePathSegment::ClosePath => {+ if cur.len() > 2 { last = cur[0]; rings.push(std::mem::take(&mut cur)); }+ }+ }+ }+ if cur.len() > 2 { rings.push(cur); }+ Ok(rings)+}++fn ellipse_ring(cx: f64, cy: f64, rx: f64, ry: f64, tol: f64) -> Vec<Pt> {+ let n = ((std::f64::consts::PI * (rx + ry) / (8.0 * tol).sqrt().max(1e-6)).ceil() as usize).clamp(16, 360);+ (0..n).map(|i| { let a = 2.0 * std::f64::consts::PI * i as f64 / n as f64; (cx + rx * a.cos(), cy + ry * a.sin()) }).collect()+}++fn rect_ring(x: f64, y: f64, w: f64, h: f64, rx: f64, ry: f64, tol: f64) -> Vec<Pt> {+ if rx <= 0.0 && ry <= 0.0 { return vec![(x, y), (x + w, y), (x + w, y + h), (x, y + h)]; }+ let (rx, ry) = (if rx > 0.0 { rx } else { ry }.min(w / 2.0), if ry > 0.0 { ry } else { rx }.min(h / 2.0));+ let mut out = Vec::new();+ let corners = [((x + w - rx, y + ry), -90.0), ((x + w - rx, y + h - ry), 0.0), ((x + rx, y + h - ry), 90.0), ((x + rx, y + ry), 180.0)];+ let n = ((rx.max(ry) / tol).sqrt().ceil() as usize).clamp(3, 24);+ for (c, a0) in corners {+ for i in 0..=n {+ let a = (a0 + 90.0 * i as f64 / n as f64).to_radians();+ out.push((c.0 + rx * a.cos(), c.1 + ry * a.sin()));+ }+ }+ out+}++fn point_list(s: &str) -> Vec<Pt> {+ let v: Vec<f64> = svgtypes::NumberListParser::from(s).filter_map(|n| n.ok()).collect();+ v.chunks(2).filter(|c| c.len() == 2).map(|c| (c[0], c[1])).collect()+}++pub struct Art { pub rings: Vec<Vec<Pt>>, pub skipped: Vec<Value>, pub mm_per_unit: Option<f64> }++/// Every filled shape of the SVG as rings in the root's user units (y down), transforms applied.+pub fn parse(text: &str, tol_units: f64) -> Result<Art, String> {+ let doc = roxmltree::Document::parse_with_options(text, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).map_err(|e| format!("not an SVG: {e}"))?;+ let root = doc.root_element();+ if root.tag_name().name() != "svg" { return Err("the document's root is not <svg>".into()); }+ // the size of one user unit in mm, when the SVG says it (width="40mm" viewBox="0 0 400 200")+ let mm_per_unit = root.attribute("width").and_then(|w| svgtypes::Length::from_str(w).ok()).and_then(|l| {+ let mm = match l.unit {+ svgtypes::LengthUnit::Mm => l.number, svgtypes::LengthUnit::Cm => l.number * 10.0, svgtypes::LengthUnit::In => l.number * 25.4,+ svgtypes::LengthUnit::Pt => l.number * 25.4 / 72.0, svgtypes::LengthUnit::Pc => l.number * 25.4 / 6.0,+ svgtypes::LengthUnit::Px | svgtypes::LengthUnit::None => l.number * 25.4 / 96.0,+ _ => return None,+ };+ let vbw = root.attribute("viewBox").map(|v| point_list(v)).filter(|v| v.len() == 2).map(|v| v[1].0).unwrap_or(l.number);+ (vbw > 0.0).then_some(mm / vbw)+ });+ let mut art = Art { rings: Vec::new(), skipped: Vec::new(), mm_per_unit };+ let mut stack: Vec<(roxmltree::Node, Tf)> = vec![(root, Tf::id())];+ while let Some((n, tf)) = stack.pop() {+ for c in n.children().filter(|c| c.is_element()) {+ let name = c.tag_name().name();+ let mut t = tf;+ if let Some(s) = c.attribute("transform") {+ match svgtypes::Transform::from_str(s) {+ Ok(x) => t = tf.then(&Tf { a: x.a, b: x.b, c: x.c, d: x.d, e: x.e, f: x.f }),+ Err(e) => { art.skipped.push(json!({"element": name, "why": format!("transform not read: {e}")})); continue; }+ }+ }+ if style(&c, "display").as_deref() == Some("none") { continue; }+ match name {+ "g" | "a" | "switch" => { stack.push((c, t)); continue; }+ "defs" | "clipPath" | "mask" | "pattern" | "symbol" | "marker" | "title" | "desc" | "metadata" | "style" | "linearGradient" | "radialGradient" | "filter" => continue,+ "text" | "image" | "use" | "foreignObject" => { art.skipped.push(json!({"element": name, "why": match name { "text" => "text: convert it to paths first", "image" => "a bitmap image", _ => "not imported" }})); continue; }+ "line" => { art.skipped.push(json!({"element": name, "why": "a line has no area"})); continue; }+ _ => {}+ }+ if inherited(&c, "visibility").as_deref() == Some("hidden") { continue; }+ if inherited(&c, "fill").as_deref() == Some("none") {+ art.skipped.push(json!({"element": name, "id": c.attribute("id"), "why": "no fill (stroke only): give the SVG filled shapes"}));+ continue;+ }+ // flatten in the element's own units, to the tolerance asked in the root's units+ let tol = (tol_units / t.scale()).max(1e-6);+ let local: Vec<Vec<Pt>> = match name {+ "path" => match path_rings(c.attribute("d").unwrap_or(""), tol) { Ok(r) => r, Err(e) => { art.skipped.push(json!({"element": name, "why": format!("path data not read: {e}")})); continue; } },+ "rect" => vec![rect_ring(num(&c, "x"), num(&c, "y"), num(&c, "width"), num(&c, "height"), num(&c, "rx"), num(&c, "ry"), tol)],+ "circle" => vec![ellipse_ring(num(&c, "cx"), num(&c, "cy"), num(&c, "r"), num(&c, "r"), tol)],+ "ellipse" => vec![ellipse_ring(num(&c, "cx"), num(&c, "cy"), num(&c, "rx"), num(&c, "ry"), tol)],+ "polygon" | "polyline" => vec![point_list(c.attribute("points").unwrap_or(""))],+ other => { art.skipped.push(json!({"element": other, "why": "not a shape this import reads"})); continue; }+ };+ for r in local.into_iter().filter(|r| r.len() > 2) {+ art.rings.push(r.into_iter().map(|p| t.ap(p)).collect());+ }+ }+ }+ Ok(art)+}++fn area(r: &[Pt]) -> f64 {+ let n = r.len();+ (0..n).map(|i| { let (a, b) = (r[i], r[(i + 1) % n]); a.0 * b.1 - b.0 * a.1 }).sum::<f64>() / 2.0+}++fn inside(p: Pt, r: &[Pt]) -> bool {+ let mut c = false;+ let n = r.len();+ let mut j = n - 1;+ for i in 0..n {+ let (a, b) = (r[i], r[j]);+ if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 { c = !c; }+ j = i;+ }+ c+}++fn cross(o: Pt, a: Pt, b: Pt) -> f64 { (a.0 - o.0) * (b.1 - o.1) - (a.1 - o.1) * (b.0 - o.0) }++/// Do segments ab and cd cross (touching at an end does not count)?+fn crosses(a: Pt, b: Pt, c: Pt, d: Pt) -> bool {+ let (d1, d2, d3, d4) = (cross(c, d, a), cross(c, d, b), cross(a, b, c), cross(a, b, d));+ ((d1 > 1e-12 && d2 < -1e-12) || (d1 < -1e-12 && d2 > 1e-12)) && ((d3 > 1e-12 && d4 < -1e-12) || (d3 < -1e-12 && d4 > 1e-12))+}++/// Group rings into shapes with holes by nesting (a ring inside an odd number of others is a hole of its tightest+/// container), then make each shape one contour that reaches its holes through zero-width slits.+pub fn contours(rings: &[Vec<Pt>]) -> Vec<Vec<Pt>> {+ let n = rings.len();+ let depth: Vec<usize> = (0..n).map(|i| (0..n).filter(|&j| j != i && inside(rings[i][0], &rings[j])).count()).collect();+ let mut out = Vec::new();+ for o in (0..n).filter(|&i| depth[i] % 2 == 0) {+ // counter-clockwise outside, clockwise holes+ let mut c = rings[o].clone();+ if area(&c) < 0.0 { c.reverse(); }+ let mut holes: Vec<Vec<Pt>> = (0..n).filter(|&h| depth[h] == depth[o] + 1 && inside(rings[h][0], &rings[o])+ && (0..n).all(|k| k == o || k == h || depth[k] != depth[o] + 1 || !inside(rings[h][0], &rings[k]) || depth[k] != depth[h]))+ .map(|h| { let mut r = rings[h].clone(); if area(&r) > 0.0 { r.reverse(); } r }).collect();+ // the leftmost holes first, each joined to the nearest point of the contour so far that it can see+ holes.sort_by(|a, b| a.iter().map(|p| p.0).fold(f64::MAX, f64::min).partial_cmp(&b.iter().map(|p| p.0).fold(f64::MAX, f64::min)).unwrap());+ for (hi, h) in holes.iter().enumerate() {+ let (hv, _) = h.iter().enumerate().min_by(|a, b| a.1 .0.partial_cmp(&b.1 .0).unwrap()).unwrap();+ let hp = h[hv];+ let others: Vec<&Vec<Pt>> = holes.iter().enumerate().filter(|(k, _)| *k > hi).map(|(_, r)| r).collect();+ let visible = |q: Pt| {+ let segs = |r: &[Pt]| (0..r.len()).any(|i| crosses(hp, q, r[i], r[(i + 1) % r.len()]));+ !segs(&c) && !segs(h) && !others.iter().any(|r| segs(r))+ };+ let mut best: Option<(usize, f64)> = None;+ for (i, q) in c.iter().enumerate() {+ let d = (q.0 - hp.0).hypot(q.1 - hp.1);+ if best.map(|b| d < b.1).unwrap_or(true) && visible(*q) { best = Some((i, d)); }+ }+ let Some((ci, _)) = best else { continue };+ let mut merged: Vec<Pt> = c[..=ci].to_vec();+ merged.extend(h[hv..].iter().chain(h[..=hv].iter()).copied());+ merged.push(c[ci]);+ merged.extend_from_slice(&c[ci + 1..]);+ c = merged;+ }+ out.push(c);+ }+ out+}++/// writers::svg_to_regions: `{svg, args: {at: [x, y] mm, widthMm?, scale?, anchor?: topleft|center, layer?, mirror?,+/// toleranceMm?}}` -> the regions in board mm (y up), each one closed contour with its holes slit in.+pub fn svg_to_regions(input: &Value) -> Value {+ let bad = |m: &str| crate::input::refuse("bad_args", m);+ let args = input.get("args").cloned().unwrap_or(json!({}));+ let Some(text) = input.get("svg").and_then(|v| v.as_str()).or_else(|| args.get("svg").and_then(|v| v.as_str())) else { return bad("svg (the SVG text) is required") };+ let Some(at) = args.get("at").and_then(|v| v.as_array()).filter(|a| a.len() == 2).and_then(|a| Some((a[0].as_f64()?, a[1].as_f64()?))) else { return bad("args.at [x, y] in mm is required") };+ let layer = args.get("layer").and_then(|v| v.as_str()).unwrap_or("F.SilkS").to_string();+ let tol_mm = args.get("toleranceMm").and_then(|v| v.as_f64()).unwrap_or(0.02).max(0.001);+ // a first pass in the SVG's own units learns the size, then it is flattened again to the tolerance in mm+ let probe = match parse(text, 0.5) { Ok(a) => a, Err(e) => return crate::input::refuse("bad_svg", &e) };+ let pts: Vec<Pt> = probe.rings.iter().flatten().copied().collect();+ if pts.is_empty() {+ let mut r = crate::input::refuse("nothing_to_import", "the SVG has no filled shape");+ r["skipped"] = json!(probe.skipped);+ r["_hint"] = json!("Filled shapes import; stroke-only artwork and text do not (convert text and strokes to filled paths first).");+ return r;+ }+ let (x0, y0, x1, y1) = pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)));+ let (w, h) = ((x1 - x0).max(1e-9), (y1 - y0).max(1e-9));+ let scale = args.get("scale").and_then(|v| v.as_f64()).unwrap_or(1.0);+ let k = match args.get("widthMm").and_then(|v| v.as_f64()) {+ Some(wm) if wm > 0.0 => wm / w,+ _ => probe.mm_per_unit.unwrap_or(25.4 / 96.0) * scale,+ };+ let art = match parse(text, tol_mm / k) { Ok(a) => a, Err(e) => return crate::input::refuse("bad_svg", &e) };+ let mirror = args.get("mirror").and_then(|v| v.as_bool()).unwrap_or(layer.starts_with("B."));+ let center = args.get("anchor").and_then(|v| v.as_str()) == Some("center");+ let (wm, hm) = (w * k, h * k);+ // SVG y runs down, the board's up; the artwork's top-left (or centre) lands on at+ let to_board = |p: Pt| -> Pt {+ let u = if mirror { x1 - p.0 } else { p.0 - x0 } * k;+ let v = (p.1 - y0) * k;+ if center { (at.0 - wm / 2.0 + u, at.1 + hm / 2.0 - v) } else { (at.0 + u, at.1 - v) }+ };+ let rings: Vec<Vec<Pt>> = art.rings.iter().map(|r| r.iter().map(|p| to_board(*p)).collect()).collect();+ let shapes = contours(&rings);+ let r4 = |v: f64| (v * 10000.0).round() / 10000.0;+ json!({"success": true, "regions": shapes.iter().map(|s| json!({"layer": layer, "points": s.iter().map(|p| json!([r4(p.0), r4(p.1)])).collect::<Vec<_>>()})).collect::<Vec<_>>(),+ "polygons": shapes.len(), "holes": rings.len() - shapes.len(), "points": shapes.iter().map(|s| s.len()).sum::<usize>(),+ "sizeMm": [r4(wm), r4(hm)], "layer": layer, "mirror": mirror, "skipped": art.skipped, "writtenFrom": "svg",+ "_hint": "Each region is one closed contour in board mm; its holes are reached through zero-width slits, so a tool that fills a separate hole contour still prints them open."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn a_square_with_a_square_hole_is_one_contour_with_a_slit() {+ let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40mm" height="40mm" viewBox="0 0 40 40">+ <path d="M0 0 H40 V40 H0 Z M10 10 V30 H30 V10 Z"/></svg>"#;+ let art = parse(svg, 0.01).unwrap();+ assert_eq!(art.rings.len(), 2);+ assert_eq!(art.mm_per_unit, Some(1.0));+ let c = contours(&art.rings);+ assert_eq!(c.len(), 1);+ // 4 outside, 4 + 1 around the hole, back to the outside: 10 points; the area is 40^2 - 20^2+ assert_eq!(c[0].len(), 10);+ assert!((area(&c[0]).abs() - 1200.0).abs() < 1e-6, "{}", area(&c[0]));+ }++ #[test]+ fn stroke_only_and_text_are_skipped_with_a_reason() {+ let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="black"><path d="M0 0 L10 10"/></g>+ <text>Hi</text><circle cx="5" cy="5" r="2"/></svg>"#;+ let art = parse(svg, 0.01).unwrap();+ assert_eq!(art.rings.len(), 1);+ assert_eq!(art.skipped.len(), 2);+ }++ #[test]+ fn transforms_and_arcs_are_applied() {+ let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><g transform="translate(100 0) scale(2)">+ <path d="M0 0 A5 5 0 0 1 10 0 Z"/></g></svg>"#;+ let art = parse(svg, 0.001).unwrap();+ let xs: Vec<f64> = art.rings[0].iter().map(|p| p.0).collect();+ assert!(xs.iter().cloned().fold(f64::MAX, f64::min) >= 99.999 && xs.iter().cloned().fold(f64::MIN, f64::max) <= 120.001);+ // a half circle of radius 10 after the scale+ assert!((area(&art.rings[0]).abs() - std::f64::consts::PI * 50.0).abs() < 0.5, "{}", area(&art.rings[0]));+ }++ #[test]+ fn the_regions_land_on_the_board_with_y_up() {+ let r = svg_to_regions(&json!({"svg": r#"<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="10mm" viewBox="0 0 20 10"><rect x="0" y="0" width="20" height="10"/></svg>"#,+ "args": {"at": [5.0, 15.0]}}));+ assert_eq!(r["polygons"], 1);+ assert_eq!(r["sizeMm"], json!([20.0, 10.0]));+ let ys: Vec<f64> = r["regions"][0]["points"].as_array().unwrap().iter().map(|p| p[1].as_f64().unwrap()).collect();+ // the top-left at (5, 15): the artwork runs down to y = 5+ assert_eq!(ys.iter().cloned().fold(f64::MIN, f64::max), 15.0);+ assert_eq!(ys.iter().cloned().fold(f64::MAX, f64::min), 5.0);+ // a bottom layer mirrors it by default+ let b = svg_to_regions(&json!({"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"4\" height=\"2\"/></svg>", "args": {"at": [0, 0], "layer": "B.SilkS"}}));+ assert_eq!(b["mirror"], true);+ assert_eq!(svg_to_regions(&json!({"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\"/>", "args": {"at": [0, 0]}}))["errorCode"], "nothing_to_import");+ }++ #[test]+ fn letter_o_holes_go_to_their_own_letter() {+ // two rings each with a hole: two contours, each reaching only its own hole+ let svg = r#"<svg xmlns="http://www.w3.org/2000/svg">+ <path d="M0 0 H10 V10 H0 Z M3 3 V7 H7 V3 Z"/><path d="M20 0 H30 V10 H20 Z M23 3 V7 H27 V3 Z"/></svg>"#;+ let c = contours(&parse(svg, 0.01).unwrap().rings);+ assert_eq!(c.len(), 2);+ for s in &c { assert!((area(s).abs() - 84.0).abs() < 1e-6); }+ }+}
tools/release.sh+12−2@@ -371,14 +371,24 @@ PUSH_FILES="server.py fusion_detect.py describe.py timings.py aps.py install_add for f in handlers/*.py addin/AdomBridge/*.py addin/AdomBridge/commands/*.py addin/AdomBridge/commands/*/*.py addin/AdomBridge/commands/*/*.json \ addin/AdomBridge/*.manifest skills/*/SKILL.md tools/release.sh CLAUDE.md \ tools/promote.sh tools/promote.py tools/check-portable.sh tools/check-naming.sh tools/freeze-contract.py \- contracts/describe.json rust/Cargo.toml rust/PORTING.md \+ contracts/describe.json rust/Cargo.toml rust/Cargo.lock rust/PORTING.md \ rust/*/Cargo.toml rust/*/src/*.rs rust/*/src/*/*.rs rust/bridge/src/*.py rust/bridge/src/*.json rust/bridge/src/*.xml \- rust/vendor/*/Cargo.toml rust/vendor/*/src/*.rs rust/vendor/*/*.md \+ rust/vendor/*/Cargo.toml rust/vendor/*/src/*.rs rust/vendor/*/src/*/*.rs rust/vendor/*/*.md \+ rust/*/src/*.xml rust/*/tests/*.rs \ tools/sign_pe.py tools/gen-readme-verbs.py tests/*.py contracts/eda-bridge-standard/*.json CHANGELOG.md \ library_bundle.py README.md docs/*.md docs/img/*.svg docs/img/*.png \ docs/img/*/*.png dashboard/ui/*.html; do [ -f "$f" ] && PUSH_FILES="$PUSH_FILES $f" done+# the whole Rust tree as tracked (sources, include_str! data, test goldens and fixtures), never a glob list+for f in $(git ls-files rust ':!:rust/target*'); do+ case " $PUSH_FILES " in *" $f "*) ;; *) PUSH_FILES="$PUSH_FILES $f";; esac+done+# Every tracked Rust source must be on the page, or master does not build from source there:+# 1.13.2 shipped without the vendored engine's src/writers/ and with a stale Cargo.lock (Drew, PR #23).+MISSING=$(git ls-files rust ':!:rust/target*' | grep -E '\.(rs|toml|lock|xml|json|py)$' | while read -r f; do+ case " $PUSH_FILES " in *" $f "*) ;; *) echo "$f";; esac; done)+[ -z "$MISSING" ] || die "these tracked Rust sources are not in the push list, so the page would not build: $(echo $MISSING)" echo " pushing $(echo $PUSH_FILES | wc -w) files" adom-wiki repo push "$REPO" --files $PUSH_FILES --json 2>/dev/null | ok_check || die "repo push FAILED" echo " pushed"