← Commit history

Merge PR #314: altium-lane: engine/src/writers (GenCAD, DXF, SVG to regions) for #16

Colby Knox ·e833a93f87 ·13d ago ·parent 8f613d7
4 files changed +874
engine/src/writers/dxf.rsadded+248
@@ -0,0 +1,248 @@+//! 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.+pub fn layer_dxf(state: &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);+            }+        }+        for g in arr("polygons").iter().filter(|g| g["layer"].as_str() == Some(layer)) {+            let pts: Vec<(f64, f64, f64)> = g["points"].as_array().map(|ps| ps.iter().map(|p| (f(&p[0]), f(&p[1]), 0.0)).collect()).unwrap_or_default();+            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, args: {layers, contours?, units?: mm|in}}` -> one DXF text per layer.+pub fn dxf_layers(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 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(&board, 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()),+        }+    }+    json!({"success": unknown.is_empty() && !files.is_empty(), "files": files, "unknownLayers": unknown, "units": if inches { "in" } else { "mm" },+        "writtenFrom": "board model",+        "_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."})+}++#[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");+    }+}
engine/src/writers/gencad.rsadded+254
@@ -0,0 +1,254 @@+//! 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: each footprint pattern once, from its first part+    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+    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 name = if pattern.is_empty() { format!("SHAPE_{rf}") } else { pattern.clone() };+        shape_of.push((rf.clone(), name.clone()));+        if shapes.iter().any(|s| s.0 == name) { continue; }+        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 });+        }+        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"));+    }+}
engine/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,+    })+}
engine/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); }+    }+}